Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-toolbar.tsx
ddCat e93bf3ca39 feat(canvas): 画布工作区 - 整合侧栏与风格预设并修复提示词回填
选择性整合 #547,保留主分支节点与工具条设计。新增默认折叠的节点、资产、任务和历史面板,风格与预设保留在节点内,移除重复入口。

补齐工具权限、提示词解析、资源引用保护及数据库迁移,修复提示词清空和连续编辑后的生成配置同步。

验证:前端全量 1925 项测试、构建、类型检查及 lint 通过;后端 go test ./... 通过。真实模型、PostgreSQL 实库与登录态页面验收仍待进行。

Refs #547

Co-authored-by: bensharp <bensharp@qq.com>
2026-09-20 16:40:08 +08:00

260 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import { Switch } from "@/components/ui/base/switch";
import { Palette, Info } from "lucide-react";
import { FloatingDock } from "@/components/ui/aceternity/floating-dock";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { CanvasAppearanceControls } from "@/components/canvas/canvas-appearance-controls";
import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer";
import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu";
import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands";
import { ToolbarSettingsModal } from "@/components/canvas/toolbars/toolbar-settings-modal";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import type { CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import { canvasThemes, type CanvasBackgroundMode, type CanvasTheme } from "@/lib/canvas-theme";
import { defaultToolbarPrefs, readToolbarPrefs, resolveToolbarEntries, type ToolContext, type ToolbarHandlers, type ToolbarPrefs } from "@/lib/canvas/tool-registry";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import type { CanvasNodeTypeId, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
export function CanvasToolbar({
selectedCount,
workspaceMode,
canvasTool,
onToolChange,
isProjectLinked,
canUndo,
canRedo,
appearance,
backgroundMode,
showImageInfo,
onAddImage,
onAddVideo,
onAddAudio,
onAddText,
onChooseStyle,
onAddScript,
onAddFrame,
onAddFolder,
onAddDrawing,
onAddExtensionNode,
onAddWorkflow,
onOpenDirector,
onUndo,
onRedo,
onUpload,
onDelete,
onClear,
onDeselect,
onAppearanceChange,
onSaveAppearanceDefault,
onBackgroundModeChange,
onShowImageInfoChange,
onOpenWorkspace,
onOpenMyAssets,
onOpenProjectCharacters,
}: {
selectedCount: number;
workspaceMode: CanvasWorkspaceMode;
canvasTool: CanvasToolMode;
onToolChange: (tool: CanvasToolMode) => void;
isProjectLinked: boolean;
canUndo: boolean;
canRedo: boolean;
appearance: CanvasAppearance;
backgroundMode: CanvasBackgroundMode;
showImageInfo: boolean;
onAddImage: () => void;
onAddVideo: () => void;
onAddAudio: () => void;
onAddText: () => void;
onChooseStyle: () => void;
onAddScript: () => void;
onAddFrame: () => void;
onAddFolder: () => void;
onAddDrawing: () => void;
onAddExtensionNode: (type: CanvasNodeTypeId) => void;
onAddWorkflow: () => void;
onOpenDirector: () => void;
onUndo: () => void;
onRedo: () => void;
onUpload: () => void;
onDelete: () => void;
onClear: () => void;
onDeselect: () => void;
onAppearanceChange: (appearance: CanvasAppearance) => void;
onSaveAppearanceDefault: (appearance: CanvasAppearance) => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onShowImageInfoChange: (show: boolean) => void;
onOpenWorkspace?: () => void;
onOpenMyAssets: () => void;
onOpenProjectCharacters: () => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const { bringToFront, zIndex } = useCanvasOverlayLayer("main-toolbar", "var(--z-toolbar)");
const dockRef = useRef<HTMLDivElement>(null);
const colorTheme = useActiveTheme();
const theme = canvasThemes[colorTheme];
const [addOpen, setAddOpen] = useState(false);
const [appearanceOpen, setAppearanceOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [panelX, setPanelX] = useState(0);
const [prefs, setPrefs] = useState<ToolbarPrefs | null>(() => readToolbarPrefs("main"));
useEffect(() => {
if (addOpen || appearanceOpen) bringToFront();
}, [addOpen, appearanceOpen, bringToFront]);
// 设置面板关闭后重新读取偏好(用户可能调整了排序/显隐)
useEffect(() => {
if (!settingsOpen) setPrefs(readToolbarPrefs("main"));
}, [settingsOpen]);
const placePanel = (event: ReactMouseEvent<HTMLElement>) => setPanelX(getPanelX(dockRef.current, event.currentTarget));
const runAddAction = (action: () => void) => {
action();
setAddOpen(false);
};
// 点击外部关闭浮层面板
useEffect(() => {
if (!addOpen && !appearanceOpen) return;
const closeFloatingPanels = (event: PointerEvent) => {
const target = event.target instanceof Node ? event.target : null;
if (target && rootRef.current?.contains(target)) return;
const element = event.target instanceof Element ? event.target : null;
if (element?.closest(".ant-color-picker,.ant-popover")) return;
setAddOpen(false);
setAppearanceOpen(false);
};
document.addEventListener("pointerdown", closeFloatingPanels, true);
return () => document.removeEventListener("pointerdown", closeFloatingPanels, true);
}, [addOpen, appearanceOpen]);
// 构建 handlers(主工具栏只需要部分回调,其余用 no-op 占位满足类型)
const handlers: ToolbarHandlers = {
onToolChange,
onDeselect,
onUndo,
onRedo,
onClear,
onAddText,
onAddImage,
onAddVideo,
onAddAudio,
onAddScript,
onAddFrame,
onAddFolder,
onAddDrawing,
onAddExtensionNode,
onAddWorkflow,
onChooseStyle,
onOpenDirector,
onUpload,
onOpenWorkspace,
onOpenMyAssets,
onOpenProjectCharacters,
onBackgroundModeChange,
onShowImageInfoChange,
onToggleAddPanel: (event: ReactMouseEvent<HTMLElement>) => { placePanel(event); setAppearanceOpen(false); setSettingsOpen(false); setAddOpen((value) => !value); },
onToggleAppearancePanel: (event: ReactMouseEvent<HTMLElement>) => { placePanel(event); setAddOpen(false); setSettingsOpen(false); setAppearanceOpen((value) => !value); },
onToggleSettingsPanel: () => { setAddOpen(false); setAppearanceOpen(false); setSettingsOpen((value) => !value); },
onDeleteSelected: onDelete,
// 以下为多选/节点悬停工具栏回调,主工具栏不使用,用 no-op 占位
onAlign: () => {}, onArrange: () => {}, onCreateStoryboard: () => {}, onCreateReferenceGroup: () => {}, onBatchConnect: () => {}, onMergeVideos: () => {}, onSendSelectionToAgent: () => {},
onNodeInfo: () => {}, onNodeDelete: () => {}, onNodeRetry: () => {}, onNodeEditText: () => {}, onNodeDecreaseFont: () => {}, onNodeIncreaseFont: () => {},
onNodeToggleDialog: () => {}, onNodeAnnotate: () => {}, onNodeGenerateImage: () => {}, onNodeUpload: () => {}, onNodeDownload: () => {}, onNodeSaveAsset: () => {},
onNodeMaskEdit: () => {}, onNodeImageEdit: () => {}, onNodeRemoveBackground: () => {}, onNodeEmotion: () => {}, onNodePortraitTexture: () => {}, onNodeCrop: () => {}, onNodeSplit: () => {}, onNodeUpscale: () => {},
onNodeSuperResolve: () => {}, onNodeAngle: () => {}, onNodeViewImage: () => {}, onNodeExtractVideoFrames: () => {}, onNodeExtractAudioFromVideo: () => {}, onNodeTrimVideoSegments: () => {}, onNodeSubtitles: () => {}, onNodeTimeline: () => {}, onNodeReversePrompt: () => {},
onNodeToggleFreeResize: () => {}, onNodeToggleLocked: () => {}, onNodeCopyPrompt: () => {},
} as ToolbarHandlers;
const ctx: ToolContext = {
selectedCount,
selectedNodeTypes: new Set(),
selectedVideoCount: 0,
canvasTool,
workspaceMode,
isProjectLinked,
canUndo,
canRedo,
extractingVideoFrames: false,
extractingAudio: false,
trimmingVideo: false,
mergingVideos: false,
addPanelOpen: addOpen,
appearancePanelOpen: appearanceOpen,
settingsPanelOpen: settingsOpen,
handlers,
};
const items = resolveToolbarEntries("main", ctx, prefs ?? defaultToolbarPrefs("main"));
// 中央空白起点与主工具栏共用同一份命令解析,避免素材类型和插件节点逐渐分叉。
const createCommands = useCanvasCreateCommands(ctx, runAddAction);
return (
<div ref={rootRef} data-canvas-no-zoom className="pointer-events-none absolute inset-x-[var(--canvas-inset-x)] bottom-[var(--canvas-inset-y)] flex justify-center" style={{ zIndex }} onPointerDownCapture={bringToFront} onFocusCapture={bringToFront}>
<AnimatePresence>
{addOpen ? (
<AddNodeMenu
x={panelX}
theme={theme}
commands={createCommands}
/>
) : null}
</AnimatePresence>
<FloatingDock ref={dockRef} items={items} className="canvas-floating-dock pointer-events-auto max-w-full" style={canvasDockStyle(theme)} />
<AnimatePresence>
{appearanceOpen ? (
<motion.div initial={{ opacity: 0, scaleY: 0.9, y: 8 }} animate={{ opacity: 1, scaleY: 1, y: 0 }} exit={{ opacity: 0, scaleY: 0.92, y: 6 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[320px] max-w-[calc(100vw-24px)]" style={{ left: panelX || "50%", transformOrigin: "bottom center", x: "-50%" }}>
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.97, transition: { duration: 0 } }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2.5 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.toolbar.item }} onWheel={(event) => event.stopPropagation()}>
<PanelHeading icon={<Palette className="size-4" />} title="画布外观" subtitle="调整整个创作空间" theme={theme} />
<CanvasAppearanceControls appearance={appearance} backgroundMode={backgroundMode} colorTheme={colorTheme} theme={theme} onAppearanceChange={onAppearanceChange} onSaveAppearanceDefault={onSaveAppearanceDefault} onBackgroundModeChange={onBackgroundModeChange} />
<div className="mt-2.5 flex items-center justify-between gap-2 rounded-[var(--dock-item-radius-labeled)] border px-2.5 py-2" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
<span className="inline-flex min-w-0 items-center gap-1.5 text-[var(--fs-tiny)] font-semibold"><Info className="size-3" />图片信息</span>
<Switch size="sm" checked={showImageInfo} onChange={onShowImageInfoChange} />
</div>
</SpotlightSurface>
</motion.div>
) : null}
</AnimatePresence>
<ToolbarSettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} toolbar="main" />
</div>
);
}
function AddNodeMenu({ x, theme, commands }: {
x: number;
theme: CanvasTheme;
commands: CanvasCreateCommand[];
}) {
return (
<motion.div initial={{ opacity: 0, scaleY: 0.9, y: 8 }} animate={{ opacity: 1, scaleY: 1, y: 0 }} exit={{ opacity: 0, scaleY: 0.92, y: 6 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[420px] max-w-[calc(100vw-24px)]" style={{ left: x || "50%", transformOrigin: "bottom center", x: "-50%" }}>
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.97, transition: { duration: 0 } }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
<CanvasCreateMenu commands={commands} />
</SpotlightSurface>
</motion.div>
);
}
function PanelHeading({ icon, title, subtitle, theme }: { icon: ReactNode; title: string; subtitle: string; theme: CanvasTheme }) {
return (
<div className="flex items-center gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-[var(--dock-item-radius)] border opacity-75 [&_svg]:size-3.5" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>{icon}</span>
<span className="min-w-0"><span className="block text-xs font-semibold">{title}</span><span className="mt-0.5 block text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>{subtitle}</span></span>
</div>
);
}
function getPanelX(dock: HTMLDivElement | null, target: HTMLElement) {
if (!dock) return 0;
const rootBox = dock.parentElement?.getBoundingClientRect() || dock.getBoundingClientRect();
const box = target.getBoundingClientRect();
return box.left - rootBox.left + box.width / 2;
}