feat(canvas):画布 - 相机控制(虚拟摄影机) (#473)
* refactor(canvas): 重构相机控制UI,拆分独立组件并调整位置 将原图像设置弹窗中的相机控制功能抽离为独立组件,调整相机控制按钮到图像设置前的布局,简化原弹窗组件代码 * refactor(canvas-toolbar): 调整光影工具分组与渲染逻辑 将原视角分组中的光影工具拆分到独立lighting分组,更新类型定义与工具栏渲染逻辑,让工具分组更贴合功能场景 --------- Co-authored-by: bensharp <bensharp@qq.com>
This commit is contained in:
@@ -1,14 +1,11 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Camera, Settings2 } from "lucide-react";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { Button } from "antd";
|
||||
|
||||
import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel";
|
||||
import { CanvasNodeCameraPanel } from "@/components/canvas/canvas-node-camera-dialog";
|
||||
import { AppModal } from "@/components/ui/product/app-modal/app-modal";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
|
||||
import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
@@ -22,17 +19,14 @@ type CanvasImageSettingsPopoverProps = {
|
||||
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
|
||||
autoAdjustOverflow?: boolean;
|
||||
showCount?: boolean;
|
||||
cameraControl?: CameraControlOptions;
|
||||
onCameraControlChange?: (options: CameraControlOptions) => void;
|
||||
};
|
||||
|
||||
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft", showCount = true, cameraControl, onCameraControlChange }: CanvasImageSettingsPopoverProps) {
|
||||
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft", showCount = true }: CanvasImageSettingsPopoverProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const buttonRef = useRef<HTMLSpanElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
|
||||
const [cameraOpen, setCameraOpen] = useState(false);
|
||||
const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image!;
|
||||
const normalized = normalizeImageValue(profile, config);
|
||||
const summaryParts = [
|
||||
@@ -73,48 +67,16 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
|
||||
|
||||
const panel = open && buttonRect ? <ImageSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} showCount={showCount} onConfigChange={onConfigChange} /> : null;
|
||||
|
||||
if (!hasSettings && !onCameraControlChange) return null;
|
||||
|
||||
const cameraEnabled = cameraControl?.enabled === true;
|
||||
if (!hasSettings) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{onCameraControlChange && (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
className="canvas-camera-control-trigger !h-8 !rounded-full !px-2.5"
|
||||
style={{
|
||||
background: cameraEnabled ? theme.node.activeStroke : theme.node.fill,
|
||||
color: cameraEnabled ? theme.node.panel : theme.node.text,
|
||||
}}
|
||||
icon={<Camera className="size-3.5" />}
|
||||
aria-pressed={cameraEnabled}
|
||||
aria-label="摄像机控制"
|
||||
title={`摄像机控制${cameraEnabled ? " · 已启用" : ""}`}
|
||||
onClick={() => setCameraOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{hasSettings && (
|
||||
<span ref={buttonRef} className="inline-flex min-w-0">
|
||||
<Button size="small" type="text" className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"}`} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} aria-expanded={open} aria-label={`图像设置:${summary}`} title={`图像设置 · ${summary}`} onClick={() => updateOpen(!open)}>
|
||||
<span className="truncate">{summary}</span>
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
<span ref={buttonRef} className="inline-flex min-w-0">
|
||||
<Button size="small" type="text" className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"}`} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} aria-expanded={open} aria-label={`图像设置:${summary}`} title={`图像设置 · ${summary}`} onClick={() => updateOpen(!open)}>
|
||||
<span className="truncate">{summary}</span>
|
||||
</Button>
|
||||
</span>
|
||||
{panel}
|
||||
{cameraOpen && onCameraControlChange && (
|
||||
<AppModal title="摄像机控制" open centered footer={null} width={780} flush onCancel={() => setCameraOpen(false)}>
|
||||
<CanvasNodeCameraPanel
|
||||
cameraControl={cameraControl}
|
||||
onClose={() => setCameraOpen(false)}
|
||||
onConfirm={(options, _prompt) => {
|
||||
onCameraControlChange(options);
|
||||
setCameraOpen(false);
|
||||
}}
|
||||
/>
|
||||
</AppModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ const imageToolDefinitions: ImageToolDefinition[] = [
|
||||
section: "视角",
|
||||
description: "调整光线方向、亮度与光效,生成新图片",
|
||||
icon: () => <Sun className="size-3.5" />,
|
||||
group: "viewpoint",
|
||||
group: "lighting",
|
||||
order: 30,
|
||||
run: (node, handlers) => handlers.onLighting(node),
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||
import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
|
||||
import { ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
@@ -14,7 +14,10 @@ import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefa
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { AppModal } from "@/components/ui/product/app-modal/app-modal";
|
||||
import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library";
|
||||
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
||||
import { CanvasNodeCameraPanel } from "./canvas-node-camera-dialog";
|
||||
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
||||
@@ -392,16 +395,23 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
/>
|
||||
</Tooltip>
|
||||
) : mode === "image" ? (
|
||||
<CanvasImageSettingsPopover
|
||||
config={config}
|
||||
placement={expanded ? "topRight" : "topLeft"}
|
||||
buttonClassName="canvas-node-composer-settings-trigger [&>span]:min-w-0 [&_.lucide]:!size-3"
|
||||
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
|
||||
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
|
||||
onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
|
||||
cameraControl={node.metadata?.cameraControl}
|
||||
onCameraControlChange={(options) => onConfigChange(node.id, { cameraControl: options })}
|
||||
/>
|
||||
// 图片模式下,显示相机配置与镜头配置
|
||||
<>
|
||||
<CameraToolsPopover
|
||||
cameraControl={node.metadata?.cameraControl}
|
||||
onCameraControlChange={(options) => onConfigChange(node.id, { cameraControl: options })}
|
||||
theme={theme}
|
||||
compact={!expanded}
|
||||
/>
|
||||
<CanvasImageSettingsPopover
|
||||
config={config}
|
||||
placement={expanded ? "topRight" : "topLeft"}
|
||||
buttonClassName="canvas-node-composer-settings-trigger [&>span]:min-w-0 [&_.lucide]:!size-3"
|
||||
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
|
||||
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
|
||||
onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
|
||||
/>
|
||||
</>
|
||||
) : mode === "video" ? (
|
||||
<CanvasVideoSettingsPopover
|
||||
config={config}
|
||||
@@ -537,6 +547,42 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
);
|
||||
}
|
||||
|
||||
function CameraToolsPopover({ cameraControl, onCameraControlChange, theme, compact }: { cameraControl?: CameraControlOptions; onCameraControlChange: (options: CameraControlOptions) => void; theme: CanvasTheme; compact: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const cameraEnabled = cameraControl?.enabled === true;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`canvas-node-composer-camera-tools-trigger inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 text-[var(--fs-tiny)] transition-colors ${compact ? "is-compact h-7" : "h-8"}`}
|
||||
style={{
|
||||
background: cameraEnabled ? `${theme.node.activeStroke}66` : theme.node.fill,
|
||||
color: cameraEnabled ? theme.node.panel : theme.node.text,
|
||||
}}
|
||||
aria-pressed={cameraEnabled}
|
||||
aria-label="摄像机控制"
|
||||
title={`摄像机控制${cameraEnabled ? " · 已启用" : ""}`}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Camera className="size-4.5" />
|
||||
{!compact ? <span>摄像机</span> : null}
|
||||
</button>
|
||||
{open ? (
|
||||
<AppModal title="摄像机控制" open centered footer={null} width={780} flush onCancel={() => setOpen(false)}>
|
||||
<CanvasNodeCameraPanel
|
||||
cameraControl={cameraControl}
|
||||
onClose={() => setOpen(false)}
|
||||
onConfirm={(options) => {
|
||||
onCameraControlChange(options);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</AppModal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferenceToolsPopover({ canAutoMention, autoLinkEnabled, onAutoMention, onAutoLinkEnabledChange, accent, compact }: { canAutoMention: boolean; autoLinkEnabled: boolean; onAutoMention: () => void; onAutoLinkEnabledChange: (enabled: boolean) => void; accent: string; compact: boolean }) {
|
||||
return (
|
||||
<Popover
|
||||
@@ -583,7 +629,7 @@ function ReferenceToolsPopover({ canAutoMention, autoLinkEnabled, onAutoMention,
|
||||
aria-label="打开智能引用"
|
||||
title="智能引用"
|
||||
>
|
||||
<SlidersHorizontal className="size-3" />
|
||||
<SlidersHorizontal className="size-4.5" />
|
||||
{!compact ? <span>引用</span> : null}
|
||||
</button>
|
||||
</Popover>
|
||||
|
||||
@@ -280,7 +280,8 @@ export function CanvasNodeToolbar({
|
||||
const primaryTools = narrow ? primary.slice(0, 1) : primary;
|
||||
const portraitTools = compact ? [] : inGroup("portrait");
|
||||
const viewpointTools = compact ? [] : inGroup("viewpoint");
|
||||
const processTools = compact ? [...inGroup("portrait"), ...inGroup("viewpoint"), ...inGroup("process")] : inGroup("process");
|
||||
const lightingTools = compact ? [] : inGroup("lighting");
|
||||
const processTools = compact ? [...inGroup("portrait"), ...inGroup("viewpoint"), ...inGroup("lighting"), ...inGroup("process")] : inGroup("process");
|
||||
const workspaceTools = narrow ? [] : inGroup("workspace");
|
||||
const utilityTools = inGroup("utility");
|
||||
const moreTools = [...(narrow ? [...primary.slice(1), ...inGroup("workspace")] : []), ...inGroup("more")];
|
||||
@@ -315,6 +316,7 @@ export function CanvasNodeToolbar({
|
||||
{primaryTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
{portraitTools.length ? <NodeDockMenuButton menuId="portrait" label="人像调整" icon={<UserRound className="size-3.5" />} tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
|
||||
{viewpointTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
{lightingTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
{processTools.length ? <NodeDockMenuButton menuId="process" label={processMenuLabel} icon={isVideo ? <Images className="size-3.5" /> : <SlidersHorizontal className="size-3.5" />} tools={processTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
|
||||
{workspaceTools.length ? <span aria-hidden className="aceternity-dock-separator mx-1 h-5 w-px shrink-0" /> : null}
|
||||
{workspaceTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeTypeId, CanvasToolMo
|
||||
|
||||
/** 工具栏标识——每个工具栏有独立的注册表与偏好 */
|
||||
export type ToolbarId = "main" | "selection" | "node-hover" | "add-node-menu";
|
||||
export type NodeToolbarGroup = "primary" | "portrait" | "viewpoint" | "process" | "workspace" | "utility" | "more";
|
||||
export type NodeToolbarGroup = "primary" | "portrait" | "viewpoint" | "lighting" | "process" | "workspace" | "utility" | "more";
|
||||
|
||||
/** 工具分类——用于分组渲染、危险隔离与 separator 自动插入 */
|
||||
export type ToolCategory =
|
||||
|
||||
Reference in New Issue
Block a user