From d89a5a5b7b515f0ebf2e003f561e5e5f537f40ba Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Fri, 14 Aug 2026 23:44:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor(*):=20=E7=94=A8=E6=88=B7=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=20-=20=E7=BB=9F=E4=B8=80=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E3=80=81=E4=BB=BB=E5=8A=A1=E4=B8=AD=E5=BF=83=E4=B8=8E=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E4=BA=A7=E5=93=81=E5=8C=96=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../canvas/canvas-audio-settings-popover.tsx | 2 +- .../components/canvas/canvas-connections.tsx | 8 +- .../components/canvas/canvas-context-menu.tsx | 4 +- .../canvas/canvas-image-settings-popover.tsx | 2 +- .../canvas-image-toolbar-settings-modal.tsx | 84 -- .../canvas/canvas-image-toolbar-tools.tsx | 78 +- .../components/canvas/canvas-node-content.tsx | 513 ++++++++++++ .../canvas/canvas-node-prompt-panel.tsx | 329 ++++---- .../components/canvas/canvas-node-toolbar.tsx | 281 +++---- web/src/components/canvas/canvas-node.tsx | 727 +----------------- web/src/components/canvas/canvas-toolbar.tsx | 4 +- .../canvas/canvas-video-settings-popover.tsx | 2 +- .../canvas/canvas-workspace-overlays.tsx | 20 +- web/src/components/canvas/infinite-canvas.tsx | 2 +- web/src/components/layout/workspace-page.tsx | 2 +- web/src/components/layout/workspace-state.tsx | 2 +- web/src/components/model-picker.tsx | 2 +- web/src/constant/canvas.ts | 4 +- web/src/lib/canvas/canvas-aceternity-style.ts | 6 +- web/src/lib/canvas/canvas-node-size.ts | 20 +- web/src/pages/canvas/project.tsx | 2 +- .../pages/settings/channel-settings-pane.tsx | 334 ++++++++ .../pages/settings/channel-video-pricing.tsx | 8 +- web/src/pages/settings/index.tsx | 688 +++-------------- web/src/pages/settings/model-default-grid.tsx | 21 +- web/src/pages/tasks/index.tsx | 25 +- web/src/pages/tasks/task-stat-pills.tsx | 36 - web/src/pages/tasks/task-status-filter.tsx | 29 + web/src/styles/globals.css | 494 +++++++----- 29 files changed, 1662 insertions(+), 2067 deletions(-) delete mode 100644 web/src/components/canvas/canvas-image-toolbar-settings-modal.tsx create mode 100644 web/src/components/canvas/canvas-node-content.tsx create mode 100644 web/src/pages/settings/channel-settings-pane.tsx delete mode 100644 web/src/pages/tasks/task-stat-pills.tsx create mode 100644 web/src/pages/tasks/task-status-filter.tsx diff --git a/web/src/components/canvas/canvas-audio-settings-popover.tsx b/web/src/components/canvas/canvas-audio-settings-popover.tsx index 0156552c3..cf1a33b06 100644 --- a/web/src/components/canvas/canvas-audio-settings-popover.tsx +++ b/web/src/components/canvas/canvas-audio-settings-popover.tsx @@ -85,7 +85,7 @@ function AudioSettingsPortal({ const topPlacement = placement?.startsWith("top"); const style = { position: "fixed", - zIndex: "var(--z-popover)", + zIndex: "var(--z-dialog-popover)", width, left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)), ...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }), diff --git a/web/src/components/canvas/canvas-connections.tsx b/web/src/components/canvas/canvas-connections.tsx index 09f200d3e..56058bc63 100644 --- a/web/src/components/canvas/canvas-connections.tsx +++ b/web/src/components/canvas/canvas-connections.tsx @@ -66,9 +66,9 @@ export const ConnectionPath = React.memo(function ConnectionPath({ {showVisual ? : null} ); diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx index b517864eb..1d14ea970 100644 --- a/web/src/components/canvas/canvas-context-menu.tsx +++ b/web/src/components/canvas/canvas-context-menu.tsx @@ -145,7 +145,7 @@ export function CanvasNodeContextMenu({ animate={{ opacity: 1, scale: 1, x: 0, y: 0 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel fixed z-[var(--z-popover)] flex w-[224px] max-h-[calc(100vh-56px)] origin-top-left flex-col overflow-hidden rounded-xl border p-1.5 backdrop-blur-2xl" - style={{ left: position.left, top: position.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 30px 90px ${theme.spatial.shadow}` }} + style={{ left: position.left, top: position.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} onContextMenu={(event) => event.preventDefault()} onPointerDown={(event) => event.stopPropagation()} > @@ -288,7 +288,7 @@ function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, re exit={reducedMotion ? { opacity: 0 } : { opacity: 0, x: left > parentPosition.left ? -4 : 4, scale: 0.98 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel fixed z-[var(--z-popover)] w-[260px] origin-top overflow-hidden rounded-[var(--dock-radius)] border p-2 backdrop-blur-2xl" - style={{ left, top: parentPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 30px 90px ${theme.spatial.shadow}` }} + style={{ left, top: parentPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} onContextMenu={(event) => event.preventDefault()} onPointerDown={(event) => event.stopPropagation()} > diff --git a/web/src/components/canvas/canvas-image-settings-popover.tsx b/web/src/components/canvas/canvas-image-settings-popover.tsx index 5ab180efb..bc453a6a0 100644 --- a/web/src/components/canvas/canvas-image-settings-popover.tsx +++ b/web/src/components/canvas/canvas-image-settings-popover.tsx @@ -107,7 +107,7 @@ function ImageSettingsPortal({ const topPlacement = placement?.startsWith("top"); const style = { position: "fixed", - zIndex: "var(--z-popover)", + zIndex: "var(--z-dialog-popover)", width, left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)), ...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }), diff --git a/web/src/components/canvas/canvas-image-toolbar-settings-modal.tsx b/web/src/components/canvas/canvas-image-toolbar-settings-modal.tsx deleted file mode 100644 index 30bf39de0..000000000 --- a/web/src/components/canvas/canvas-image-toolbar-settings-modal.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useMemo, type ReactNode } from "react"; -import { Button, Checkbox, Modal, Space, Switch } from "antd"; -import { Ellipsis, Settings2, Type } from "lucide-react"; - -import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock"; -import { canvasThemes } from "@/lib/canvas-theme"; -import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style"; -import { useThemeStore } from "@/stores/use-theme-store"; -import type { ImageQuickToolId } from "./canvas-image-toolbar-tools"; - -export type ImageToolbarSettingsTool = { - id: ImageQuickToolId; - title: string; - label: string; - icon: ReactNode; - active?: boolean; - danger?: boolean; -}; - -export function ImageToolSettingsModal({ open, tools, selectedIds, showLabels, onToggle, onShowLabelsChange, onCancel, onSave }: { - open: boolean; - tools: ImageToolbarSettingsTool[]; - selectedIds: ImageQuickToolId[]; - showLabels: boolean; - onToggle: (id: ImageQuickToolId, visible: boolean) => void; - onShowLabelsChange: (visible: boolean) => void; - onCancel: () => void; - onSave: () => void; -}) { - const maxSelected = 7; - const theme = canvasThemes[useThemeStore((state) => state.theme)]; - const selected = useMemo(() => new Set(selectedIds), [selectedIds]); - const selectedTools = tools.filter((tool) => selected.has(tool.id)); - const previewItems: FloatingDockEntry[] = [ - ...selectedTools.map((tool) => ({ id: tool.id, label: tool.title, displayLabel: tool.label, icon: tool.icon, active: tool.active, danger: tool.danger })), - { id: "more", label: "自定义节点工具", displayLabel: "更多", icon: }, - ]; - - const updateSelectedTools = (values: ImageQuickToolId[]) => { - const next = new Set(values); - tools.forEach((tool) => { - const visible = next.has(tool.id); - if (selected.has(tool.id) !== visible) onToggle(tool.id, visible); - }); - }; - - return ( - 自定义节点 Dock} - open={open} - centered - onCancel={onCancel} - destroyOnHidden - styles={{ body: { padding: 0 }, footer: { marginTop: 0 } }} - footer={} - > -
- - - 显示功能名 - - -
-
-
- -
-
-
-
快捷工具{selectedTools.length}/{maxSelected}
- updateSelectedTools(values as ImageQuickToolId[])}> - {tools.map((tool) => ( - - ))} - -
-
- ); -} diff --git a/web/src/components/canvas/canvas-image-toolbar-tools.tsx b/web/src/components/canvas/canvas-image-toolbar-tools.tsx index a83e3cd27..fe1b7188f 100644 --- a/web/src/components/canvas/canvas-image-toolbar-tools.tsx +++ b/web/src/components/canvas/canvas-image-toolbar-tools.tsx @@ -3,10 +3,9 @@ import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, Penc import type { CanvasNodeData } from "@/types/canvas"; -export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "maskEdit" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view"; -export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId; +type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "maskEdit" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view"; -export type ImageToolHandlers = { +type ImageToolHandlers = { onUpload: (node: CanvasNodeData) => void; onToggleFreeResize: (node: CanvasNodeData) => void; onAnnotate: (node: CanvasNodeData) => void; @@ -23,178 +22,109 @@ export type ImageToolHandlers = { onReversePrompt: (node: CanvasNodeData) => void; }; -export type ImageToolDefinition = { +type ImageToolDefinition = { id: ImageNodeActionToolId; - defaultVisible: boolean; - panelLabel: string; label: string | ((node: CanvasNodeData) => string); - title: string | ((node: CanvasNodeData) => string); icon: (node: CanvasNodeData) => ReactNode; - active?: (node: CanvasNodeData) => boolean; run: (node: CanvasNodeData, handlers: ImageToolHandlers) => void; }; -export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v13"; - -const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download", "edit"]; - -export const imageToolDefinitions: ImageToolDefinition[] = [ +const imageToolDefinitions: ImageToolDefinition[] = [ { id: "copyPrompt", - defaultVisible: true, - panelLabel: "复制提示词", label: "复制提示词", - title: "复制生成该图片的提示词", icon: () => , run: (node, handlers) => handlers.onCopyPrompt(node), }, { id: "reversePrompt", - defaultVisible: true, - panelLabel: "反推提示词", label: "反推提示词", - title: "创建反推提示词的文本和配置节点", icon: () => , run: (node, handlers) => handlers.onReversePrompt(node), }, { id: "replace", - defaultVisible: true, - panelLabel: "替换图片", label: "替换图片", - title: "替换图片", icon: () => , run: (node, handlers) => handlers.onUpload(node), }, { id: "resize", - defaultVisible: false, - panelLabel: "锁比例", label: (node) => (node.metadata?.freeResize ? "自由比例" : "锁比例"), - title: (node) => (node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"), icon: (node) => (node.metadata?.freeResize ? : ), - active: (node) => Boolean(node.metadata?.freeResize), run: (node, handlers) => handlers.onToggleFreeResize(node), }, { id: "annotation", - defaultVisible: true, - panelLabel: "标注", label: "标注", - title: "在图片上绘制标记并保存为新节点", icon: () => , run: (node, handlers) => handlers.onAnnotate(node), }, { id: "maskEdit", - defaultVisible: true, - panelLabel: "局部编辑", label: "局部编辑", - title: "添加蒙版遮罩后局部修改", icon: () => , run: (node, handlers) => handlers.onMaskEdit(node), }, { id: "emotion", - defaultVisible: true, - panelLabel: "表情与情绪", label: "情绪", - title: "调整人物表情与情绪", icon: () => , run: (node, handlers) => handlers.onEmotion(node), }, { id: "portraitTexture", - defaultVisible: true, - panelLabel: "人物质感调节", label: "人物质感", - title: "调节人景融合、光影、皮肤、纹理与锐度", icon: () => , run: (node, handlers) => handlers.onPortraitTexture(node), }, { id: "crop", - defaultVisible: true, - panelLabel: "剪裁", label: "剪裁", - title: "剪裁并生成新节点", icon: () => , run: (node, handlers) => handlers.onCrop(node), }, { id: "split", - defaultVisible: true, - panelLabel: "切图", label: "切图", - title: "按行列切分图片", icon: () => , run: (node, handlers) => handlers.onSplit(node), }, { id: "upscale", - defaultVisible: true, - panelLabel: "放大", label: "放大", - title: "放大图片分辨率", icon: () => , run: (node, handlers) => handlers.onUpscale(node), }, { id: "superResolve", - defaultVisible: false, - panelLabel: "超分", label: "超分", - title: "AI 超分", icon: () => , run: (node, handlers) => handlers.onSuperResolve(node), }, { id: "angle", - defaultVisible: true, - panelLabel: "多视角", label: "多视角", - title: "生成不同观察视角", icon: () => , run: (node, handlers) => handlers.onAngle(node), }, { id: "view", - defaultVisible: true, - panelLabel: "查看大图", label: "查看大图", - title: "查看图片详情", icon: () => , run: (node, handlers) => handlers.onViewImage(node), }, ]; -export const defaultImageQuickToolIds: ImageQuickToolId[] = ["info", "download", "maskEdit", "emotion", "portraitTexture", "crop", "angle"]; - -export function isImageQuickToolId(value: string): value is ImageQuickToolId { - return defaultBaseToolIds.some((id) => id === value) || imageToolDefinitions.some((tool) => tool.id === value); -} - export function buildImageToolbarTools(node: CanvasNodeData, handlers: ImageToolHandlers) { return imageToolDefinitions.map((tool) => ({ id: tool.id, label: resolveToolText(tool.label, node), - title: resolveToolText(tool.title, node), icon: tool.icon(node), - active: tool.active?.(node), onClick: () => tool.run(node, handlers), })); } -export function normalizeImageQuickToolIds(value: unknown[]) { - const allIds: ImageQuickToolId[] = [...defaultBaseToolIds, ...imageToolDefinitions.map((tool) => tool.id)]; - const ids = new Set(allIds); - return allIds.filter((id) => value.includes(id) && ids.has(id)); -} - -export function readImageQuickToolsConfig(value: unknown): ImageQuickToolId[] { - return Array.isArray(value) ? normalizeImageQuickToolIds(value).slice(0, 7) : defaultImageQuickToolIds; -} - function resolveToolText(value: string | ((node: CanvasNodeData) => string), node: CanvasNodeData) { return typeof value === "function" ? value(node) : value; } diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx new file mode 100644 index 000000000..ce4c17486 --- /dev/null +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -0,0 +1,513 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"; +import { BookOpenCheck, Clock3, FileText, Image as ImageIcon, LoaderCircle, Music2, Pencil, Play, RefreshCw, Square, Video } from "lucide-react"; + +import { VideoPlayer } from "@/components/video-player"; +import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error"; +import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text"; +import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage"; +import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import type { CanvasTheme } from "@/lib/canvas-theme"; +import { formatBytes } from "@/lib/image-utils"; +import { resourceIdFromStorageKey } from "@/services/api/resources"; +import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache"; +import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; +import { createDefaultSubtitleStyle } from "@/types/timeline"; +import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; +import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay"; + +type CanvasNodeContentProps = { + node: CanvasNodeData; + theme: CanvasTheme; + isEditingContent: boolean; + textareaRef: RefObject; + isBatchRoot: boolean; + batchCount: number; + batchExpanded: boolean; + batchOpening: boolean; + batchRecovering: boolean; + renderNodeContent?: (node: CanvasNodeData) => ReactNode; + drawingProjectId?: string; + onContentChange: (nodeId: string, content: string) => void; + onStopEditing: () => void; + mentionReferences: CanvasResourceReference[]; + onRetry?: (node: CanvasNodeData) => void; + onCancelTask?: (node: CanvasNodeData) => void; + onOpenTaskDetails?: (node: CanvasNodeData) => void; + onToggleBatch?: () => void; + reduceMediaEffects?: boolean; +}; + +export function CanvasNodeContent(props: CanvasNodeContentProps) { + const hasCustomContent = props.node.type === CanvasNodeType.Config + || props.node.type === CanvasNodeType.Script + || Boolean(props.node.metadata?.directorSceneId) + || (props.node.metadata?.workflowKind === "character" && Boolean(props.node.metadata.characterAssetId)) + || (props.node.metadata?.workflowKind === "story_input" && !props.isEditingContent) + || (props.node.metadata?.workflowKind === "styleboard" && !props.node.metadata.content); + if (hasCustomContent && props.renderNodeContent) return props.renderNodeContent(props.node); + if (props.isBatchRoot) return ; + if (props.node.metadata?.status === "loading") return ; + if (props.node.metadata?.status === "error") return ; + + const Renderer = nodeContentRenderers[props.node.type]; + return Renderer ? : ; +} + +const nodeContentRenderers = { + [CanvasNodeType.Text]: TextContent, + [CanvasNodeType.Script]: UnknownNodeContent, + [CanvasNodeType.Skill]: SkillContent, + [CanvasNodeType.Image]: ImageNodeContent, + [CanvasNodeType.Config]: EmptyImageContent, + [CanvasNodeType.Video]: VideoNodeContent, + [CanvasNodeType.Audio]: AudioNodeContent, + [CanvasNodeType.Drawing]: DrawingContent, + [CanvasNodeType.Frame]: UnknownNodeContent, +} satisfies Record ReactNode>; + +function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProps) { + const shapeCount = node.metadata?.drawingShapeCount || 0; + const pageCount = node.metadata?.drawingPageCount || 1; + const [previewUrl, setPreviewUrl] = useState(node.metadata?.drawingPreviewUrl || ""); + + useEffect(() => { + const drawingId = node.metadata?.drawingId; + const fallbackPreview = node.metadata?.drawingPreviewUrl || ""; + setPreviewUrl(fallbackPreview); + if (!drawingProjectId || !drawingId) return; + let active = true; + let objectUrl = ""; + void loadCanvasDrawingPreview(drawingProjectId, drawingId).then((preview) => { + if (!active) return; + if (!preview) { + setPreviewUrl(fallbackPreview); + return; + } + objectUrl = URL.createObjectURL(preview); + setPreviewUrl(objectUrl); + }).catch((error) => console.warn("读取绘图节点预览失败", error)); + return () => { + active = false; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [drawingProjectId, node.metadata?.drawingId, node.metadata?.drawingPreviewUrl, node.metadata?.drawingRevision]); + + return ( +
+ {previewUrl ? ( + 绘图预览 + ) : ( +
+ + + +
+ )} +
+
+
{node.title || "绘图"}
+
{shapeCount} 个图形 · {pageCount} 个页面
+
+ +
+
+ ); +} + +function LoadingContent({ node, theme, onCancelTask, onOpenTaskDetails }: Pick) { + const taskId = node.metadata?.taskId; + const progress = typeof node.metadata?.taskProgress === "number" ? Math.max(0, Math.min(100, Math.round(node.metadata.taskProgress))) : null; + const statusLabel = taskStatusLabel(node.metadata?.taskStatus); + const elapsed = useTaskElapsed(node.metadata?.taskCreatedAt); + return ( +
+
+ {node.metadata?.taskStage || (taskId ? "任务处理中" : "正在创建任务")} + {taskId ? ( +
+
+ {statusLabel} + {progress !== null ? ` · ${progress}%` : ""} +
+ {progress !== null ? ( +
+
+
+ ) : null} +
+ {elapsed} · {shortTaskId(taskId)} +
+
+ + +
+
+ ) : null} +
+ ); +} + +function useTaskElapsed(createdAt?: string) { + const [, setTick] = useState(0); + useEffect(() => { + if (!createdAt) return; + const timer = window.setInterval(() => setTick((value) => value + 1), 1000); + return () => window.clearInterval(timer); + }, [createdAt]); + if (!createdAt) return "刚刚"; + const seconds = Math.max(0, Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000)); + if (seconds < 60) return `${seconds}秒`; + const minutes = Math.floor(seconds / 60); + return minutes < 60 ? `${minutes}分${seconds % 60}秒` : `${Math.floor(minutes / 60)}时${minutes % 60}分`; +} + +function taskStatusLabel(status?: string) { + if (status === "queued") return "排队中"; + if (status === "running") return "生成中"; + if (status === "succeeded") return "任务已完成"; + if (status === "failed") return "任务失败"; + if (status === "cancelled") return "任务已取消"; + return status ? "未知任务状态" : "等待任务状态"; +} + +function shortTaskId(id: string) { + if (id.length <= 20) return id; + return `${id.slice(0, 14)}...${id.slice(-4)}`; +} + +function ErrorContent({ node, theme, onRetry }: Pick) { + const moderationFailure = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails); + return ( +
+
{generationErrorMessage(node.metadata?.errorDetails)}
+ {moderationFailure ? ( +
+ 修改节点提示词后,可重新点击生成。 +
+ ) : ( + + )} +
+ ); +} + +function UnknownNodeContent({ theme }: Pick) { + return
未知节点
; +} + +function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing }: CanvasNodeContentProps) { + const fontSize = node.metadata?.fontSize || 14; + const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as CSSProperties; + const richTextHTML = useMemo(() => canvasRichTextHTML(node.metadata?.richText), [node.metadata?.richText]); + + return ( +
+ {isEditingContent ? ( + onContentChange(node.id, value)} + onBlur={onStopEditing} + onKeyDown={(event) => { + if (event.key === "Escape") onStopEditing(); + }} + onMouseDown={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + /> + ) : richTextHTML ? ( +
event.stopPropagation()} + dangerouslySetInnerHTML={{ __html: richTextHTML }} + /> + ) : ( +
event.stopPropagation()}> + {node.metadata?.content || 双击编辑文字} +
+ )} +
+ ); +} + +function SkillContent({ node, theme }: CanvasNodeContentProps) { + const skill = node.metadata?.skillSnapshot; + const tags = skill?.tags?.slice(0, 4) || []; + const template = skill?.template || node.metadata?.content || ""; + + return ( +
+
+
+
+ + + +
+
{skill?.name || node.title || "技能"}
+
+ {skillCategoryLabel(skill?.category)}·{skillOutputModeLabel(skill?.outputMode)} + {skill?.version ? <>·v{skill.version} : null} +
+
+
+
+
+ {skill?.description ?
{skill.description}
: null} +
+
模板
+
{template || "未配置技能模板"}
+
+
+ {tags.length ? tags.map((tag) => {tag}) : 连接到图片、视频、音频或文本节点后生效} +
+
+ ); +} + +function skillCategoryLabel(category?: string) { + if (category === "writing") return "剧情"; + if (category === "storyboard") return "分镜"; + if (category === "image") return "生图"; + if (category === "video") return "视频"; + return "通用"; +} + +function skillOutputModeLabel(mode?: string) { + if (mode === "json") return "JSON"; + if (mode === "image_prompt") return "生图提示词"; + if (mode === "workflow") return "工作流"; + return "文本"; +} + +function ImageNodeContent(props: CanvasNodeContentProps) { + if (!props.node.metadata?.content && props.isBatchRoot) { + const content = props.node.metadata?.status === "loading" + ? + : props.node.metadata?.status === "error" + ? + : ; + return {content}; + } + if (!props.node.metadata?.content) return ; + return ; +} + +function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: CanvasNodeContentProps) { + const isCharacterReference = node.metadata?.workflowKind === "character" && node.metadata?.characterView === "multi"; + const content = ( +
+
+ {isCharacterReference ? ( +
+
{node.metadata?.characterName || node.title}
+
多视角参考 · 待生成
+
+ ) : 空图片节点} +
+ ); + if (isBatchRoot) return {content}; + return content; +} + +function VideoNodeContent({ node, theme, reduceMediaEffects }: CanvasNodeContentProps) { + const playWhenReadyRef = useRef(false); + const playerBoxRef = useRef(null); + const { url, loading, load } = useNodeResourceUrl(node, false); + const subtitleEntries = node.metadata?.subtitleEntries || []; + const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle(); + const [currentTimeMs, setCurrentTimeMs] = useState(0); + const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null); + + useEffect(() => { + const box = playerBoxRef.current; + const video = box?.querySelector("video"); + if (!video) return; + const handleLoadedMetadata = () => { + if (video.videoWidth > 0 && video.videoHeight > 0) setVideoSize({ width: video.videoWidth, height: video.videoHeight }); + }; + video.addEventListener("loadedmetadata", handleLoadedMetadata); + handleLoadedMetadata(); + if (!subtitleEntries.length) return () => video.removeEventListener("loadedmetadata", handleLoadedMetadata); + const handleTimeUpdate = () => setCurrentTimeMs(Math.round(video.currentTime * 1000)); + video.addEventListener("timeupdate", handleTimeUpdate); + return () => { + video.removeEventListener("timeupdate", handleTimeUpdate); + video.removeEventListener("loadedmetadata", handleLoadedMetadata); + }; + }, [subtitleEntries.length, url]); + + if (!node.metadata?.content) return } label="空视频节点" color={theme.node.placeholder} />; + if (!url) return : } label={loading ? "正在缓存视频" : "加载并缓存视频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />; + + const sourceRatio = (videoSize?.width || node.metadata?.naturalWidth || node.width) / Math.max(1, videoSize?.height || node.metadata?.naturalHeight || node.height); + const fitHeight = Math.min(node.height, node.width / Math.max(0.01, sourceRatio)); + const fitWidth = Math.round(fitHeight * sourceRatio); + const activeEntry = subtitleEntries.find((entry) => currentTimeMs >= entry.startMs && currentTimeMs < entry.endMs); + const activeHighlight = activeEntry ? (node.metadata?.subtitleHighlights || []).find((item) => item.entryIndex === activeEntry.index) : undefined; + + return ( +
+
+ { playWhenReadyRef.current = false; }} brandColor={theme.accent.primary} className="h-full w-full rounded-[var(--node-radius)] bg-black" dataCanvasNoZoom compactControls /> + {activeEntry && activeEntry.text.trim() ? : null} +
+
+ ); +} + +function AudioNodeContent({ node, theme }: CanvasNodeContentProps) { + const audioRef = useRef(null); + const playWhenReadyRef = useRef(false); + const { url, loading, load } = useNodeResourceUrl(node, false); + useEffect(() => { + if (!url || !playWhenReadyRef.current) return; + playWhenReadyRef.current = false; + void audioRef.current?.play().catch(() => undefined); + }, [url]); + if (!node.metadata?.content) return } label="空音频节点" color={theme.node.placeholder} />; + if (!url) return : } label={loading ? "正在缓存音频" : "加载并缓存音频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />; + return ( +
+
{node.title || "音频"}
+
+ ); +} + +function EmptyMediaContent({ icon, label, color }: { icon: ReactNode; label: string; color: string }) { + return
{icon}{label}
; +} + +function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: Pick) { + const imageContainerRef = useRef(null); + const nearViewport = useNearViewport(imageContainerRef); + const { url, loading } = useNodeResourceUrl(node, nearViewport); + return ( + +
+ {url ? {node.title} event.preventDefault()} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> :
{loading ? : }
} +
+
+ ); +} + +function DeferredMediaLoad({ icon, label, disabled, onClick }: { icon: ReactNode; label: string; disabled: boolean; onClick: () => void }) { + return ; +} + +function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) { + const storageKey = node.metadata?.storageKey || ""; + const fallback = node.metadata?.content || ""; + const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey)); + const [url, setUrl] = useState(isRemoteResource ? "" : fallback); + const [loading, setLoading] = useState(isRemoteResource && eager); + + useEffect(() => { + let cancelled = false; + if (!isRemoteResource) { + setUrl(fallback); + setLoading(false); + return; + } + setUrl(""); + setLoading(eager); + const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey); + void resolve.then((cached) => { + if (!cancelled) setUrl(cached || (eager ? fallback : "")); + }).catch(() => { + if (!cancelled && eager) setUrl(fallback); + }).finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [eager, fallback, isRemoteResource, storageKey]); + + const load = useCallback(async () => { + if (url) return url; + if (!isRemoteResource) return fallback; + setLoading(true); + try { + const next = (await cacheResourceObjectUrl(storageKey)) || fallback; + setUrl(next); + return next; + } catch { + setUrl(fallback); + return fallback; + } finally { + setLoading(false); + } + }, [fallback, isRemoteResource, storageKey, url]); + return { url, loading, load }; +} + +function useNearViewport(ref: RefObject) { + const [nearViewport, setNearViewport] = useState(false); + useEffect(() => { + const element = ref.current; + if (!element || typeof IntersectionObserver === "undefined") { + setNearViewport(true); + return; + } + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setNearViewport(true); + observer.disconnect(); + } + }, { rootMargin: "600px" }); + observer.observe(element); + return () => observer.disconnect(); + }, [ref]); + return nearViewport; +} + +export function CanvasNodeImageInfo({ node }: { node: CanvasNodeData }) { + const width = Math.round(node.metadata?.naturalWidth || node.width); + const height = Math.round(node.metadata?.naturalHeight || node.height); + const size = formatBytes(node.metadata?.bytes || 0); + return {width} x {height}{size ? ` · ${size}` : ""}; +} + +function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, theme, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; theme: CanvasTheme; onToggleBatch?: () => void; children: ReactNode }) { + const isBatchRoot = batchCount > 1; + return ( +
{ event.stopPropagation(); onToggleBatch?.(); } : undefined}> + {isBatchRoot ? ( +
+ {Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => ( +
+ ))} +
+ ) : null} + {children} +
+ ); +} diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 5c941e3da..145b23002 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +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, Maximize2, Music2, Pencil, SlidersHorizontal, Square, UserRound, Video } from "lucide-react"; import { Button, Image as AntImage, Modal, Tooltip } from "antd"; @@ -38,6 +38,15 @@ type CanvasNodePromptPanelProps = { type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes]; +const PROMPT_REFERENCE_SHELF_HEIGHT = 36; +const PROMPT_EDITOR_MIN_HEIGHT = 44; +const PROMPT_EDITOR_EXPANDED_MIN_HEIGHT = 76; +const PROMPT_EDITOR_LINE_HEIGHT = 20; +const PROMPT_EDITOR_EXPANDED_LINE_HEIGHT = 24; +const PROMPT_EDITOR_VERTICAL_PADDING = 12; +const PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING = 20; +const PROMPT_EDITOR_MAX_LINES = 8; + export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) { const globalConfig = useEffectiveConfig(); const themeName = useThemeStore((state) => state.theme); @@ -52,7 +61,10 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi const [presetOpen, setPresetOpen] = useState(false); const [expandedPresetOpen, setExpandedPresetOpen] = useState(false); const [expandedPromptOpen, setExpandedPromptOpen] = useState(false); - const [promptContentHeight, setPromptContentHeight] = useState(0); + const [promptContentHeight, setPromptContentHeight] = useState(PROMPT_EDITOR_MIN_HEIGHT); + const [expandedPromptContentHeight, setExpandedPromptContentHeight] = useState(PROMPT_EDITOR_EXPANDED_MIN_HEIGHT); + const [manualPromptHeight, setManualPromptHeight] = useState(null); + const [manualExpandedPromptHeight, setManualExpandedPromptHeight] = useState(null); const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴) const activeReferences = mentionReferences.filter((item) => item.active && item.kind !== "skill"); const requirements: ModelRequirements = { @@ -79,36 +91,27 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi }); const activeReferenceCount = activeReferences.length; const videoFrameOptions = mentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl })); - const darkSurface = themeName === "dark"; const monochromeAccent = theme.node.activeStroke; - const shellBorder = darkSurface ? "rgba(255,255,255,.08)" : "rgba(15,23,42,.08)"; - const insetBorder = darkSurface ? "rgba(255,255,255,.06)" : "rgba(15,23,42,.07)"; - const shellSurface = darkSurface ? theme.canvas.background : "rgba(250,251,252,.97)"; - const composerSurface = darkSurface ? theme.canvas.background : "rgba(15,23,42,.025)"; - const controlSurface = darkSurface ? theme.canvas.background : "rgba(15,23,42,.045)"; - const controlsSurface = darkSurface ? controlSurface : "transparent"; - const shellShadow = darkSurface ? `0 22px 60px ${theme.spatial.shadow}, 0 2px 8px rgba(0,0,0,.22)` : "0 12px 32px rgba(15,23,42,.10), 0 1px 2px rgba(15,23,42,.06)"; - const composerShadow = darkSurface ? "inset 0 1px 4px rgba(0,0,0,.22)" : "inset 0 0 0 1px rgba(15,23,42,.045)"; - const modalShadow = darkSurface ? `0 30px 90px ${theme.spatial.shadow}` : "0 24px 72px rgba(15,23,42,.16)"; - const referenceShelfHeight = activeReferenceCount ? 42 : 0; - const composerMinHeight = activeReferenceCount ? 82 : 58; - const composerHeight = Math.min(224, Math.max(composerMinHeight, Math.ceil(promptContentHeight + referenceShelfHeight))); + const controlSurface = "var(--canvas-composer-control-surface)"; + const promptBounds = promptEditorBounds(false, activeReferenceCount > 0); + const expandedPromptBounds = promptEditorBounds(true, activeReferenceCount > 0); + const composerHeight = clampPromptHeight(manualPromptHeight ?? promptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), promptBounds); + const expandedComposerHeight = clampPromptHeight(manualExpandedPromptHeight ?? expandedPromptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), expandedPromptBounds); const isSubmitDisabled = !isRunning && !prompt.trim(); const canExpandPrompt = mode === "image" || mode === "video"; const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture); - const updatePromptContentHeight = useCallback((height: number) => { - setPromptContentHeight((current) => (Math.abs(current - height) < 1 ? current : height)); - }, []); useEffect(() => { setPrompt(node.metadata?.composerContent ?? node.metadata?.prompt ?? ""); }, [node.id, node.metadata?.composerContent, node.metadata?.prompt]); - useEffect(() => setPromptContentHeight(0), [node.id]); - useEffect(() => { setExpandedPromptOpen(false); setExpandedPresetOpen(false); + setPromptContentHeight(PROMPT_EDITOR_MIN_HEIGHT); + setExpandedPromptContentHeight(PROMPT_EDITOR_EXPANDED_MIN_HEIGHT); + setManualPromptHeight(null); + setManualExpandedPromptHeight(null); }, [node.id]); const skillReferences = useMemo(() => mentionReferences.filter((item) => item.kind === "skill"), [mentionReferences]); @@ -154,11 +157,11 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi }; const renderComposerHeader = (expanded: boolean) => ( -
+
{isPortraitTexture ? ( onConfigChange(node.id, { portraitTexture })} /> ) : ( -
+
@@ -167,7 +170,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi )} {!simpleMode ? : null}
- {activeReferenceCount ? } label={`已连接 ${activeReferenceCount} 个`} /> : null} + {activeReferenceCount ? } label={`参考 ${activeReferenceCount}`} /> : null} {!expanded && canExpandPrompt ? ( + ); + }; + const renderComposerControls = (expanded: boolean) => simpleMode ? ( -
+
{activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"} - + {renderSubmitButton(expanded)}
) : ( -
-
+
+
navigateToSettings({ continueCreation: true })} showSelectedPrice={false} + variant="creation" />
@@ -228,7 +251,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} onMissingConfig={() => navigateToSettings({ continueCreation: true })} onOpenChange={expanded ? undefined : onImageSettingsOpenChange} @@ -236,67 +259,66 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi ) : mode === "video" ? ( onConfigChange(node.id, videoConfigPatch(key, value))} /> ) : mode === "audio" ? ( onConfigChange(node.id, audioConfigPatch(key, value))} /> ) : null} - {creditsEnabled ? : null} - + {renderSubmitButton(expanded)}
); + const renderPromptEditor = (expanded: boolean) => { + const bounds = expanded ? expandedPromptBounds : promptBounds; + const height = expanded ? expandedComposerHeight : composerHeight; + return ( + <> +
+ + +
+ + + ); + }; + return (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} > {renderComposerHeader(false)} -
- - -
+ {renderPromptEditor(false)} {/* B区 参数区(对应 #98 决策2:默认折叠,手风琴展开)*/} {mode === "video" && !simpleMode ? ( -
+
{paramsExpanded ? ( -
+
onConfigChange(node.id, patch)} />
) : null}
) : null} -
{renderComposerControls(false)}
+ {renderComposerControls(false)} { setExpandedPresetOpen(false); setExpandedPromptOpen(false); }} styles={{ - container: { display: "flex", height: "min(440px, calc(100vh - 40px))", flexDirection: "column", borderRadius: 12, border: `1px solid ${shellBorder}`, padding: 0, overflow: "hidden", background: shellSurface, boxShadow: modalShadow }, - body: { minHeight: 0, flex: 1, padding: 0 }, + container: { border: 0, borderRadius: "var(--canvas-composer-radius)", padding: 0, overflow: "hidden", background: "var(--canvas-composer-surface)", boxShadow: "var(--canvas-composer-shadow)" }, + body: { minHeight: 0, padding: 0 }, }} > -
+
{renderComposerHeader(true)}
-
- - -
+ {renderPromptEditor(true)} {mode === "video" && !simpleMode ? ( -
+
onConfigChange(node.id, patch)} />
) : null} @@ -363,9 +373,9 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi ); } -function ComposerPill({ theme, borderColor, icon, label }: { theme: CanvasTheme; borderColor: string; icon: ReactNode; label: string }) { +function ComposerPill({ theme, icon, label }: { theme: CanvasTheme; icon: ReactNode; label: string }) { return ( - + {icon} {label} @@ -393,46 +403,25 @@ function ConnectedReferenceShelf({ references, theme, onInsert }: { references: return ( <> -
- {activeReferences.map((reference, index) => { +
+ {activeReferences.map((reference) => { const canPreview = (reference.kind === "image" || reference.kind === "character") && Boolean(reference.previewUrl); return ( - + + - {canPreview ? ( - - ) : null} ); })} @@ -469,21 +458,73 @@ function ReferenceThumbnail({ reference }: { reference: CanvasResourceReference ); } -function GenerationCostBadge({ credits, theme }: { credits: number | null; theme: CanvasTheme }) { - if (credits === null) return null; +function PromptResizeHandle({ height, min, max, onResize }: { height: number; min: number; max: number; onResize: (height: number) => void }) { + const dragRef = useRef<{ pointerId: number; startY: number; startHeight: number } | null>(null); + + const finishResize = (event: ReactPointerEvent) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + dragRef.current = null; + }; + + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === "ArrowUp") { + event.preventDefault(); + onResize(Math.max(min, height - 8)); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + onResize(Math.min(max, height + 8)); + } else if (event.key === "Home") { + event.preventDefault(); + onResize(min); + } else if (event.key === "End") { + event.preventDefault(); + onResize(max); + } + }; + return ( - { + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + dragRef.current = { pointerId: event.pointerId, startY: event.clientY, startHeight: height }; + event.currentTarget.setPointerCapture(event.pointerId); + }} + onPointerMove={(event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + onResize(Math.min(max, Math.max(min, drag.startHeight + event.clientY - drag.startY))); + }} + onPointerUp={finishResize} + onPointerCancel={finishResize} > - - {credits.toLocaleString()} - + + ); } +function promptEditorBounds(expanded: boolean, hasReferences: boolean) { + const shelfHeight = hasReferences ? PROMPT_REFERENCE_SHELF_HEIGHT : 0; + const min = (expanded ? PROMPT_EDITOR_EXPANDED_MIN_HEIGHT : PROMPT_EDITOR_MIN_HEIGHT) + shelfHeight; + const max = (expanded ? PROMPT_EDITOR_EXPANDED_LINE_HEIGHT * PROMPT_EDITOR_MAX_LINES + PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING : PROMPT_EDITOR_LINE_HEIGHT * PROMPT_EDITOR_MAX_LINES + PROMPT_EDITOR_VERTICAL_PADDING) + shelfHeight; + return { min, max }; +} + +function clampPromptHeight(height: number, bounds: { min: number; max: number }) { + return Math.min(bounds.max, Math.max(bounds.min, height)); +} + function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode { return type === CanvasNodeType.Text || type === CanvasNodeType.Skill ? "text" : type === CanvasNodeType.Video ? "video" : type === CanvasNodeType.Audio ? "audio" : "image"; } diff --git a/web/src/components/canvas/canvas-node-toolbar.tsx b/web/src/components/canvas/canvas-node-toolbar.tsx index b3ca5d1f0..6b1ae16c6 100644 --- a/web/src/components/canvas/canvas-node-toolbar.tsx +++ b/web/src/components/canvas/canvas-node-toolbar.tsx @@ -1,20 +1,19 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; import { App, Button, Dropdown, Input, Modal, Segmented, Tag } from "antd"; -import { Ellipsis, Lock, Plus, Settings2, Unlock } from "lucide-react"; +import type { MenuProps } from "antd"; +import { ChevronDown, Ellipsis, Lock, Plus, Unlock } from "lucide-react"; -import { canvasThemes } from "@/lib/canvas-theme"; import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style"; -import { defaultToolbarPrefs, readToolbarPrefs, resolveToolbarTools, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry"; +import { canvasThemes } from "@/lib/canvas-theme"; +import { resolveToolbarTools, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry"; import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset"; import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils"; -import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error"; +import { generationErrorMessage } from "@/lib/generation-error"; import { useCopyText } from "@/hooks/use-copy-text"; import { useThemeStore } from "@/stores/use-theme-store"; -import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock"; import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ViewportTransform } from "@/types/canvas"; -import { ImageToolSettingsModal } from "./canvas-image-toolbar-settings-modal"; -import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, isImageQuickToolId, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools"; +import { buildImageToolbarTools } from "./canvas-image-toolbar-tools"; type CanvasNodeToolbarProps = { node: CanvasNodeData | null; @@ -71,7 +70,6 @@ const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> type ToolbarTool = { id: string; - title: string; label: string; icon: ReactNode; onClick: () => void; @@ -80,9 +78,6 @@ type ToolbarTool = { disabled?: boolean; }; -const MAX_IMAGE_QUICK_TOOLS = 7; -const NODE_DOCK_LABELS_STORAGE_KEY = "canvas-node-dock-show-labels-v1"; - export function CanvasNodeToolbar({ node, viewport, @@ -123,17 +118,9 @@ export function CanvasNodeToolbar({ onDelete, workspaceMode = "professional", }: CanvasNodeToolbarProps) { - const [quickImageToolIds, setQuickImageToolIds] = useState(defaultImageQuickToolIds); - const [draftImageToolIds, setDraftImageToolIds] = useState(defaultImageQuickToolIds); - const [showDockLabels, setShowDockLabels] = useState(true); - const [draftShowDockLabels, setDraftShowDockLabels] = useState(true); - const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false); - const [imageToolMenuOpen, setImageToolMenuOpen] = useState(false); + const [openMenuId, setOpenMenuId] = useState(null); const [anchor, setAnchor] = useState<{ left: number; top: number } | null>(null); const toolbarRef = useRef(null); - // Dropdown 关闭与菜单点击处于同一事件批次,ref 用来同步守住即将打开的 Modal,避免工具栏先被卸载。 - const imageToolSettingsOpenRef = useRef(false); - const imageToolMenuOpenRef = useRef(false); const { message } = App.useApp(); const copyText = useCopyText(); const themeName = useThemeStore((state) => state.theme); @@ -141,25 +128,7 @@ export function CanvasNodeToolbar({ const simpleMode = workspaceMode === "simple"; useEffect(() => { - try { - const stored = window.localStorage.getItem(IMAGE_QUICK_TOOLS_STORAGE_KEY); - if (!stored) return; - const parsed = JSON.parse(stored) as unknown; - setQuickImageToolIds(readImageQuickToolsConfig(parsed)); - } catch { - window.localStorage.removeItem(IMAGE_QUICK_TOOLS_STORAGE_KEY); - } - }, []); - - useEffect(() => { - setShowDockLabels(window.localStorage.getItem(NODE_DOCK_LABELS_STORAGE_KEY) !== "0"); - }, []); - - useEffect(() => { - imageToolSettingsOpenRef.current = false; - imageToolMenuOpenRef.current = false; - setImageToolSettingsOpen(false); - setImageToolMenuOpen(false); + setOpenMenuId(null); }, [node?.id]); useLayoutEffect(() => { @@ -181,7 +150,6 @@ export function CanvasNodeToolbar({ const halfToolbar = toolbarWidth / 2; const canClamp = toolbarWidth > 0 && toolbarWidth <= containerRect.width - 20; const left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft; - // 外置标题固定占 24px,额外保留 6px 即可兼顾层级和名称编辑入口。 const top = nodeRect.top - containerRect.top - 30; if (toolbarRef.current) { toolbarRef.current.style.left = `${left}px`; @@ -206,25 +174,17 @@ export function CanvasNodeToolbar({ unsubscribeViewport(); window.removeEventListener("resize", update); }; - }, [anchor === null, containerRef, node, showDockLabels, viewport.k, viewport.x, viewport.y]); + }, [anchor === null, containerRef, node, viewport.k, viewport.x, viewport.y]); if (!node || !anchor) return null; - const activeNode = node; const isImage = node.type === CanvasNodeType.Image; const isVideo = node.type === CanvasNodeType.Video; const isAudio = node.type === CanvasNodeType.Audio; const hasImage = isImage && Boolean(node.metadata?.content); - const hasVideo = isVideo && Boolean(node.metadata?.content); - const hasAudio = isAudio && Boolean(node.metadata?.content); const isText = node.type === CanvasNodeType.Text; const isCharacterReference = isText && node.metadata?.workflowKind === "character" && Boolean(node.metadata.characterAssetId); const isEditableText = isText && !isCharacterReference; - const isConfig = node.type === CanvasNodeType.Config; - const canOpenDialog = isEditableText || isImage || isVideo; - const requiresPromptChange = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails); - const canRetry = node.metadata?.status === "error" && !requiresPromptChange; - const quickImageToolIdSet = new Set(quickImageToolIds); const copyImagePrompt = (target: CanvasNodeData) => { const prompt = target.metadata?.prompt?.trim(); if (!prompt) { @@ -235,16 +195,6 @@ export function CanvasNodeToolbar({ }; const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt }); - function openImageToolSettings() { - imageToolSettingsOpenRef.current = true; - imageToolMenuOpenRef.current = false; - onKeep(activeNode.id); - setImageToolMenuOpen(false); - setDraftImageToolIds(quickImageToolIds); - setDraftShowDockLabels(showDockLabels); - setImageToolSettingsOpen(true); - } - // 构建 ToolContext——供注册表解析工具 const nodeHoverHandlers = { onNodeInfo: onInfo, onNodeDelete: onDelete, onNodeRetry: onRetry, onNodeEditText: onEditText, onNodeDecreaseFont: onDecreaseFont, onNodeIncreaseFont: onIncreaseFont, @@ -276,14 +226,13 @@ export function CanvasNodeToolbar({ handlers: nodeHoverHandlers, }; - // 从注册表解析工具(已按 applicable 过滤、用户偏好排序) - const registryTools = resolveToolbarTools("node-hover", nodeHoverCtx, readToolbarPrefs("node-hover") ?? defaultToolbarPrefs("node-hover")); - // 分离 node-lock(始终显示,不参与 quickImageToolIds 过滤) + // 注册表只负责动作合同与适用性,Dock 的业务分组在此处唯一确定。 + const registryTools = resolveToolbarTools("node-hover", nodeHoverCtx, null); + // 锁定始终放在菜单末尾,避免与业务工具混排。 const otherRegistryTools = registryTools.filter((tool) => tool.id !== "node-lock"); // 转为 ToolbarTool 供组件内部逻辑使用 const otherTools: ToolbarTool[] = otherRegistryTools.map((tool) => ({ id: tool.id, - title: typeof tool.label === "function" ? tool.label(nodeHoverCtx) : tool.label, label: tool.displayLabel ? (typeof tool.displayLabel === "function" ? tool.displayLabel(nodeHoverCtx) : tool.displayLabel) : (typeof tool.label === "function" ? tool.label(nodeHoverCtx) : tool.label), icon: typeof tool.icon === "function" ? tool.icon(nodeHoverCtx) : tool.icon, active: tool.active?.(nodeHoverCtx), @@ -291,123 +240,107 @@ export function CanvasNodeToolbar({ disabled: tool.disabled?.(nodeHoverCtx), onClick: () => tool.run(nodeHoverCtx), })); - // 合并图片工具(当 hasImage && !simpleMode 时追加到 otherTools 末尾) - const allTools: ToolbarTool[] = hasImage && !simpleMode ? [...otherTools, ...imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, danger: undefined, disabled: undefined, onClick: tool.onClick }))] : otherTools; - // hasImage 时按 quickImageToolIds 过滤 - const toolbarTools = hasImage ? allTools.filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : allTools; - const selectableImageToolbarTools = allTools.filter((tool): tool is ToolbarTool & { id: ImageQuickToolId } => isImageQuickToolId(tool.id)); - const temporaryImageToolbarTools = selectableImageToolbarTools.filter((tool) => !quickImageToolIdSet.has(tool.id)); - const dockItems: FloatingDockEntry[] = [ - ...toolbarTools.map((tool) => ({ id: tool.id, label: tool.title, displayLabel: tool.label, icon: tool.icon, active: tool.active, danger: tool.danger, disabled: tool.disabled, onClick: () => tool.onClick() })), - { kind: "separator", id: "node-state-separator" }, - { id: "node-lock", label: node.metadata?.locked ? "解锁节点" : "锁定位置和尺寸", displayLabel: node.metadata?.locked ? "解锁" : "锁定", icon: node.metadata?.locked ? : , active: Boolean(node.metadata?.locked), onClick: () => onToggleLocked(node) }, - ]; - - const closeImageToolSettings = () => { - imageToolSettingsOpenRef.current = false; - setImageToolSettingsOpen(false); - onLeave(); + const allTools: ToolbarTool[] = hasImage && !simpleMode + ? [...otherTools, ...imageTools.map((tool) => ({ id: tool.id, label: tool.label, icon: tool.icon, onClick: tool.onClick }))] + : otherTools; + const toolById = new Map(allTools.map((tool) => [tool.id, tool])); + const takeTools = (ids: string[]) => ids.map((id) => toolById.get(id)).filter((tool): tool is ToolbarTool => Boolean(tool)); + const imageBaseTools = takeTools(hasImage ? ["delete", "download"] : ["delete", "uploadImage"]); + const imageEditTools = takeTools(["maskEdit", "crop", "split"]); + const imagePortraitTools = takeTools(["emotion", "portraitTexture"]).map((tool) => tool.id === "emotion" ? { ...tool, label: "人物情绪" } : tool); + const imageAngleTool = toolById.get("angle"); + const videoTools = takeTools(["delete", "download", "subtitles", "timeline", "extractLastFrame", "extractAudio", "trimRegenerate", "uploadVideo"]).map((tool) => { + if (tool.id === "extractLastFrame") return { ...tool, label: "尾帧截取" }; + if (tool.id === "trimRegenerate") return { ...tool, label: "截取重生成" }; + return tool; + }); + const genericTools = takeTools(isAudio ? ["delete", "download", "timeline", "uploadAudio"] : isEditableText ? ["delete", "edit", "editText", "generateImage", "saveAsset"] : ["delete", "info", "config"]); + const visibleToolIds = new Set([ + ...(isImage ? [...imageBaseTools, ...imageEditTools, ...imagePortraitTools, ...(imageAngleTool ? [imageAngleTool] : [])] : isVideo ? videoTools : genericTools).map((tool) => tool.id), + ]); + const overflowTools = allTools + .filter((tool) => !visibleToolIds.has(tool.id)) + .map((tool) => tool.id === "edit" && (isImage || isVideo) ? { ...tool, label: "生成设置" } : tool); + const lockTool: ToolbarTool = { + id: "node-lock", + label: node.metadata?.locked ? "解锁" : "锁定", + icon: node.metadata?.locked ? : , + active: Boolean(node.metadata?.locked), + onClick: () => onToggleLocked(node), }; - - const runTemporaryImageTool = (tool: ToolbarTool) => { - imageToolMenuOpenRef.current = false; - setImageToolMenuOpen(false); - tool.onClick(); - }; - - const handleImageToolMenuOpenChange = (open: boolean) => { - imageToolMenuOpenRef.current = open; - setImageToolMenuOpen(open); - if (open) onKeep(activeNode.id); - else if (!imageToolSettingsOpenRef.current) onLeave(); - }; - - const setDraftImageToolVisible = (id: ImageQuickToolId, visible: boolean) => { - setDraftImageToolIds((current) => { - const selected = new Set(current); - if (visible && selected.size >= MAX_IMAGE_QUICK_TOOLS) { - message.warning(`最多固定 ${MAX_IMAGE_QUICK_TOOLS} 个快捷工具`); - return current; - } - if (visible) selected.add(id); - else selected.delete(id); - return selectableImageToolbarTools.filter((tool) => selected.has(tool.id)).map((tool) => tool.id); - }); - }; - - const saveImageToolSettings = () => { - setQuickImageToolIds(draftImageToolIds); - setShowDockLabels(draftShowDockLabels); - window.localStorage.setItem(IMAGE_QUICK_TOOLS_STORAGE_KEY, JSON.stringify(draftImageToolIds)); - window.localStorage.setItem(NODE_DOCK_LABELS_STORAGE_KEY, draftShowDockLabels ? "1" : "0"); - closeImageToolSettings(); - }; - - const dockShellStyle = canvasDockStyle(theme, theme.node.text); - const embeddedDockStyle = { ...dockShellStyle, background: "transparent", borderColor: "transparent", boxShadow: "none" }; - const labeledDockStyle = { - ...dockShellStyle, - boxShadow: themeName === "dark" - ? `0 18px 52px ${theme.spatial.shadow}` - : "0 10px 30px rgba(15,23,42,.12), 0 1px 2px rgba(15,23,42,.05), inset 0 1px 0 rgba(255,255,255,.7)", + const handleMenuOpenChange = (menuId: string, open: boolean) => { + setOpenMenuId((current) => open ? menuId : current === menuId ? null : current); + if (open) onKeep(node.id); + else onLeave(); }; + const dockStyle = canvasDockStyle(theme, theme.node.text); return ( - <> +
onKeep(node.id)} + onMouseLeave={() => { if (!openMenuId) onLeave(); }} + onMouseDown={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + >
onKeep(node.id)} - onMouseLeave={() => { - if (!imageToolSettingsOpenRef.current && !imageToolMenuOpenRef.current) onLeave(); - }} - onMouseDown={(event) => event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} + role="toolbar" + aria-label="节点快捷工具" + className="thin-scrollbar flex h-11 max-w-full items-center gap-0.5 overflow-x-auto rounded-[var(--dock-radius-tight)] px-2 backdrop-blur-2xl" + style={{ ...dockStyle, border: 0 }} > -
- {dockItems.length ? : null} - {hasImage && !simpleMode ? ( - ({ key: tool.id, icon: tool.icon, label: tool.label, danger: tool.danger, onClick: () => runTemporaryImageTool(tool) })), - ...(temporaryImageToolbarTools.length ? [{ type: "divider" as const }] : []), - { key: "manage-image-quick-tools", icon: , label: "管理快捷工具", onClick: openImageToolSettings }, - ], - }} - > - - - ) : null} -
+ {isImage ? ( + <> + {imageBaseTools.map((tool) => )} + {imageEditTools.length ? : null} + {imagePortraitTools.length ? : null} + {imageAngleTool ? : null} + + ) : isVideo ? videoTools.map((tool) => ) : genericTools.map((tool) => )} + + + {overflowTools.length ? ( + } tools={overflowTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} placement="topRight" /> + ) : null}
- {hasImage ? ( - - ) : null} - +
+ ); +} + +function NodeDockToolButton({ tool }: { tool: ToolbarTool }) { + return ( + + ); +} + +function NodeDockMenuButton({ menuId, label, icon, tools, openMenuId, onOpenChange, placement = "top" }: { menuId: string; label: string; icon: ReactNode; tools: ToolbarTool[]; openMenuId: string | null; onOpenChange: (menuId: string, open: boolean) => void; placement?: "top" | "topRight" }) { + const open = openMenuId === menuId; + const items: MenuProps["items"] = tools.map((tool) => ({ key: tool.id, icon: tool.icon, label: tool.label, disabled: tool.disabled, onClick: tool.onClick })); + return ( + onOpenChange(menuId, nextOpen)} menu={{ items }}> + + ); } diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx index 5de77e6d5..20e4f75ac 100644 --- a/web/src/components/canvas/canvas-node.tsx +++ b/web/src/components/canvas/canvas-node.tsx @@ -1,25 +1,15 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; -import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Clock3, FileText, Image as ImageIcon, LoaderCircle, Lock, Maximize2, Music2, Pencil, Play, Plus, RefreshCw, Replace, Settings2, Square, Star, Type, Video } from "lucide-react"; +import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, Replace, Settings2, Star, Type, Upload, Video } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; -import { CometCard } from "@/components/ui/aceternity/comet-card"; import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status"; -import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text"; -import { formatBytes } from "@/lib/image-utils"; -import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error"; import { useThemeStore } from "@/stores/use-theme-store"; -import { resourceIdFromStorageKey } from "@/services/api/resources"; -import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache"; -import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import { storyboardMinNodeHeight } from "./canvas-script-node"; import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; -import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage"; import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size"; -import { VideoPlayer } from "@/components/video-player"; -import { createDefaultSubtitleStyle } from "@/types/timeline"; -import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay"; +import { CanvasNodeContent, CanvasNodeImageInfo } from "./canvas-node-content"; type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes]; @@ -68,29 +58,6 @@ type CanvasNodeProps = { onContextMenu: (event: React.MouseEvent, nodeId: string) => void; }; -type NodeContentRendererProps = { - node: CanvasNodeData; - theme: (typeof canvasThemes)[keyof typeof canvasThemes]; - isEditingContent: boolean; - textareaRef: React.RefObject; - isBatchRoot: boolean; - batchCount: number; - batchExpanded: boolean; - batchOpening: boolean; - batchRecovering: boolean; - renderNodeContent?: (node: CanvasNodeData) => ReactNode; - drawingProjectId?: string; - onContentChange: (nodeId: string, content: string) => void; - onStopEditing: () => void; - mentionReferences: CanvasResourceReference[]; - onRetry?: (node: CanvasNodeData) => void; - onCancelTask?: (node: CanvasNodeData) => void; - onOpenTaskDetails?: (node: CanvasNodeData) => void; - onOpenDrawing?: (node: CanvasNodeData) => void; - onToggleBatch?: () => void; - reduceMediaEffects?: boolean; -}; - export const CanvasNode = React.memo(function CanvasNode({ data, dragOffset, @@ -142,16 +109,14 @@ export const CanvasNode = React.memo(function CanvasNode({ const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content); const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content); const hasMediaContent = hasImageContent || hasVideoContent || hasAudioContent; - const isGeneratingNode = data.type !== CanvasNodeType.Frame && data.metadata?.status === "loading"; + const hasReplaceAction = !readOnly && (data.type === CanvasNodeType.Image || data.type === CanvasNodeType.Video); const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1; const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId); const showStatusTrack = Boolean(resourceLabel || data.metadata?.locked || isBatchRoot || (isBatchChild && !readOnly) || (hasMediaContent && !readOnly)); const isActive = isConnectionTarget || isSelected || isFocusRelated; - const flushMediaContent = hasImageContent || hasVideoContent; - const mediaBorderColor = isActive ? theme.accent.primary : isRelated && !isBatchChild ? theme.accent.primary : "transparent"; + const nodeState = isFocusRelated ? "focus" : isConnectionTarget ? "target" : isSelected ? "selected" : isRelated && !isBatchChild ? "related" : "idle"; const assetTags = data.metadata?.assetTags?.filter((tag) => tag.trim()) || []; const scriptMinHeight = data.type === CanvasNodeType.Script ? storyboardMinNodeHeight(data.metadata?.storyboardComposerHeight) : null; - const nodeHoverLocked = reduceMediaEffects || Boolean(dragOffset) || isEditingContent || isEditingTitle || isGeneratingNode || scale < 0.32 || batchClosing || batchOpening; const textareaRef = useRef(null); const resizeRef = useRef({ isResizing: false, @@ -318,23 +283,12 @@ export const CanvasNode = React.memo(function CanvasNode({ onCommit={commitTitle} onCancel={() => { setTitleDraft(data.title); setIsEditingTitle(false); }} /> - {/* 画布节点不启用指针跟随 3D 位移,hover 反馈统一走 CSS 静态抬升,避免鼠标移动形成反馈震荡 */} - onMouseDown(event, data.id)} onDoubleClick={(event) => { @@ -372,7 +326,7 @@ export const CanvasNode = React.memo(function CanvasNode({ className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot || data.type === CanvasNodeType.Script ? "overflow-visible" : "overflow-hidden"}`} style={ { - background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill, + background: hasImageContent || hasVideoContent ? "transparent" : "var(--card-surface)", "--batch-from-x": `${batchMotion?.x || 0}px`, "--batch-from-y": `${batchMotion?.y || 0}px`, "--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`, @@ -385,7 +339,7 @@ export const CanvasNode = React.memo(function CanvasNode({ {data.metadata?.status && data.metadata.status !== "idle" && data.type !== CanvasNodeType.Frame ? ( ) : null} - onToggleBatch?.(data.id)} reduceMediaEffects={reduceMediaEffects} />
- {flushMediaContent ? ( + {hasReplaceAction ? (
- ) : null} - - {(hasImageContent || hasVideoContent) && !readOnly ? ( -
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} >
) : null} @@ -444,8 +389,8 @@ export const CanvasNode = React.memo(function CanvasNode({ > ) : null} {showStatusTrack ? ( -
+
{resourceLabel && data.type !== CanvasNodeType.Image ? : null} {hasMediaContent && !readOnly ? : null} {isBatchRoot ? onToggleBatch?.(data.id)} /> : null} @@ -486,19 +430,17 @@ export const CanvasNode = React.memo(function CanvasNode({ {assetTags.length || (showImageInfo && hasImageContent) ? (
{assetTags.length ? : null} - {showImageInfo && hasImageContent ? : null} + {showImageInfo && hasImageContent ? : null}
) : null} - {!hasImageContent && !hasVideoContent && !hasAudioContent && data.type !== CanvasNodeType.Drawing ?
: null} - {!readOnly && !data.metadata?.locked ? <> : null} - +
{!readOnly && data.type !== CanvasNodeType.Script ? onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null} {!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config ? onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null} @@ -507,291 +449,9 @@ export const CanvasNode = React.memo(function CanvasNode({ ); }); -function NodeContent(props: NodeContentRendererProps) { - const hasCustomContent = props.node.type === CanvasNodeType.Config - || props.node.type === CanvasNodeType.Script - || Boolean(props.node.metadata?.directorSceneId) - || (props.node.metadata?.workflowKind === "character" && Boolean(props.node.metadata.characterAssetId)) - || (props.node.metadata?.workflowKind === "story_input" && !props.isEditingContent) - || (props.node.metadata?.workflowKind === "styleboard" && !props.node.metadata.content); - if (hasCustomContent && props.renderNodeContent) return props.renderNodeContent(props.node); - if (props.isBatchRoot) return ; - if (props.node.metadata?.status === "loading") return ; - if (props.node.metadata?.status === "error") return ; - - const Renderer = nodeContentRenderers[props.node.type]; - return Renderer ? : ; -} - -const nodeContentRenderers = { - [CanvasNodeType.Text]: TextContent, - [CanvasNodeType.Script]: UnknownNodeContent, - [CanvasNodeType.Skill]: SkillContent, - [CanvasNodeType.Image]: ImageNodeContent, - [CanvasNodeType.Config]: EmptyImageContent, - [CanvasNodeType.Video]: VideoNodeContent, - [CanvasNodeType.Audio]: AudioNodeContent, - [CanvasNodeType.Drawing]: DrawingContent, - [CanvasNodeType.Frame]: UnknownNodeContent, -} satisfies Record ReactNode>; - -function DrawingContent({ node, theme, drawingProjectId }: NodeContentRendererProps) { - const shapeCount = node.metadata?.drawingShapeCount || 0; - const pageCount = node.metadata?.drawingPageCount || 1; - const [previewUrl, setPreviewUrl] = useState(node.metadata?.drawingPreviewUrl || ""); - - useEffect(() => { - const drawingId = node.metadata?.drawingId; - const fallbackPreview = node.metadata?.drawingPreviewUrl || ""; - setPreviewUrl(fallbackPreview); - if (!drawingProjectId || !drawingId) return; - let active = true; - let objectUrl = ""; - void loadCanvasDrawingPreview(drawingProjectId, drawingId).then((preview) => { - if (!active) return; - if (!preview) { - setPreviewUrl(fallbackPreview); - return; - } - objectUrl = URL.createObjectURL(preview); - setPreviewUrl(objectUrl); - }).catch((error) => console.warn("读取绘图节点预览失败", error)); - return () => { - active = false; - if (objectUrl) URL.revokeObjectURL(objectUrl); - }; - }, [drawingProjectId, node.metadata?.drawingId, node.metadata?.drawingPreviewUrl, node.metadata?.drawingRevision]); - - return ( -
- {previewUrl ? ( - 绘图预览 - ) : ( -
- - - -
- )} -
-
-
{node.title || "绘图"}
-
{shapeCount} 个图形 · {pageCount} 个页面
-
- -
-
- ); -} - -function LoadingContent({ node, theme, onCancelTask, onOpenTaskDetails }: Pick) { - const taskId = node.metadata?.taskId; - const progress = typeof node.metadata?.taskProgress === "number" ? Math.max(0, Math.min(100, Math.round(node.metadata.taskProgress))) : null; - const statusLabel = taskStatusLabel(node.metadata?.taskStatus); - const elapsed = useTaskElapsed(node.metadata?.taskCreatedAt); - return ( -
-
- {node.metadata?.taskStage || (taskId ? "任务处理中" : "正在创建任务")} - {taskId ? ( -
-
- {statusLabel} - {progress !== null ? ` · ${progress}%` : ""} -
- {progress !== null ? ( -
-
-
- ) : null} -
- {elapsed} · {shortTaskId(taskId)} -
-
- - -
-
- ) : null} -
- ); -} - -function useTaskElapsed(createdAt?: string) { - const [, setTick] = useState(0); - useEffect(() => { - if (!createdAt) return; - const timer = window.setInterval(() => setTick((value) => value + 1), 1000); - return () => window.clearInterval(timer); - }, [createdAt]); - if (!createdAt) return "刚刚"; - const seconds = Math.max(0, Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000)); - if (seconds < 60) return `${seconds}秒`; - const minutes = Math.floor(seconds / 60); - return minutes < 60 ? `${minutes}分${seconds % 60}秒` : `${Math.floor(minutes / 60)}时${minutes % 60}分`; -} - -function taskStatusLabel(status?: string) { - if (status === "queued") return "排队中"; - if (status === "running") return "生成中"; - if (status === "succeeded") return "任务已完成"; - if (status === "failed") return "任务失败"; - if (status === "cancelled") return "任务已取消"; - return status ? "未知任务状态" : "等待任务状态"; -} - -function shortTaskId(id: string) { - if (id.length <= 20) return id; - return `${id.slice(0, 14)}...${id.slice(-4)}`; -} - -function ErrorContent({ node, theme, onRetry }: Pick) { - const moderationFailure = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails); - return ( -
-
{generationErrorMessage(node.metadata?.errorDetails)}
- {moderationFailure ? ( -
- 修改节点提示词后,可重新点击生成。 -
- ) : ( - - )} -
- ); -} - -function UnknownNodeContent({ theme }: Pick) { - return ( -
- 未知节点 -
- ); -} - -function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing }: NodeContentRendererProps) { - const fontSize = node.metadata?.fontSize || 14; - const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as React.CSSProperties; - const richTextHTML = useMemo(() => canvasRichTextHTML(node.metadata?.richText), [node.metadata?.richText]); - - return ( -
- {isEditingContent ? ( - onContentChange(node.id, value)} - onBlur={onStopEditing} - onKeyDown={(event) => { - if (event.key === "Escape") onStopEditing(); - }} - onMouseDown={(event) => event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - onWheel={(event) => event.stopPropagation()} - /> - ) : richTextHTML ? ( -
event.stopPropagation()} - dangerouslySetInnerHTML={{ __html: richTextHTML }} - /> - ) : ( -
event.stopPropagation()} - > - {node.metadata?.content || 双击编辑文字} -
- )} -
- ); -} - -function SkillContent({ node, theme }: NodeContentRendererProps) { - const skill = node.metadata?.skillSnapshot; - const tags = skill?.tags?.slice(0, 4) || []; - const template = skill?.template || node.metadata?.content || ""; - - return ( -
-
-
-
- - - -
-
{skill?.name || node.title || "技能"}
-
- {skillCategoryLabel(skill?.category)} - · - {skillOutputModeLabel(skill?.outputMode)} - {skill?.version ? ( - <> - · - v{skill.version} - - ) : null} -
-
-
-
-
- - {skill?.description ?
{skill.description}
: null} - -
-
模板
-
{template || "未配置技能模板"}
-
- -
- {tags.length ? tags.map((tag) => ( - - {tag} - - )) : 连接到图片、视频、音频或文本节点后生效} -
-
- ); -} - -function skillCategoryLabel(category?: string) { - if (category === "writing") return "剧情"; - if (category === "storyboard") return "分镜"; - if (category === "image") return "生图"; - if (category === "video") return "视频"; - return "通用"; -} - -function skillOutputModeLabel(mode?: string) { - if (mode === "json") return "JSON"; - if (mode === "image_prompt") return "生图提示词"; - if (mode === "workflow") return "工作流"; - return "文本"; -} - function ResourceLabelBadge({ reference, theme }: { reference: CanvasResourceReference; theme: CanvasTheme }) { return ( - + {reference.label} ); @@ -801,7 +461,7 @@ function ResourceStorageBadge({ storageKey, active, theme }: { storageKey?: stri const location = resourceStorageLocation(storageKey); const background = active ? (location === "local" ? "rgba(245,158,11,.9)" : theme.accent.primary) : "rgba(0,0,0,.35)"; return ( - + {resourceStorageLabel(storageKey)} ); @@ -845,339 +505,6 @@ function AssetTagBadges({ tags, theme }: { tags: string[]; theme: (typeof canvas ); } -function ImageNodeContent(props: NodeContentRendererProps) { - if (!props.node.metadata?.content && props.isBatchRoot) { - const content = - props.node.metadata?.status === "loading" ? ( - - ) : props.node.metadata?.status === "error" ? ( - - ) : ( - - ); - return ( - - {content} - - ); - } - if (!props.node.metadata?.content) return ; - - return ( - - ); -} - -function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: NodeContentRendererProps) { - const isCharacterReference = node.metadata?.workflowKind === "character" && node.metadata?.characterView === "multi"; - const content = ( -
-
- -
- {isCharacterReference ? ( -
-
{node.metadata?.characterName || node.title}
-
多视角参考 · 待生成
-
- ) : 空图片节点} -
- ); - if (isBatchRoot) - return ( - - {content} - - ); - return content; -} - -function VideoNodeContent({ node, theme, reduceMediaEffects }: NodeContentRendererProps) { - const playWhenReadyRef = useRef(false); - const playerBoxRef = useRef(null); - const { url, loading, load } = useNodeResourceUrl(node, false); - const subtitleEntries = node.metadata?.subtitleEntries || []; - const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle(); - const [currentTimeMs, setCurrentTimeMs] = useState(0); - const [videoSize, setVideoSize] = useState<{ width: number; height: number } | null>(null); - - // 视频元素由 vidstack 内部创建,直接监听原生事件取分辨率;只有存在字幕时才跟踪播放时间,避免无字幕节点频繁重渲染。 - useEffect(() => { - const box = playerBoxRef.current; - const video = box?.querySelector("video"); - if (!video) return; - const handleLoadedMetadata = () => { - if (video.videoWidth > 0 && video.videoHeight > 0) setVideoSize({ width: video.videoWidth, height: video.videoHeight }); - }; - video.addEventListener("loadedmetadata", handleLoadedMetadata); - handleLoadedMetadata(); - if (!subtitleEntries.length) { - return () => video.removeEventListener("loadedmetadata", handleLoadedMetadata); - } - const handleTimeUpdate = () => setCurrentTimeMs(Math.round(video.currentTime * 1000)); - video.addEventListener("timeupdate", handleTimeUpdate); - return () => { - video.removeEventListener("timeupdate", handleTimeUpdate); - video.removeEventListener("loadedmetadata", handleLoadedMetadata); - }; - }, [subtitleEntries.length, url]); - - if (!node.metadata?.content) - return ( -
-
- ); - if (!url) { - return : } label={loading ? "正在缓存视频" : "加载并缓存视频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />; - } - - // 视频画面按实际分辨率等比适配节点盒子,字幕叠加层与画面同框,不在黑边上错位。 - const sourceRatio = (videoSize?.width || node.metadata?.naturalWidth || node.width) / Math.max(1, videoSize?.height || node.metadata?.naturalHeight || node.height); - const fitHeight = Math.min(node.height, node.width / Math.max(0.01, sourceRatio)); - const fitWidth = Math.round(fitHeight * sourceRatio); - const activeEntry = subtitleEntries.find((entry) => currentTimeMs >= entry.startMs && currentTimeMs < entry.endMs); - const activeHighlight = activeEntry ? (node.metadata?.subtitleHighlights || []).find((item) => item.entryIndex === activeEntry.index) : undefined; - - return ( -
-
- { playWhenReadyRef.current = false; }} - brandColor={theme.accent.primary} - className="h-full w-full rounded-[var(--node-radius)] bg-black" - dataCanvasNoZoom - compactControls - /> - {activeEntry && activeEntry.text.trim() ? ( - - ) : null} -
-
- ); -} - -function AudioNodeContent({ node, theme }: NodeContentRendererProps) { - const audioRef = useRef(null); - const playWhenReadyRef = useRef(false); - const { url, loading, load } = useNodeResourceUrl(node, false); - useEffect(() => { - if (!url || !playWhenReadyRef.current) return; - playWhenReadyRef.current = false; - void audioRef.current?.play().catch(() => undefined); - }, [url]); - if (!node.metadata?.content) - return ( -
- - 空音频节点 -
- ); - if (!url) { - return : } label={loading ? "正在缓存音频" : "加载并缓存音频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />; - } - return ( -
-
- - {node.title || "音频"} -
-
- ); -} - -function ImageContent({ - node, - theme, - isBatchRoot, - batchCount, - batchExpanded, - batchOpening, - batchRecovering, - onToggleBatch, -}: { - node: CanvasNodeData; - theme: CanvasTheme; - isBatchRoot: boolean; - batchCount: number; - batchExpanded: boolean; - batchOpening: boolean; - batchRecovering: boolean; - onToggleBatch?: () => void; -}) { - const imageContainerRef = useRef(null); - const nearViewport = useNearViewport(imageContainerRef); - const { url, loading } = useNodeResourceUrl(node, nearViewport); - - return ( - -
- {url ? ( - {node.title} event.preventDefault()} - className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} - /> - ) :
{loading ? : }
} -
-
- ); -} - -function DeferredMediaLoad({ icon, label, disabled, onClick }: { icon: ReactNode; label: string; disabled: boolean; onClick: () => void }) { - return ( - - ); -} - -function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) { - const storageKey = node.metadata?.storageKey || ""; - const fallback = node.metadata?.content || ""; - const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey)); - const [url, setUrl] = useState(isRemoteResource ? "" : fallback); - const [loading, setLoading] = useState(isRemoteResource && eager); - - useEffect(() => { - let cancelled = false; - if (!isRemoteResource) { - setUrl(fallback); - setLoading(false); - return; - } - setUrl(""); - setLoading(eager); - const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey); - void resolve - .then((cached) => { - if (!cancelled) setUrl(cached || (eager ? fallback : "")); - }) - .catch(() => { - if (!cancelled && eager) setUrl(fallback); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [eager, fallback, isRemoteResource, storageKey]); - - const load = useCallback(async () => { - if (url) return url; - if (!isRemoteResource) return fallback; - setLoading(true); - try { - const next = (await cacheResourceObjectUrl(storageKey)) || fallback; - setUrl(next); - return next; - } catch { - setUrl(fallback); - return fallback; - } finally { - setLoading(false); - } - }, [fallback, isRemoteResource, storageKey, url]); - - return { url, loading, load }; -} - -function useNearViewport(ref: React.RefObject) { - const [nearViewport, setNearViewport] = useState(false); - - useEffect(() => { - const element = ref.current; - if (!element || typeof IntersectionObserver === "undefined") { - setNearViewport(true); - return; - } - const observer = new IntersectionObserver( - (entries) => { - if (entries.some((entry) => entry.isIntersecting)) { - setNearViewport(true); - observer.disconnect(); - } - }, - { rootMargin: "600px" }, - ); - observer.observe(element); - return () => observer.disconnect(); - }, [ref]); - - return nearViewport; -} - -function ImageInfoBar({ node }: { node: CanvasNodeData }) { - const width = Math.round(node.metadata?.naturalWidth || node.width); - const height = Math.round(node.metadata?.naturalHeight || node.height); - const size = formatBytes(node.metadata?.bytes || 0); - return ( - - {width} x {height} - {size ? ` · ${size}` : ""} - - ); -} - -function BatchFrame({ batchCount, batchExpanded, batchOpening, batchRecovering, theme, onToggleBatch, children }: { batchCount: number; batchExpanded: boolean; batchOpening: boolean; batchRecovering: boolean; theme: CanvasTheme; onToggleBatch?: () => void; children: ReactNode }) { - const isBatchRoot = batchCount > 1; - return ( -
{ - event.stopPropagation(); - onToggleBatch?.(); - } - : undefined - } - > - {isBatchRoot ? ( -
- {Array.from({ length: Math.min(batchCount - 1, 5) }).map((_, index) => ( -
- ))} -
- ) : null} - {children} -
- ); -} function ResizeHandle({ corner, onMouseDown }: { corner: ResizeCorner; onMouseDown: (event: React.MouseEvent, corner: ResizeCorner) => void }) { const positionClass = { "top-left": "-left-[14px] -top-[14px] cursor-nwse-resize", @@ -1223,7 +550,7 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the autoFocus value={draft} className="h-6 min-w-20 max-w-[190px] flex-1 truncate rounded border bg-transparent px-1.5 text-xs font-medium outline-none" - style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 10px 28px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.06)` }} + style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: "var(--elevation-card)" }} onChange={(event) => onDraftChange(event.target.value)} onFocus={(event) => event.currentTarget.select()} onBlur={onCommit} @@ -1342,7 +669,7 @@ function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { si background: theme.spatial.elevated, borderColor: theme.node.activeStroke, color: theme.node.activeStroke, - boxShadow: `0 4px 12px ${theme.spatial.shadow}`, + boxShadow: "none", }} > diff --git a/web/src/components/canvas/canvas-toolbar.tsx b/web/src/components/canvas/canvas-toolbar.tsx index 0805bb523..6d7519abe 100644 --- a/web/src/components/canvas/canvas-toolbar.tsx +++ b/web/src/components/canvas/canvas-toolbar.tsx @@ -193,7 +193,7 @@ export function CanvasToolbar({ {appearanceOpen ? ( - event.stopPropagation()}> + event.stopPropagation()}> } title="画布外观" subtitle="调整整个创作空间" theme={theme} />
主题模式
@@ -232,7 +232,7 @@ function AddNodeMenu({ x, theme, commands }: { }) { return ( - event.stopPropagation()}> + event.stopPropagation()}> diff --git a/web/src/components/canvas/canvas-video-settings-popover.tsx b/web/src/components/canvas/canvas-video-settings-popover.tsx index b5c6da62e..77690a38a 100644 --- a/web/src/components/canvas/canvas-video-settings-popover.tsx +++ b/web/src/components/canvas/canvas-video-settings-popover.tsx @@ -86,7 +86,7 @@ function VideoSettingsPortal({ const placeAbove = topPlacement ? topSpace >= estimatedHeight || topSpace >= bottomSpace : bottomSpace < estimatedHeight && topSpace > bottomSpace; const style = { position: "fixed", - zIndex: "var(--z-popover)", + zIndex: "var(--z-dialog-popover)", width, left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)), ...(placeAbove ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, topSpace) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, bottomSpace) }), diff --git a/web/src/components/canvas/canvas-workspace-overlays.tsx b/web/src/components/canvas/canvas-workspace-overlays.tsx index 417601312..9b82aa4f6 100644 --- a/web/src/components/canvas/canvas-workspace-overlays.tsx +++ b/web/src/components/canvas/canvas-workspace-overlays.tsx @@ -79,23 +79,26 @@ export function CanvasSelectionToolbar({ anchorRef, containerRef, count, childre onPointerDown={(event) => event.stopPropagation()} > - 已选 {count} + 已选 {count}
{children}
); } -export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidth = 520, panelHeight = 420, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject; panelWidth?: number; panelHeight?: number; children: ReactNode }) { +export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidth, panelHeight = 190, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject; panelWidth?: number; panelHeight?: number; children: ReactNode }) { const panelRef = useRef(null); - const initialPosition = getNodePanelPosition(node, viewport, { width: containerRef.current?.clientWidth || 0, height: containerRef.current?.clientHeight || 0 }, panelWidth, panelHeight); + const initialWidth = resolveNodePanelWidth(node, viewport, panelWidth); + const initialPosition = getNodePanelPosition(node, viewport, { width: containerRef.current?.clientWidth || 0, height: containerRef.current?.clientHeight || 0 }, initialWidth, panelHeight); useLayoutEffect(() => { const container = containerRef.current; const panel = panelRef.current; if (!container || !panel) return; const update = (nextViewport: ViewportTransform) => { - const position = getNodePanelPosition(node, nextViewport, { width: container.clientWidth, height: container.clientHeight }, panel.offsetWidth || panelWidth, panel.offsetHeight || panelHeight); + const nextWidth = resolveNodePanelWidth(node, nextViewport, panelWidth); + panel.style.width = `${nextWidth}px`; + const position = getNodePanelPosition(node, nextViewport, { width: container.clientWidth, height: container.clientHeight }, nextWidth, panel.offsetHeight || panelHeight); panel.style.left = `${position.left}px`; panel.style.top = `${position.top}px`; }; @@ -115,7 +118,7 @@ export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidt ref={panelRef} data-canvas-no-zoom className="thin-scrollbar absolute z-[var(--z-modal-overlay)] max-w-[calc(100%_-_24px)] overflow-y-auto" - style={{ left: initialPosition.left, top: initialPosition.top, width: panelWidth, maxHeight: "calc(100% - 84px)" }} + style={{ left: initialPosition.left, top: initialPosition.top, width: initialWidth, maxHeight: "calc(100% - 84px)" }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > @@ -124,6 +127,11 @@ export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidt ); } +function resolveNodePanelWidth(node: CanvasNodeData, viewport: ViewportTransform, requestedWidth?: number) { + if (requestedWidth) return requestedWidth; + return clamp(Math.round(node.width * viewport.k * 1.5), 680, 920); +} + export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, containerRef, canCreateDrawing, getDisabledReason, onCreate, onClose }: { pending: PendingConnectionCreate; viewport: ViewportTransform; viewportSize: { width: number; height: number }; containerRef: RefObject; canCreateDrawing: boolean; getDisabledReason: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing) => string; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing) => void; onClose: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const reducedMotion = useReducedMotion(); @@ -157,7 +165,7 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co className="aceternity-floating-panel absolute z-[var(--z-modal-overlay)] w-[248px] origin-top-left overflow-hidden rounded-[var(--r-2xl)] border p-2 backdrop-blur-2xl" data-canvas-no-zoom data-connection-create-menu - style={{ left: initialPosition.left, top: initialPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 30px 90px ${theme.spatial.shadow}` }} + style={{ left: initialPosition.left, top: initialPosition.top, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > diff --git a/web/src/components/canvas/infinite-canvas.tsx b/web/src/components/canvas/infinite-canvas.tsx index a701b7833..8366fa875 100644 --- a/web/src/components/canvas/infinite-canvas.tsx +++ b/web/src/components/canvas/infinite-canvas.tsx @@ -426,7 +426,7 @@ function CanvasGrid({ mode }: { mode: CanvasBackgroundMode }) { backgroundImage, backgroundSize: "var(--canvas-grid-size) var(--canvas-grid-size)", transform: "translate3d(var(--canvas-grid-x), var(--canvas-grid-y), 0)", - opacity: mode === "dots" ? 0.52 : 0.36, + opacity: mode === "dots" ? 0.28 : 0.2, willChange: "transform", }} /> diff --git a/web/src/components/layout/workspace-page.tsx b/web/src/components/layout/workspace-page.tsx index 04418508f..903e8f8f4 100644 --- a/web/src/components/layout/workspace-page.tsx +++ b/web/src/components/layout/workspace-page.tsx @@ -42,7 +42,7 @@ export function ListToolbar({ children, trailing, active, onReset, className }: } export function TableSurface({ children, className }: { children: ReactNode; className?: string }) { - return
{children}
; + return
{children}
; } export function CollectionGrid({ children, className }: { children: ReactNode; className?: string }) { diff --git a/web/src/components/layout/workspace-state.tsx b/web/src/components/layout/workspace-state.tsx index a808f5ab0..05f0dbe94 100644 --- a/web/src/components/layout/workspace-state.tsx +++ b/web/src/components/layout/workspace-state.tsx @@ -27,7 +27,7 @@ export function WorkspaceLoadingState({ label = "正在加载内容", detail, ro
{label}
{detail ?
{detail}
: null}
- {Array.from({ length: rows }, (_, index) =>
)} + {Array.from({ length: rows }, (_, index) =>
)}
); diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index a16517f97..3505d45e1 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -157,7 +157,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu disabled={Boolean(disabledReason)} title={disabledReason || modelOptionLabel(config, displayModel)} className="canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45" - style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text }} + style={{ background: "transparent", color: theme.node.text }} onClick={() => { if (!model) return; onChange(model); diff --git a/web/src/constant/canvas.ts b/web/src/constant/canvas.ts index 5f786cb43..a21639498 100644 --- a/web/src/constant/canvas.ts +++ b/web/src/constant/canvas.ts @@ -9,13 +9,13 @@ type CanvasNodeSpec = { }; export const NODE_DEFAULT_SIZE = { - [CanvasNodeType.Image]: { width: 420, height: 300, title: "New Generation" }, + [CanvasNodeType.Image]: { width: 720, height: 405, title: "图片" }, [CanvasNodeType.Text]: { width: 340, height: 240, title: "Note" }, [CanvasNodeType.Drawing]: { width: 440, height: 300, title: "绘图" }, [CanvasNodeType.Script]: { width: 920, height: 360, title: "分镜脚本" }, [CanvasNodeType.Skill]: { width: 360, height: 220, title: "技能" }, [CanvasNodeType.Config]: { width: 340, height: 300, title: "生成配置" }, - [CanvasNodeType.Video]: { width: 480, height: 270, title: "Video" }, + [CanvasNodeType.Video]: { width: 720, height: 405, title: "视频" }, [CanvasNodeType.Audio]: { width: 340, height: 120, title: "Audio" }, [CanvasNodeType.Frame]: { width: 760, height: 520, title: "未命名背板" }, } satisfies Record; diff --git a/web/src/lib/canvas/canvas-aceternity-style.ts b/web/src/lib/canvas/canvas-aceternity-style.ts index 5ff3e5c9b..76a4ad2fa 100644 --- a/web/src/lib/canvas/canvas-aceternity-style.ts +++ b/web/src/lib/canvas/canvas-aceternity-style.ts @@ -4,10 +4,10 @@ import type { CanvasTheme } from "@/lib/canvas-theme"; export function canvasDockStyle(theme: CanvasTheme, color: string = theme.toolbar.item): CSSProperties { return { - background: theme.spatial.elevated, - borderColor: theme.toolbar.border, + background: "var(--dock-surface)", + borderColor: "var(--dock-border)", color, - boxShadow: `0 18px 52px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.14)`, + boxShadow: "var(--elevation-overlay)", "--dock-command-bg": theme.spatial.surface, "--dock-command-hover": theme.toolbar.itemHover, "--dock-command-active": theme.toolbar.activeBg, diff --git a/web/src/lib/canvas/canvas-node-size.ts b/web/src/lib/canvas/canvas-node-size.ts index 4c5d6fb65..a131e76b7 100644 --- a/web/src/lib/canvas/canvas-node-size.ts +++ b/web/src/lib/canvas/canvas-node-size.ts @@ -1,9 +1,10 @@ +import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; -export const MEDIA_NODE_MIN_SIZE = { width: 300, height: 220 } as const; -export const VIDEO_NODE_MAX_SIZE = { width: 480, height: 480 } as const; +export const MEDIA_NODE_MIN_SIZE = { width: 420, height: 236 } as const; +export const VIDEO_NODE_MAX_SIZE = { width: 720, height: 520 } as const; -export function fitNodeSize(width: number, height: number, maxWidth = 640, maxHeight = 640, minWidth = MEDIA_NODE_MIN_SIZE.width, minHeight = MEDIA_NODE_MIN_SIZE.height) { +export function fitNodeSize(width: number, height: number, maxWidth = 720, maxHeight = 520, minWidth = MEDIA_NODE_MIN_SIZE.width, minHeight = MEDIA_NODE_MIN_SIZE.height) { const w = Math.max(1, width); const h = Math.max(1, height); // 媒体节点既要保留原始比例,也要给生成状态、操作按钮留下稳定的可读空间。 @@ -30,8 +31,18 @@ export function nodeSizeFromRatio(size: string, baseWidth: number, baseHeight: n export function ensureMediaNodeMinimumSize(node: CanvasNodeData) { if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return node; + const title = node.title === "New Generation" ? "图片" : node.title === "Video" ? "视频" : node.title; let width = node.width; let height = node.height; + const emptyStage = NODE_DEFAULT_SIZE[node.type]; + const shouldPromoteEmptyStage = !node.metadata?.content + && !node.metadata?.freeResize + && !node.metadata?.locked + && width * height < emptyStage.width * emptyStage.height; + if (shouldPromoteEmptyStage) { + width = emptyStage.width; + height = emptyStage.height; + } const naturalWidth = node.metadata?.naturalWidth || 0; const naturalHeight = node.metadata?.naturalHeight || 0; const requestedSize = node.type === CanvasNodeType.Image && node.metadata?.generationType === "edit" @@ -50,9 +61,10 @@ export function ensureMediaNodeMinimumSize(node: CanvasNodeData) { width *= scale; height *= scale; } - if (width === node.width && height === node.height) return node; + if (width === node.width && height === node.height && title === node.title) return node; return { ...node, + title, position: { x: node.position.x + node.width / 2 - width / 2, y: node.position.y + node.height / 2 - height / 2, diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index a6e722cec..dde12a1a4 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -1786,7 +1786,7 @@ function InfiniteCanvasPage() { ) : null} {dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox ? ( - + {renderCanvasNodePanel(dialogNode)} ) : null} diff --git a/web/src/pages/settings/channel-settings-pane.tsx b/web/src/pages/settings/channel-settings-pane.tsx new file mode 100644 index 000000000..1dd2d625b --- /dev/null +++ b/web/src/pages/settings/channel-settings-pane.tsx @@ -0,0 +1,334 @@ +import { App, Button, Form, Input, Popconfirm, Segmented, Select, Tooltip } from "antd"; +import { ChevronDown, ChevronUp, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { useState } from "react"; + +import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor"; +import { WorkspaceState } from "@/components/layout/workspace-state"; +import { defaultModelCapabilityConfig } from "@/lib/model-capabilities"; +import { modelProtocolCapability, protocolForModelCatalog } from "@/lib/model-protocols"; +import { fetchChannelModels, type ChannelModelCatalogItem } from "@/services/api/image"; +import { + createModelChannel, + defaultBaseUrlForApiFormat, + filterModelsByCapability, + modelOptionsFromChannels, + useConfigStore, + type AiConfig, + type ModelChannel, +} from "@/stores/use-config-store"; +import { ChannelModelSettings } from "./channel-video-pricing"; + +type UserChannelConnection = "openai" | "gemini"; +type ChannelModelCost = NonNullable[number]; + +export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void }) { + const { message } = App.useApp(); + const config = useConfigStore((state) => state.config); + const replaceConfig = useConfigStore((state) => state.replaceConfig); + const [loadingChannelIds, setLoadingChannelIds] = useState([]); + const [collapsedChannelIds, setCollapsedChannelIds] = useState>(new Set()); + const userChannels = config.channels.filter((channel) => channel.scope !== "system"); + + const updateChannels = (channels: ModelChannel[], baseConfig = config) => { + replaceConfig(withChannels(baseConfig, channels)); + }; + + const updateChannel = (id: string, patch: Partial) => { + updateChannels(config.channels.map((channel) => { + if (channel.id !== id) return channel; + const models = patch.models ? uniqueModels(patch.models) : channel.models; + return { + ...channel, + ...patch, + models, + modelCosts: patch.modelCosts !== undefined ? patch.modelCosts : (patch.models ? channel.modelCosts?.filter((item) => models.includes(item.model)) : channel.modelCosts), + }; + })); + }; + + const updateChannelConnection = (channel: ModelChannel, connection: UserChannelConnection) => { + const apiFormat = connection; + const defaultBaseUrl = defaultBaseUrlForApiFormat(apiFormat); + const baseUrl = isKnownDefaultBaseUrl(channel.baseUrl) ? defaultBaseUrl : channel.baseUrl; + // 渠道只负责连接类型;具体模型能力和请求协议由下方共享能力卡片维护。 + updateChannel(channel.id, { apiFormat, interfaceType: undefined, baseUrl }); + }; + + const addChannel = () => { + const channel = createModelChannel({ name: `渠道 ${userChannels.length + 1}` }); + updateChannels([...config.channels, channel]); + requestAnimationFrame(() => document.getElementById(`channel-${channel.id}-name`)?.focus()); + }; + + const deleteChannel = (id: string) => { + const channel = config.channels.find((item) => item.id === id); + if (channel?.scope === "system") { + message.warning("系统渠道由管理员维护"); + return; + } + updateChannels(config.channels.filter((item) => item.id !== id)); + }; + + const setChannelLoading = (id: string, loading: boolean) => { + setLoadingChannelIds((items) => (loading ? Array.from(new Set([...items, id])) : items.filter((item) => item !== id))); + }; + + const toggleChannelCollapsed = (id: string) => { + setCollapsedChannelIds((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const refreshChannelModels = async (channel: ModelChannel) => { + const connectionError = channelConnectionError(channel); + if (connectionError) { + message.error(`${channel.name || "当前渠道"}:${connectionError}`); + return; + } + setChannelLoading(channel.id, true); + try { + const result = await fetchChannelModels(channel, true); + if (!result.models.length) { + message.warning(`${channel.name || "当前渠道"}未返回模型,已保留现有手工模型`); + return; + } + const latestConfig = useConfigStore.getState().config; + const latestChannel = latestConfig.channels.find((item) => item.id === channel.id); + if (!latestChannel) return; + if (channelConnectionSignature(latestChannel) !== channelConnectionSignature(channel)) { + message.warning(`${latestChannel.name || "当前渠道"}的连接配置已改变,已忽略旧的拉取结果`); + return; + } + updateChannels( + latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models: result.models, modelCosts: mergeFetchedModelCosts(item, result.catalog) } : item)), + latestConfig, + ); + message.success(`${latestChannel.name || "当前渠道"}模型列表已更新`); + } catch (error) { + message.error(channelModelFetchErrorMessage(error)); + } finally { + setChannelLoading(channel.id, false); + } + }; + + const refreshAllModels = async () => { + const runnable = userChannels.filter((channel) => !channelConnectionError(channel)); + const skipped = userChannels.filter((channel) => channelConnectionError(channel)); + if (!runnable.length) { + const detail = skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`).join(";"); + message.error(detail || "没有可拉取的自定义渠道,请先填写有效 Base URL 和 API Key"); + return; + } + setChannelLoading("all", true); + try { + const results = await Promise.all( + runnable.map(async (channel) => { + try { + const result = await fetchChannelModels(channel, true); + return { channel, result, error: "" }; + } catch (error) { + return { channel, result: { models: [], catalog: [] }, error: error instanceof Error ? error.message : "读取失败" }; + } + }), + ); + const latestConfig = useConfigStore.getState().config; + const successful = results.filter((item) => { + const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id); + return Boolean(item.result.models.length && latestChannel && channelConnectionSignature(latestChannel) === channelConnectionSignature(item.channel)); + }); + const stale = results.filter((item) => { + const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id); + return Boolean(item.result.models.length && (!latestChannel || channelConnectionSignature(latestChannel) !== channelConnectionSignature(item.channel))); + }); + const failed = results.filter((item) => !item.result.models.length); + if (successful.length) { + const resultMap = new Map(successful.map((item) => [item.channel.id, item.result] as const)); + updateChannels( + latestConfig.channels.map((channel) => { + const fetched = resultMap.get(channel.id); + return fetched ? { ...channel, models: fetched.models, modelCosts: mergeFetchedModelCosts(channel, fetched.catalog) } : channel; + }), + latestConfig, + ); + message.success(`已更新 ${successful.length} 个渠道的模型`); + } + const warnings = [ + ...failed.map((item) => `${item.channel.name || "未命名渠道"}:${item.error || "未返回模型"}`), + ...stale.map((item) => `${item.channel.name || "未命名渠道"}:连接配置已改变,已忽略旧结果`), + ...skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`), + ]; + if (warnings.length) message.warning(`${warnings.join(";")}。未更新的渠道已保留原有模型列表`); + } catch (error) { + message.error(error instanceof Error ? error.message : "批量读取模型失败,原有模型列表未改动"); + } finally { + setChannelLoading("all", false); + } + }; + + return ( +
+
+
+

自定义渠道

+

渠道只保存连接类型;拉取模型后,请在“模型与能力”中配置协议。

+
+
+ + +
+
+ {userChannels.length ? ( +
+ {userChannels.map((channel) => { + const collapsed = collapsedChannelIds.has(channel.id); + return ( +
+
+
+

{channel.name || "未命名渠道"}

+
+ {channelProtocolLabel(channel)} · 已保存 {channel.models.length} 个模型 + +
+
+
+ + +
+
+