feat(canvas): 自由画布 - 增强空白起点、外观设置与节点连接体验 (#356)

This commit is contained in:
vv-get-ti
2026-08-31 06:49:43 +08:00
committed by GitHub
parent cc4aa736ff
commit 151e99f8d9
22 changed files with 925 additions and 113 deletions

View File

@@ -0,0 +1,170 @@
import { useEffect, useState, type ReactNode } from "react";
import { Button, ColorPicker, Input, Segmented, Slider } from "antd";
import { CircleDot, Grid2x2, Moon, Paintbrush, RotateCcw, Save, Square, Sun } from "lucide-react";
import {
canvasAppearanceForTheme,
customCanvasAppearanceFromTheme,
enterCustomCanvasAppearance,
normalizeHexColor,
type CanvasAppearance,
type CanvasCustomAppearance,
} from "@/lib/canvas/canvas-appearance";
import type { CanvasBackgroundMode, CanvasColorTheme, CanvasTheme } from "@/lib/canvas-theme";
const LIGHT_PRESETS = ["#EDEEEE", "#F3DCE5", "#EEE5D8", "#DFE9E6"];
const DARK_PRESETS = ["#262626", "#30272B", "#302D26", "#25302E"];
export function CanvasAppearanceControls({
appearance,
backgroundMode,
colorTheme,
theme,
onAppearanceChange,
onSaveAppearanceDefault,
onBackgroundModeChange,
}: {
appearance: CanvasAppearance;
backgroundMode: CanvasBackgroundMode;
colorTheme: CanvasColorTheme;
theme: CanvasTheme;
onAppearanceChange: (appearance: CanvasAppearance) => void;
onSaveAppearanceDefault: (appearance: CanvasAppearance) => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
}) {
const [draft, setDraft] = useState(appearance);
useEffect(() => setDraft(appearance), [appearance]);
const selectFixedTheme = (target: CanvasColorTheme) => {
const next = canvasAppearanceForTheme(target, draft);
setDraft(next);
onAppearanceChange(next);
};
const selectCustomTheme = () => {
const next = enterCustomCanvasAppearance(draft, colorTheme);
setDraft(next);
onAppearanceChange(next);
};
const updateCustom = (patch: Partial<CanvasCustomAppearance>) => {
const current = draft.mode === "custom" && draft.custom
? draft
: enterCustomCanvasAppearance(draft, colorTheme);
const next: CanvasAppearance = { mode: "custom", custom: { ...current.custom!, ...patch } };
setDraft(next);
onAppearanceChange(next);
};
const resetCustom = () => {
const baseTheme = draft.custom?.baseTheme || colorTheme;
const next = customCanvasAppearanceFromTheme(baseTheme);
setDraft(next);
onAppearanceChange(next);
};
const saveAsDefault = () => {
onAppearanceChange(draft);
onSaveAppearanceDefault(draft);
};
const presets = draft.custom?.baseTheme === "dark" ? DARK_PRESETS : LIGHT_PRESETS;
return (
<>
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">主题模式</div>
<div className="mt-1 grid grid-cols-3 gap-1 rounded-[var(--dock-item-radius-labeled)] border p-1" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
<ThemeButton active={draft.mode === "light"} label="浅色" theme={theme} onClick={() => selectFixedTheme("light")}><Sun className="size-3.5" /></ThemeButton>
<ThemeButton active={draft.mode === "dark"} label="深色" theme={theme} onClick={() => selectFixedTheme("dark")}><Moon className="size-3.5" /></ThemeButton>
<ThemeButton active={draft.mode === "custom"} label="自定义" theme={theme} onClick={selectCustomTheme}><Paintbrush className="size-3.5" /></ThemeButton>
</div>
{draft.mode === "custom" && draft.custom ? (
<div className="mt-2.5 space-y-2.5 rounded-[var(--dock-item-radius-labeled)] border p-2.5" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
<div className="flex items-center justify-between gap-2">
<span className="text-[var(--fs-tiny)] font-semibold">背景预设</span>
<span className="text-[var(--fs-micro)] opacity-50">{draft.custom.baseTheme === "dark" ? "深色" : "浅色"}界面</span>
</div>
<div className="grid grid-cols-4 gap-1.5">
{presets.map((color) => (
<button key={color} type="button" className="h-7 rounded-md border transition-transform hover:scale-[1.04] focus-visible:outline focus-visible:outline-2 motion-reduce:transition-none motion-reduce:hover:scale-100" style={{ background: color, borderColor: draft.custom?.backgroundColor === color ? theme.node.activeStroke : theme.toolbar.border }} aria-label={`使用背景颜色 ${color}`} title={color} onClick={() => updateCustom({ backgroundColor: color, backgroundBrightness: 0 })} />
))}
</div>
<Segmented
block
size="small"
aria-label="界面样式"
value={draft.custom.baseTheme}
onChange={(value) => updateCustom({ baseTheme: value as CanvasColorTheme })}
options={[
{ value: "light", label: <span className="inline-flex items-center gap-1"><Sun className="size-3.5" />浅色界面</span> },
{ value: "dark", label: <span className="inline-flex items-center gap-1"><Moon className="size-3.5" />深色界面</span> },
]}
/>
<ColorField label="画布背景" value={draft.custom.backgroundColor} theme={theme} onChange={(backgroundColor) => updateCustom({ backgroundColor, backgroundBrightness: 0 })} />
<SliderField label="明亮度" value={draft.custom.backgroundBrightness} min={-30} max={30} suffix="%" onChange={(backgroundBrightness) => updateCustom({ backgroundBrightness })} />
<ColorField label="网格颜色" value={draft.custom.gridColor} theme={theme} onChange={(gridColor) => updateCustom({ gridColor })} />
<SliderField label="网格强度" value={draft.custom.gridOpacity} min={0} max={100} suffix="%" onChange={(gridOpacity) => updateCustom({ gridOpacity })} />
<Button block size="small" type="text" icon={<RotateCcw className="size-3.5" />} onClick={resetCustom}>从继承主题重新开始</Button>
<Button block size="small" type="primary" icon={<Save className="size-3.5" />} onClick={saveAsDefault}>保存为默认</Button>
</div>
) : null}
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">空间网格</div>
<Segmented
className="mt-1 w-full !rounded-[var(--dock-item-radius-labeled)] !p-0.5 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-7 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-7 [&_.ant-segmented-item-label]:!text-[var(--fs-tiny)] [&_.ant-segmented-item-label]:!leading-7"
value={backgroundMode}
onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)}
options={[
{ value: "dots", label: <span className="inline-flex items-center gap-1.5"><CircleDot className="size-3.5" />点</span> },
{ value: "lines", label: <span className="inline-flex items-center gap-1.5"><Grid2x2 className="size-3.5" />线</span> },
{ value: "blank", label: <span className="inline-flex items-center gap-1.5"><Square className="size-3.5" />空白</span> },
]}
/>
</>
);
}
function ThemeButton({ active, label, theme, onClick, children }: { active: boolean; label: string; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
return (
<button type="button" className="inline-flex h-8 min-w-0 items-center justify-center gap-1 rounded-[var(--dock-item-radius)] px-1 text-[var(--fs-tiny)] font-semibold transition-colors" style={active ? { background: theme.node.text, color: theme.node.panel } : { color: theme.toolbar.item }} aria-label={`切换到${label}主题`} aria-pressed={active} title={`切换到${label}主题`} onClick={onClick}>
{children}<span className="truncate">{label}</span>
</button>
);
}
function ColorField({ label, value, theme, onChange }: { label: string; value: string; theme: CanvasTheme; onChange: (value: string) => void }) {
const [textValue, setTextValue] = useState(value);
const [editing, setEditing] = useState(false);
useEffect(() => {
if (!editing) setTextValue(value);
}, [editing, value]);
const commit = () => {
const normalized = normalizeHexColor(textValue);
setEditing(false);
if (normalized) {
setTextValue(normalized);
onChange(normalized);
} else {
setTextValue(value);
}
};
return (
<label className="grid grid-cols-[72px_28px_minmax(0,1fr)] items-center gap-1.5 text-[var(--fs-tiny)] font-medium">
<span>{label}</span>
<ColorPicker disabledAlpha size="small" value={value} onChange={(color) => onChange(color.toHexString().toUpperCase())} />
<Input size="small" value={textValue} aria-label={`${label}色码`} style={{ color: theme.node.text }} onFocus={() => setEditing(true)} onChange={(event) => {
const next = event.target.value;
setTextValue(next);
const normalized = normalizeHexColor(next);
if (normalized) onChange(normalized);
}} onBlur={commit} onPressEnter={commit} />
</label>
);
}
function SliderField({ label, value, min, max, suffix, onChange }: { label: string; value: number; min: number; max: number; suffix: string; onChange: (value: number) => void }) {
return (
<div className="grid grid-cols-[72px_minmax(0,1fr)_38px] items-center gap-1.5 text-[var(--fs-tiny)] font-medium">
<span>{label}</span>
<Slider className="m-0" min={min} max={max} value={value} tooltip={{ open: false }} onChange={onChange} />
<span className="text-right tabular-nums opacity-60">{value > 0 && min < 0 ? "+" : ""}{value}{suffix}</span>
</div>
);
}

View File

@@ -1,8 +1,9 @@
import { Fragment, type CSSProperties, type ReactNode } from "react";
import { Dropdown } from "antd";
import { Fragment, useState, type CSSProperties, type ReactNode } from "react";
import { Dropdown, Popover } from "antd";
import { AlignLeft, ArrowRight, Bot, Check, ChevronDown, ChevronUp, Clapperboard, FolderKanban, Images, MoreHorizontal, Palette, Pencil, Plus, Sparkles, Type, Upload, X } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu";
import type { CanvasShortDramaProgress, CanvasShortDramaStepId } from "@/lib/canvas/canvas-short-drama";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
@@ -23,9 +24,10 @@ export function CanvasLinkedProjectEmptyState({ projectName, hasChapter, onAddFi
);
}
export function CanvasShortDramaEmptyState({ onCreatePipeline, onOpenAgent, onUpload, onAddText, onAddScript }: {
export function CanvasShortDramaEmptyState({ onCreatePipeline, onOpenAgent, onStartFreeform, onUpload, onAddText, onAddScript }: {
onCreatePipeline: () => void;
onOpenAgent: () => void;
onStartFreeform: () => void;
onUpload: () => void;
onAddText: () => void;
onAddScript: () => void;
@@ -39,7 +41,7 @@ export function CanvasShortDramaEmptyState({ onCreatePipeline, onOpenAgent, onUp
<h2 className="text-lg font-semibold">从哪里开始?</h2>
<p className="mt-1 text-sm" style={{ color: theme.node.muted }}>选择一条主路径,之后仍可随时切换。</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-3 md:grid-cols-3">
<PathCard
icon={<Clapperboard className="size-5" />}
title="自己创作"
@@ -60,6 +62,16 @@ export function CanvasShortDramaEmptyState({ onCreatePipeline, onOpenAgent, onUp
focusStyle={focusStyle}
onClick={onOpenAgent}
/>
<PathCard
icon={<Plus className="size-5" />}
title="自由空白画布"
description="不预设流程,自由添加文本、图片、音频和视频。"
action="从空白画布开始"
accent={theme.node.muted}
theme={theme}
focusStyle={focusStyle}
onClick={onStartFreeform}
/>
</div>
<div className="mt-3 flex justify-center">
<Dropdown
@@ -82,16 +94,39 @@ export function CanvasShortDramaEmptyState({ onCreatePipeline, onOpenAgent, onUp
);
}
export function CanvasFreeformEmptyState({ onUpload, onAddText }: { onUpload: () => void; onAddText: () => void }) {
export function CanvasFreeformEmptyState({ commands }: { commands: CanvasCreateCommand[] }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [createOpen, setCreateOpen] = useState(false);
const createCommands = commands.map((command) => ({
...command,
onClick: () => {
setCreateOpen(false);
command.onClick();
},
}));
return (
<div className="pointer-events-none absolute inset-0 z-20 grid place-items-center px-4 pb-20 pt-24">
<div className="pointer-events-auto w-full max-w-[440px] rounded-lg border p-4 shadow-sm backdrop-blur" data-canvas-no-zoom style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
<div className="text-center"><h2 className="text-base font-semibold">从空白画布开始</h2><p className="mt-1 text-xs" style={{ color: theme.node.muted }}>添加文本或导入已有素材。</p></div>
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onAddText} className="inline-flex h-10 items-center justify-center gap-2 rounded-md border text-sm font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Type className="size-4" />新建文本</button>
<button type="button" onClick={onUpload} className="inline-flex h-10 items-center justify-center gap-2 rounded-md border text-sm font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Upload className="size-4" />导入素材</button>
</div>
<div className="pointer-events-auto flex min-h-[260px] w-full max-w-[520px] flex-col items-center justify-center rounded-2xl border border-dashed px-8 py-10 text-center backdrop-blur" data-canvas-no-zoom style={{ background: theme.node.fill, borderColor: theme.node.edge, boxShadow: theme.node.shadow, color: theme.node.text }}>
<h2 className="text-base font-semibold">自由空白画布</h2>
<p className="mt-1 text-xs" style={{ color: theme.node.muted }}>不预设流程,从任意一种素材开始创作。</p>
<Popover
arrow={false}
open={createOpen}
onOpenChange={setCreateOpen}
placement="bottom"
trigger="click"
content={<div className="w-[420px] max-w-[calc(100vw-48px)] p-1" onWheel={(event) => event.stopPropagation()}><CanvasCreateMenu commands={createCommands} /></div>}
>
<button
type="button"
aria-label="添加第一项"
className="mt-7 grid size-14 place-items-center rounded-full border outline-none transition-transform hover:scale-105 focus-visible:ring-2 motion-reduce:transition-none motion-reduce:hover:scale-100"
style={{ background: theme.toolbar.panel, borderColor: theme.node.edge, color: theme.node.text, boxShadow: theme.node.shadow, "--tw-ring-color": theme.accent.primary } as CSSProperties}
>
<Plus className="size-6" />
</button>
</Popover>
<p className="mt-4 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>点击 + 添加文本、图片、视频、音频或导入素材</p>
</div>
</div>
);

View File

@@ -1,19 +1,20 @@
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import { Segmented, Switch } from "antd";
import { CircleDot, Grid2x2, Moon, Palette, Sun, Square, Info } from "lucide-react";
import { Switch } from "antd";
import { Palette, Info } from "lucide-react";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
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 { 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 { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
import { defaultToolbarPrefs, readToolbarPrefs, resolveAddNodeMenuCommands, resolveToolbarEntries, type AddNodeMenuCommand, type ToolContext, type ToolbarHandlers, type ToolbarPrefs } from "@/lib/canvas/tool-registry";
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 { useThemeStore } from "@/stores/use-theme-store";
import { usePluginStore } from "@/stores/use-plugin-store";
import type { CanvasNodeTypeId, CanvasToolMode, CanvasWorkspaceMode } from "@/types/canvas";
export function CanvasToolbar({
@@ -24,6 +25,7 @@ export function CanvasToolbar({
isProjectLinked,
canUndo,
canRedo,
appearance,
backgroundMode,
showImageInfo,
onAddImage,
@@ -44,6 +46,8 @@ export function CanvasToolbar({
onDelete,
onClear,
onDeselect,
onAppearanceChange,
onSaveAppearanceDefault,
onBackgroundModeChange,
onShowImageInfoChange,
onOpenMyAssets,
@@ -56,6 +60,7 @@ export function CanvasToolbar({
isProjectLinked: boolean;
canUndo: boolean;
canRedo: boolean;
appearance: CanvasAppearance;
backgroundMode: CanvasBackgroundMode;
showImageInfo: boolean;
onAddImage: () => void;
@@ -76,6 +81,8 @@ export function CanvasToolbar({
onDelete: () => void;
onClear: () => void;
onDeselect: () => void;
onAppearanceChange: (appearance: CanvasAppearance) => void;
onSaveAppearanceDefault: (appearance: CanvasAppearance) => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onShowImageInfoChange: (show: boolean) => void;
onOpenMyAssets: () => void;
@@ -84,9 +91,6 @@ export function CanvasToolbar({
const rootRef = useRef<HTMLDivElement>(null);
const dockRef = useRef<HTMLDivElement>(null);
const colorTheme = useThemeStore((state) => state.theme);
const installations = usePluginStore((state) => state.installations);
const pluginStates = usePluginStore((state) => state.pluginStates);
const setTheme = useThemeStore((state) => state.setTheme);
const theme = canvasThemes[colorTheme];
const [addOpen, setAddOpen] = useState(false);
const [appearanceOpen, setAppearanceOpen] = useState(false);
@@ -111,6 +115,8 @@ export function CanvasToolbar({
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);
};
@@ -174,21 +180,10 @@ export function CanvasToolbar({
handlers,
};
const enabledPluginIds = new Set(installations.filter((item) => pluginStates[item.manifest.id]?.effectiveEnabled ?? item.enabled).map((item) => item.manifest.id));
const items = resolveToolbarEntries("main", ctx, prefs ?? defaultToolbarPrefs("main"));
// 解析添加节点菜单命令——onClick 绑定到 runAddAction 以在执行后关闭面板
const addNodeCommands = resolveAddNodeMenuCommands({ ...ctx, enabledPluginIds });
const toCommand = (cmd: AddNodeMenuCommand): CanvasCreateCommand => ({
id: cmd.id,
label: cmd.label,
icon: cmd.icon,
badge: cmd.badge,
section: cmd.section,
onClick: () => runAddAction(() => cmd.run(ctx)),
});
const createCommands = addNodeCommands.map(toCommand);
// 中央空白起点与主工具栏共用同一份命令解析,避免素材类型和插件节点逐渐分叉。
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)] z-[var(--z-toolbar)] flex justify-center">
@@ -206,25 +201,10 @@ export function CanvasToolbar({
<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-[224px] max-w-[calc(100vw-24px)]" style={{ left: panelX || "50%", transformOrigin: "bottom center", x: "-50%" }}>
<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} />
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">主题模式</div>
<div className="mt-1 grid grid-cols-2 gap-1 rounded-[var(--dock-item-radius-labeled)] border p-1" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="light" onThemeChange={setTheme}><Sun className="size-3.5" />浅色</CanvasThemeButton>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="dark" onThemeChange={setTheme}><Moon className="size-3.5" />深色</CanvasThemeButton>
</div>
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">空间网格</div>
<Segmented
className="mt-1 w-full !rounded-[var(--dock-item-radius-labeled)] !p-0.5 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-7 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-7 [&_.ant-segmented-item-label]:!text-[var(--fs-tiny)] [&_.ant-segmented-item-label]:!leading-7"
value={backgroundMode}
onChange={(value) => onBackgroundModeChange(value as CanvasBackgroundMode)}
options={[
{ value: "dots", label: <span className="inline-flex items-center gap-1.5"><CircleDot className="size-3.5" />点</span> },
{ value: "lines", label: <span className="inline-flex items-center gap-1.5"><Grid2x2 className="size-3.5" />线</span> },
{ value: "blank", label: <span className="inline-flex items-center gap-1.5"><Square className="size-3.5" />空白</span> },
]}
/>
<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="small" checked={showImageInfo} onChange={onShowImageInfoChange} />
@@ -262,24 +242,6 @@ function PanelHeading({ icon, title, subtitle, theme }: { icon: ReactNode; title
);
}
function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }: { colorTheme: CanvasColorTheme; targetTheme: CanvasColorTheme; onThemeChange: (theme: CanvasColorTheme) => void; children: ReactNode }) {
const theme = canvasThemes[colorTheme];
const active = colorTheme === targetTheme;
return (
<AnimatedThemeToggler
theme={colorTheme}
targetTheme={targetTheme}
onThemeChange={onThemeChange}
className="inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-xs font-semibold transition-colors"
style={active ? { background: theme.node.text, color: theme.node.panel } : { color: theme.toolbar.item }}
aria-label={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
title={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
>
{children}
</AnimatedThemeToggler>
);
}
function getPanelX(dock: HTMLDivElement | null, target: HTMLElement) {
if (!dock) return 0;
const rootBox = dock.parentElement?.getBoundingClientRect() || dock.getBoundingClientRect();

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
import { resolveCanvasAppearance, resolveCanvasGridColor, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import { applyCanvasLiveViewport, canvasDotGridPx, canvasDotPx, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { useThemeStore } from "@/stores/use-theme-store";
import type { ViewportTransform } from "@/types/canvas";
@@ -8,6 +9,7 @@ import type { ViewportTransform } from "@/types/canvas";
type InfiniteCanvasProps = {
containerRef: React.RefObject<HTMLDivElement | null>;
viewport: ViewportTransform;
appearance?: CanvasAppearance;
backgroundMode?: CanvasBackgroundMode;
onViewportChange: (viewport: ViewportTransform) => void;
onViewportPreviewChange?: (viewport: ViewportTransform) => void;
@@ -40,10 +42,9 @@ type PinchState = {
initialScale: number;
};
export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) {
export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) {
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const canvasBackground = colorTheme === "light" ? "#e6e6e6" : theme.canvas.background;
const resolvedAppearance = resolveCanvasAppearance(appearance, colorTheme);
const panState = useRef({
isPanning: false,
pointerId: -1,
@@ -374,7 +375,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
ref={containerRef}
className={`relative h-full w-full select-none overflow-hidden touch-none ${isPanning ? "cursor-grabbing" : boxSelectEnabled ? "cursor-crosshair" : "cursor-grab"}`}
style={{
background: canvasBackground,
background: resolvedAppearance.background,
overscrollBehavior: "none",
"--canvas-live-x": `${viewport.x}px`,
"--canvas-live-y": `${viewport.y}px`,
@@ -404,7 +405,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
}}
onDrop={onDrop}
>
<CanvasGrid mode={backgroundMode} />
<CanvasGrid appearance={appearance} mode={backgroundMode} />
{graphicsLayer}
<div
data-canvas-world-layer
@@ -418,9 +419,10 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
);
}
function CanvasGrid({ mode }: { mode: CanvasBackgroundMode }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const backgroundImage = mode === "dots" ? `radial-gradient(circle, ${theme.canvas.dot} var(--canvas-dot-size), transparent calc(var(--canvas-dot-size) + 0.2px))` : `linear-gradient(${theme.canvas.line} 1px, transparent 1px), linear-gradient(90deg, ${theme.canvas.line} 1px, transparent 1px)`;
function CanvasGrid({ appearance, mode }: { appearance?: CanvasAppearance; mode: CanvasBackgroundMode }) {
const colorTheme = useThemeStore((state) => state.theme);
const gridColor = resolveCanvasGridColor(appearance, colorTheme, mode);
const backgroundImage = mode === "dots" ? `radial-gradient(circle, ${gridColor} var(--canvas-dot-size), transparent calc(var(--canvas-dot-size) + 0.2px))` : `linear-gradient(${gridColor} 1px, transparent 1px), linear-gradient(90deg, ${gridColor} 1px, transparent 1px)`;
if (mode === "blank") return null;
return (

View File

@@ -0,0 +1,23 @@
import { resolveAddNodeMenuCommands, type AddNodeMenuContext } from "@/lib/canvas/tool-registry";
import { usePluginStore } from "@/stores/use-plugin-store";
import type { CanvasCreateCommand } from "./canvas-create-menu";
export function useCanvasCreateCommands(context: AddNodeMenuContext, runCommand?: (command: () => void) => void): CanvasCreateCommand[] {
const installations = usePluginStore((state) => state.installations);
const pluginStates = usePluginStore((state) => state.pluginStates);
const enabledPluginIds = new Set(installations.filter((item) => pluginStates[item.manifest.id]?.effectiveEnabled ?? item.enabled).map((item) => item.manifest.id));
return resolveAddNodeMenuCommands({ ...context, enabledPluginIds }).map((command) => ({
id: command.id,
label: command.label,
icon: command.icon,
badge: command.badge,
section: command.section,
onClick: () => {
const run = () => command.run(context);
if (runCommand) runCommand(run);
else run();
},
}));
}

View File

@@ -4,7 +4,7 @@ export type CanvasBackgroundMode = "dots" | "lines" | "blank";
export const canvasThemes = {
light: {
canvas: {
background: "#ffffff",
background: "#edeeee",
dot: "rgba(15,23,42,.20)",
line: "rgba(15,23,42,.15)",
selectionFill: "rgba(17,17,17,.10)",
@@ -70,9 +70,9 @@ export const canvasThemes = {
},
dark: {
canvas: {
background: "#0b0b0b",
dot: "rgba(178,178,178,.18)",
line: "rgba(178,178,178,.14)",
background: "#262626",
dot: "rgba(205,205,205,.28)",
line: "rgba(205,205,205,.28)",
selectionFill: "rgba(255,255,255,.12)",
},
node: {

View File

@@ -0,0 +1,174 @@
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme } from "@/lib/canvas-theme";
import { scopedLocalStorage } from "@/lib/user-scope";
export type CanvasAppearanceMode = CanvasColorTheme | "custom";
export type CanvasCustomAppearance = {
baseTheme: CanvasColorTheme;
backgroundColor: string;
backgroundBrightness: number;
gridColor: string;
gridOpacity: number;
};
export type CanvasAppearance = {
mode: CanvasAppearanceMode;
custom?: CanvasCustomAppearance;
};
export type CanvasAppearanceDefault = {
appearance: CanvasAppearance;
backgroundMode: CanvasBackgroundMode;
};
export type ResolvedCanvasAppearance = {
baseTheme: CanvasColorTheme;
background: string;
grid: string;
};
const CANVAS_APPEARANCE_DEFAULT_KEY = "infinite-canvas:canvas-appearance-default";
const HEX_COLOR_PATTERN = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
const CUSTOM_GRID_COLOR = "#B6B3B3";
const CUSTOM_GRID_OPACITY = 70;
export function canvasAppearanceForTheme(theme: CanvasColorTheme, previous?: CanvasAppearance): CanvasAppearance {
return previous?.custom?.baseTheme === theme ? { mode: theme, custom: previous.custom } : { mode: theme };
}
export function customCanvasAppearanceFromTheme(theme: CanvasColorTheme): CanvasAppearance {
return {
mode: "custom",
custom: {
baseTheme: theme,
backgroundColor: canvasThemes[theme].canvas.background.toUpperCase(),
backgroundBrightness: 0,
gridColor: CUSTOM_GRID_COLOR,
gridOpacity: CUSTOM_GRID_OPACITY,
},
};
}
export function enterCustomCanvasAppearance(current: CanvasAppearance, currentTheme: CanvasColorTheme) {
if (current.custom) return { ...current, mode: "custom" } as CanvasAppearance;
return customCanvasAppearanceFromTheme(currentTheme);
}
export function canvasAppearanceBaseTheme(appearance: CanvasAppearance | undefined, fallback: CanvasColorTheme): CanvasColorTheme {
if (appearance?.mode === "light" || appearance?.mode === "dark") return appearance.mode;
return appearance?.custom?.baseTheme === "light" || appearance?.custom?.baseTheme === "dark"
? appearance.custom.baseTheme
: fallback;
}
export function normalizeCanvasAppearance(value: unknown, fallback: CanvasColorTheme): CanvasAppearance {
if (!value || typeof value !== "object") return canvasAppearanceForTheme(fallback);
const candidate = value as Partial<CanvasAppearance>;
const mode = candidate.mode === "light" || candidate.mode === "dark" || candidate.mode === "custom" ? candidate.mode : fallback;
const custom = normalizeCustomAppearance(candidate.custom);
if (mode === "custom" && !custom) return customCanvasAppearanceFromTheme(fallback);
return custom ? { mode, custom } : { mode };
}
export function resolveCanvasAppearance(appearance: CanvasAppearance | undefined, fallback: CanvasColorTheme): ResolvedCanvasAppearance {
const normalized = normalizeCanvasAppearance(appearance, fallback);
const baseTheme = canvasAppearanceBaseTheme(normalized, fallback);
const base = canvasThemes[baseTheme];
if (normalized.mode !== "custom" || !normalized.custom) {
return {
baseTheme,
background: base.canvas.background,
grid: base.canvas.line,
};
}
const custom = normalized.custom;
return {
baseTheme,
background: adjustHexLightnessOklch(custom.backgroundColor, custom.backgroundBrightness),
grid: rgbaFromHex(custom.gridColor, custom.gridOpacity / 100),
};
}
export function resolveCanvasGridColor(appearance: CanvasAppearance | undefined, fallback: CanvasColorTheme, mode: CanvasBackgroundMode) {
const normalized = normalizeCanvasAppearance(appearance, fallback);
if (normalized.mode === "custom" && normalized.custom) return rgbaFromHex(normalized.custom.gridColor, normalized.custom.gridOpacity / 100);
const theme = canvasThemes[canvasAppearanceBaseTheme(normalized, fallback)];
return mode === "dots" ? theme.canvas.dot : theme.canvas.line;
}
export function normalizeHexColor(value: string) {
const match = value.trim().match(HEX_COLOR_PATTERN);
if (!match) return null;
const hex = match[1].length === 3
? match[1].split("").map((channel) => channel.repeat(2)).join("")
: match[1];
return `#${hex.toUpperCase()}`;
}
export function readCanvasAppearanceDefault(): CanvasAppearanceDefault | null {
const value = scopedLocalStorage.getItem(CANVAS_APPEARANCE_DEFAULT_KEY);
if (!value) return null;
try {
const parsed = JSON.parse(value) as Partial<CanvasAppearanceDefault>;
if (parsed.backgroundMode !== "dots" && parsed.backgroundMode !== "lines" && parsed.backgroundMode !== "blank") return null;
const fallback = canvasAppearanceBaseTheme(parsed.appearance, "dark");
return {
appearance: normalizeCanvasAppearance(parsed.appearance, fallback),
backgroundMode: parsed.backgroundMode,
};
} catch {
return null;
}
}
export function writeCanvasAppearanceDefault(value: CanvasAppearanceDefault) {
const fallback = canvasAppearanceBaseTheme(value.appearance, "dark");
scopedLocalStorage.setItem(CANVAS_APPEARANCE_DEFAULT_KEY, JSON.stringify({
appearance: normalizeCanvasAppearance(value.appearance, fallback),
backgroundMode: value.backgroundMode,
}));
}
function normalizeCustomAppearance(value: unknown): CanvasCustomAppearance | undefined {
if (!value || typeof value !== "object") return undefined;
const candidate = value as Partial<CanvasCustomAppearance>;
const backgroundColor = typeof candidate.backgroundColor === "string" ? normalizeHexColor(candidate.backgroundColor) : null;
const gridColor = typeof candidate.gridColor === "string" ? normalizeHexColor(candidate.gridColor) : null;
if (!backgroundColor || !gridColor || (candidate.baseTheme !== "light" && candidate.baseTheme !== "dark")) return undefined;
return {
baseTheme: candidate.baseTheme,
backgroundColor,
backgroundBrightness: clampNumber(candidate.backgroundBrightness, -30, 30, 0),
gridColor,
gridOpacity: clampNumber(candidate.gridOpacity, 0, 100, CUSTOM_GRID_OPACITY),
};
}
function clampNumber(value: unknown, min: number, max: number, fallback: number) {
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
}
function rgbaFromHex(value: string, opacity: number) {
const normalized = normalizeHexColor(value) || "#808080";
const [red, green, blue] = [1, 3, 5].map((offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16));
return `rgba(${red},${green},${blue},${clampNumber(opacity, 0, 1, 1).toFixed(3).replace(/0+$/, "").replace(/\.$/, "")})`;
}
function adjustHexLightnessOklch(value: string, amount: number) {
const normalized = normalizeHexColor(value) || "#808080";
if (!amount) return normalized;
const [red, green, blue] = [1, 3, 5]
.map((offset) => Number.parseInt(normalized.slice(offset, offset + 2), 16) / 255)
.map((channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue);
const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue);
const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue);
const lightness = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
const b = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
const chroma = Math.sqrt(a * a + b * b);
const hue = chroma < 0.0001 ? 0 : (Math.atan2(b, a) * 180 / Math.PI + 360) % 360;
const adjustedLightness = Math.min(1, Math.max(0, lightness + amount / 100));
return `oklch(${(adjustedLightness * 100).toFixed(2)}% ${chroma.toFixed(4)} ${hue.toFixed(2)})`;
}

View File

@@ -0,0 +1,13 @@
import type { Position } from "@/types/canvas";
type NodeSize = {
width: number;
height: number;
};
export function connectedNodeCenterFromEdgeDrop(dropPosition: Position, nodeSize: NodeSize, handleType: "source" | "target"): Position {
return {
x: dropPosition.x + (handleType === "source" ? nodeSize.width / 2 : -nodeSize.width / 2),
y: dropPosition.y,
};
}

View File

@@ -0,0 +1,20 @@
export type CanvasStarterMode = "guided" | "freeform";
export type CanvasEmptyStateKind = "none" | "guided" | "freeform" | "linked";
export function resolveCanvasEmptyStateKind({
nodeCount,
shortDramaEnabled,
isProjectLinked,
starterMode,
}: {
nodeCount: number;
shortDramaEnabled: boolean;
isProjectLinked: boolean;
starterMode?: CanvasStarterMode;
}): CanvasEmptyStateKind {
if (nodeCount > 0) return "none";
if (!shortDramaEnabled) return "freeform";
if (isProjectLinked) return "linked";
return starterMode === "freeform" ? "freeform" : "guided";
}

View File

@@ -12,6 +12,7 @@ import { uploadImage } from "@/services/image-storage";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import copyToClipboard from "copy-to-clipboard";
import { nanoid } from "nanoid";
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode";
import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
@@ -55,6 +56,7 @@ import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { Minimap } from "@/components/canvas/canvas-mini-map";
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { CanvasToolbar } from "@/components/canvas/canvas-toolbar";
import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands";
import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
import { getProject } from "@/services/api/projects";
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
@@ -71,6 +73,7 @@ import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components
import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer";
import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain";
import { stampCanvasNodeChanges } from "@/lib/canvas/canvas-node-timestamps";
@@ -215,7 +218,9 @@ function InfiniteCanvasPage() {
const assets = useAssetStore((state) => state.assets);
const assetsHydrated = useAssetStore((state) => state.hydrated);
const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const theme = canvasThemes[colorTheme];
const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine);
const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || "");
@@ -244,6 +249,7 @@ function InfiniteCanvasPage() {
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
const [canvasAppearance, setCanvasAppearance] = useState<CanvasAppearance>(() => canvasAppearanceForTheme(colorTheme));
const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>("lines");
const [showImageInfo, setShowImageInfo] = useState(false);
const [canvasTool, setCanvasTool] = useState<CanvasToolMode>("move");
@@ -335,18 +341,32 @@ function InfiniteCanvasPage() {
});
}, [canvasStorageScope, projectId]);
const resolvedCanvasAppearance = useMemo(() => resolveCanvasAppearance(canvasAppearance, colorTheme), [canvasAppearance, colorTheme]);
const applyCanvasAppearance = useCallback((next: CanvasAppearance) => {
const fallback = canvasAppearanceBaseTheme(next, colorTheme);
const normalized = normalizeCanvasAppearance(next, fallback);
setCanvasAppearance(normalized);
setTheme(canvasAppearanceBaseTheme(normalized, fallback));
}, [colorTheme, setTheme]);
const saveCanvasAppearanceDefault = useCallback((next: CanvasAppearance) => {
writeCanvasAppearanceDefault({ appearance: next, backgroundMode });
message.success("已保存为当前账号在本机的新建画布默认外观");
}, [backgroundMode, message]);
const { getHistoryCleanupContext, historyPausedRef, historyState, redoCanvas, resetHistory, undoCanvas } = useCanvasHistory({
projectLoaded,
nodes,
connections,
chatSessions,
activeChatId,
canvasAppearance,
backgroundMode,
showImageInfo,
setNodes,
setConnections,
setChatSessions,
setActiveChatId,
applyCanvasAppearance,
setBackgroundMode,
setShowImageInfo,
setSelectedNodeIds,
@@ -368,6 +388,7 @@ function InfiniteCanvasPage() {
connections,
chatSessions,
activeChatId,
canvasAppearance,
backgroundMode,
showImageInfo,
viewport,
@@ -379,6 +400,7 @@ function InfiniteCanvasPage() {
setConnections,
setChatSessions,
setActiveChatId,
setCanvasAppearance,
setBackgroundMode,
setShowImageInfo,
setViewport,
@@ -1944,20 +1966,47 @@ function InfiniteCanvasPage() {
}
focusCanvasNode(styleNode.id);
}, [focusCanvasNode, message, nodesRef]);
const emptyCanvasState = nodes.length ? null : !shortDramaEnabled ? (
<CanvasFreeformEmptyState onUpload={() => handleUploadRequest()} onAddText={() => createNode(CanvasNodeType.Text)} />
) : currentProject?.projectId ? (
const freeformCreateCommands = useCanvasCreateCommands({
workspaceMode,
isProjectLinked: Boolean(shortDramaEnabled && currentProject?.projectId),
handlers: {
onAddText: () => createNode(CanvasNodeType.Text),
onAddImage: () => createNode(CanvasNodeType.Image),
onAddVideo: () => createNode(CanvasNodeType.Video),
onAddAudio: () => createNode(CanvasNodeType.Audio),
onAddScript: () => createNode(CanvasNodeType.Script),
onAddFrame: () => createNode(CanvasNodeType.Frame),
onAddFolder: createFolder,
onAddDrawing: () => createNode(CanvasNodeType.Drawing),
onAddWorkflow: () => createNode(CanvasNodeType.Config),
onAddExtensionNode: (type) => createNode(type),
onChooseStyle: () => setStylePickerOpen(true),
onOpenDirector: () => setDirectorTemplateRequest({}),
onUpload: () => handleUploadRequest(),
onOpenMyAssets: () => openCanvasAssetLibrary(),
onOpenProjectCharacters: () => openProjectAssets("character"),
},
});
const emptyStateKind = resolveCanvasEmptyStateKind({
nodeCount: nodes.length,
shortDramaEnabled,
isProjectLinked: Boolean(currentProject?.projectId),
starterMode: currentProject?.starterMode,
});
const emptyCanvasState = emptyStateKind === "freeform" ? (
<CanvasFreeformEmptyState commands={freeformCreateCommands} />
) : emptyStateKind === "linked" ? (
<CanvasLinkedProjectEmptyState
projectName={linkedProjectQuery.data?.project.name || currentProject.title}
projectName={linkedProjectQuery.data?.project.name || currentProject?.title || "项目画布"}
hasChapter={Boolean(linkedProjectQuery.data?.units.length)}
onAddFirstChapter={() => {
const first = linkedProjectQuery.data?.units.slice().sort((left, right) => left.position - right.position)[0];
if (first) void handleProjectChapterInsert({ id: first.id, projectId: currentProject.projectId!, title: first.title, position: first.position });
if (first) void handleProjectChapterInsert({ id: first.id, projectId: linkedProjectId, title: first.title, position: first.position });
}}
onOpenAssets={() => openProjectAssets()}
onAddText={() => createNode(CanvasNodeType.Text)}
/>
) : (
) : emptyStateKind === "guided" ? (
<CanvasShortDramaEmptyState
onCreatePipeline={createShortDramaPipeline}
onOpenAgent={() => {
@@ -1965,11 +2014,12 @@ function InfiniteCanvasPage() {
setAgentMode("online");
openAgent("online");
}}
onStartFreeform={() => updateProject(projectId, { starterMode: "freeform" })}
onUpload={() => handleUploadRequest()}
onAddText={() => createNode(CanvasNodeType.Text)}
onAddScript={() => createNode(CanvasNodeType.Script)}
/>
);
) : null;
if (!projectLoaded) return <CanvasRefreshShell />;
return (
@@ -1980,7 +2030,7 @@ function InfiniteCanvasPage() {
>
跳转到画布主内容
</a>
<main id="canvas-main" tabIndex={-1} className="flex h-full min-h-0 overflow-hidden outline-none" style={{ background: theme.canvas.background, color: theme.node.text }}>
<main id="canvas-main" tabIndex={-1} className="flex h-full min-h-0 overflow-hidden outline-none" style={{ background: resolvedCanvasAppearance.background, color: theme.node.text }}>
{!focusMode && shortDramaEnabled && currentProject?.projectId ? (
<CanvasProjectSidebar projectId={currentProject.projectId} detail={linkedProjectQuery.data} onAddChapter={handleProjectChapterInsert} onLocateStyle={locateProjectStyleNode} onOpenAssets={() => openProjectAssets()} />
) : null}
@@ -2078,6 +2128,7 @@ function InfiniteCanvasPage() {
<InfiniteCanvas
containerRef={containerRef}
viewport={viewport}
appearance={canvasAppearance}
backgroundMode={backgroundMode}
graphicsLayer={
<CanvasLeaferGraphicsLayer
@@ -2217,6 +2268,7 @@ function InfiniteCanvasPage() {
isProjectLinked={Boolean(shortDramaEnabled && currentProject?.projectId)}
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
appearance={canvasAppearance}
backgroundMode={backgroundMode}
showImageInfo={showImageInfo}
onAddImage={() => createNode(CanvasNodeType.Image)}
@@ -2237,6 +2289,8 @@ function InfiniteCanvasPage() {
onDelete={() => deleteNodes(new Set(selectedNodeIds))}
onClear={() => setClearConfirmOpen(true)}
onDeselect={deselectCanvas}
onAppearanceChange={applyCanvasAppearance}
onSaveAppearanceDefault={saveCanvasAppearanceDefault}
onBackgroundModeChange={setBackgroundMode}
onShowImageInfoChange={setShowImageInfo}
onOpenMyAssets={() => {

View File

@@ -13,6 +13,7 @@ import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, normalizeCanvasAppearance, resolveCanvasAppearance, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import { canvasThemes } from "@/lib/canvas-theme";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, isCanvasFolderNode, isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
@@ -26,7 +27,8 @@ type DragState = { primaryId: string; nodeIds: string[]; startX: number; startY:
export default function SharedCanvasPage() {
const { token = "" } = useParams();
const { message } = App.useApp();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const containerRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<ViewportTransform>({ x: 0, y: 0, k: 1 });
const dragRef = useRef<DragState | null>(null);
@@ -34,6 +36,7 @@ export default function SharedCanvasPage() {
const [title, setTitle] = useState("共享画布");
const [nodes, setNodes] = useState<CanvasNodeData[]>([]);
const [connections, setConnections] = useState<Awaited<ReturnType<typeof getPublicCanvasShare>>["project"]["connections"]>([]);
const [appearance, setAppearance] = useState<CanvasAppearance>(() => canvasAppearanceForTheme(colorTheme));
const [backgroundMode, setBackgroundMode] = useState<"lines" | "dots" | "blank">("lines");
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 });
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
@@ -74,12 +77,22 @@ export default function SharedCanvasPage() {
useEffect(() => {
let active = true;
const themeBeforeShare = useThemeStore.getState().theme;
let appliedShareTheme: typeof themeBeforeShare | null = null;
let themeChangedAfterApply = false;
const unsubscribeTheme = useThemeStore.subscribe((state, previous) => {
if (appliedShareTheme && state.theme !== previous.theme && state.theme !== appliedShareTheme) themeChangedAfterApply = true;
});
setLoading(true);
getPublicCanvasShare(token).then(({ project }) => {
if (!active) return;
setTitle(project.title || "共享画布");
setNodes((project.nodes || []).map(ensureMediaNodeMinimumSize));
setConnections(project.connections || []);
const nextAppearance = project.appearance ? normalizeCanvasAppearance(project.appearance, themeBeforeShare) : canvasAppearanceForTheme(themeBeforeShare);
setAppearance(nextAppearance);
appliedShareTheme = canvasAppearanceBaseTheme(nextAppearance, themeBeforeShare);
useThemeStore.getState().setTheme(appliedShareTheme);
setBackgroundMode(project.backgroundMode || "lines");
const initial = project.viewport || { x: 0, y: 0, k: 1 };
viewportRef.current = initial;
@@ -89,7 +102,13 @@ export default function SharedCanvasPage() {
}).finally(() => {
if (active) setLoading(false);
});
return () => { active = false; };
return () => {
active = false;
unsubscribeTheme();
if (appliedShareTheme && !themeChangedAfterApply && useThemeStore.getState().theme === appliedShareTheme) {
useThemeStore.getState().setTheme(themeBeforeShare);
}
};
}, [token]);
useEffect(() => {
@@ -222,7 +241,7 @@ export default function SharedCanvasPage() {
if (loadError) return <div className="grid h-screen place-items-center px-5" style={{ background: theme.canvas.background }}><WorkspaceState icon="error" title="分享链接不可用" description={loadError} action={<Link to="/"><Button>返回首页</Button></Link>} /></div>;
return (
<main className="relative h-screen overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
<main className="relative h-screen overflow-hidden" style={{ background: resolveCanvasAppearance(appearance, colorTheme).background, color: theme.node.text }}>
<header className="pointer-events-none absolute inset-x-0 top-0 z-[var(--z-panel-floating)] flex h-16 items-center justify-between px-5">
<div className="pointer-events-auto flex min-w-0 items-center gap-3">
<Share2 className="size-4" style={{ color: theme.node.muted }} />
@@ -232,7 +251,7 @@ export default function SharedCanvasPage() {
<Link className="pointer-events-auto" to="/login"><Button type="text" icon={<LogIn className="size-4" />}>登录</Button></Link>
</header>
<InfiniteCanvas containerRef={containerRef} viewport={viewport} backgroundMode={backgroundMode} onViewportChange={onViewportChange} onViewportPreviewChange={(next) => { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}>
<InfiniteCanvas containerRef={containerRef} viewport={viewport} appearance={appearance} backgroundMode={backgroundMode} onViewportChange={onViewportChange} onViewportPreviewChange={(next) => { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}>
<svg className="absolute overflow-visible" viewBox={`${connectionBounds.left} ${connectionBounds.top} ${connectionBounds.width} ${connectionBounds.height}`} style={{ left: connectionBounds.left, top: connectionBounds.top, width: connectionBounds.width, height: connectionBounds.height, pointerEvents: "none", zIndex: 0 }}>
{visibleConnections.map(({ connection, from, to }) => <ConnectionPath key={connection.id} connection={connection} from={from} to={to} active={false} onSelect={() => setInfoNodeId(to.id)} />)}
</svg>

View File

@@ -5,6 +5,7 @@ import { nanoid } from "nanoid";
import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
import { getNodeSpec } from "@/constant/canvas";
import { batchSourceRestriction, buildBatchConnectionCreateRequest, hasBatchConnectionCandidate, planBatchConnections, type CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import { connectedNodeCenterFromEdgeDrop } from "@/lib/canvas/canvas-connected-node-placement";
import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy";
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardPromptTemplateMetadata, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage";
@@ -255,7 +256,9 @@ export function useCanvasConnectionController({
: sourceNodeForQuickCreate.position.x - 96 - spec.width / 2,
y: anchorY,
}
: pending.position;
: batchSourceNodeIds.length
? pending.position
: connectedNodeCenterFromEdgeDrop(pending.position, spec, pending.connection.handleType);
const newNode = createCanvasNode(nodeType, position, metadata);
if (nodeType === CanvasNodeType.Config && selectedWorkflowProvider) newNode.title = selectedWorkflowProvider === "runninghub" ? "RunningHub 工作流" : "ComfyUI Bridge";
if (storyboardRow) newNode.title = `镜头 ${storyboardRow.shotNumber} · 视频`;

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import type { CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ContextMenuState } from "@/types/canvas";
@@ -8,6 +9,7 @@ export type CanvasHistorySnapshot = {
connections: CanvasConnection[];
chatSessions: CanvasAssistantSession[];
activeChatId: string | null;
canvasAppearance: CanvasAppearance;
backgroundMode: CanvasBackgroundMode;
showImageInfo: boolean;
};
@@ -34,6 +36,7 @@ type CanvasHistoryPatch = {
connections?: EntityPatch<CanvasConnection>;
chatSessions?: EntityPatch<CanvasAssistantSession>;
activeChatId?: ValuePatch<string | null>;
canvasAppearance?: ValuePatch<CanvasAppearance>;
backgroundMode?: ValuePatch<CanvasBackgroundMode>;
showImageInfo?: ValuePatch<boolean>;
};
@@ -44,6 +47,7 @@ type UseCanvasHistoryOptions = CanvasHistorySnapshot & {
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setChatSessions: Dispatch<SetStateAction<CanvasAssistantSession[]>>;
setActiveChatId: Dispatch<SetStateAction<string | null>>;
applyCanvasAppearance: (appearance: CanvasAppearance) => void;
setBackgroundMode: Dispatch<SetStateAction<CanvasBackgroundMode>>;
setShowImageInfo: Dispatch<SetStateAction<boolean>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
@@ -57,12 +61,14 @@ export function useCanvasHistory({
connections,
chatSessions,
activeChatId,
canvasAppearance,
backgroundMode,
showImageInfo,
setNodes,
setConnections,
setChatSessions,
setActiveChatId,
applyCanvasAppearance,
setBackgroundMode,
setShowImageInfo,
setSelectedNodeIds,
@@ -78,8 +84,8 @@ export function useCanvasHistory({
const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false });
const createHistorySnapshot = useCallback(
(): CanvasHistorySnapshot => ({ nodes, connections, chatSessions, activeChatId, backgroundMode, showImageInfo }),
[activeChatId, backgroundMode, chatSessions, connections, nodes, showImageInfo],
(): CanvasHistorySnapshot => ({ nodes, connections, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo }),
[activeChatId, backgroundMode, canvasAppearance, chatSessions, connections, nodes, showImageInfo],
);
const clearCommitTimer = useCallback(() => {
@@ -109,6 +115,7 @@ export function useCanvasHistory({
setConnections(snapshot.connections);
setChatSessions(snapshot.chatSessions);
setActiveChatId(snapshot.activeChatId);
applyCanvasAppearance(snapshot.canvasAppearance);
setBackgroundMode(snapshot.backgroundMode);
setShowImageInfo(snapshot.showImageInfo);
setSelectedNodeIds(new Set());
@@ -120,7 +127,7 @@ export function useCanvasHistory({
applyTimerRef.current = null;
setHistoryState({ canUndo: historyRef.current.past.length > 0, canRedo: historyRef.current.future.length > 0 });
});
}, [clearCommitTimer, setActiveChatId, setBackgroundMode, setChatSessions, setConnections, setContextMenu, setNodes, setSelectedConnectionId, setSelectedNodeIds, setShowImageInfo]);
}, [applyCanvasAppearance, clearCommitTimer, setActiveChatId, setBackgroundMode, setChatSessions, setConnections, setContextMenu, setNodes, setSelectedConnectionId, setSelectedNodeIds, setShowImageInfo]);
const undoCanvas = useCallback(() => {
const patch = historyRef.current.past.pop();
@@ -176,6 +183,7 @@ function snapshotsShareReferences(before: CanvasHistorySnapshot, after: CanvasHi
&& before.connections === after.connections
&& before.chatSessions === after.chatSessions
&& before.activeChatId === after.activeChatId
&& before.canvasAppearance === after.canvasAppearance
&& before.backgroundMode === after.backgroundMode
&& before.showImageInfo === after.showImageInfo;
}
@@ -186,6 +194,7 @@ function createCanvasHistoryPatch(before: CanvasHistorySnapshot, after: CanvasHi
patch.connections = createEntityPatch(before.connections, after.connections);
patch.chatSessions = createEntityPatch(before.chatSessions, after.chatSessions);
if (before.activeChatId !== after.activeChatId) patch.activeChatId = { before: before.activeChatId, after: after.activeChatId };
if (before.canvasAppearance !== after.canvasAppearance) patch.canvasAppearance = { before: before.canvasAppearance, after: after.canvasAppearance };
if (before.backgroundMode !== after.backgroundMode) patch.backgroundMode = { before: before.backgroundMode, after: after.backgroundMode };
if (before.showImageInfo !== after.showImageInfo) patch.showImageInfo = { before: before.showImageInfo, after: after.showImageInfo };
return Object.values(patch).some(Boolean) ? patch : null;
@@ -219,6 +228,7 @@ function applyCanvasHistoryPatch(snapshot: CanvasHistorySnapshot, patch: CanvasH
connections: patch.connections ? applyEntityPatch(snapshot.connections, patch.connections, side) : snapshot.connections,
chatSessions: patch.chatSessions ? applyEntityPatch(snapshot.chatSessions, patch.chatSessions, side) : snapshot.chatSessions,
activeChatId: patch.activeChatId ? patch.activeChatId[side] : snapshot.activeChatId,
canvasAppearance: patch.canvasAppearance ? patch.canvasAppearance[side] : snapshot.canvasAppearance,
backgroundMode: patch.backgroundMode ? patch.backgroundMode[side] : snapshot.backgroundMode,
showImageInfo: patch.showImageInfo ? patch.showImageInfo[side] : snapshot.showImageInfo,
};

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRe
import { App } from "antd";
import { useNavigate } from "react-router";
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, normalizeCanvasAppearance, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
@@ -9,6 +10,7 @@ import { hydrateAssistantImages, hydrateCanvasImages, resetInterruptedGeneration
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, CanvasNodeMetadata, ViewportTransform } from "@/types/canvas";
import type { CanvasHistorySnapshot } from "./use-canvas-history";
@@ -19,6 +21,7 @@ type UseCanvasProjectLifecycleOptions = {
connections: CanvasConnection[];
chatSessions: CanvasAssistantSession[];
activeChatId: string | null;
canvasAppearance: CanvasAppearance;
backgroundMode: CanvasBackgroundMode;
showImageInfo: boolean;
viewport: ViewportTransform;
@@ -30,6 +33,7 @@ type UseCanvasProjectLifecycleOptions = {
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setChatSessions: Dispatch<SetStateAction<CanvasAssistantSession[]>>;
setActiveChatId: Dispatch<SetStateAction<string | null>>;
setCanvasAppearance: Dispatch<SetStateAction<CanvasAppearance>>;
setBackgroundMode: Dispatch<SetStateAction<CanvasBackgroundMode>>;
setShowImageInfo: Dispatch<SetStateAction<boolean>>;
setViewport: Dispatch<SetStateAction<ViewportTransform>>;
@@ -46,6 +50,7 @@ export function useCanvasProjectLifecycle({
connections,
chatSessions,
activeChatId,
canvasAppearance,
backgroundMode,
showImageInfo,
viewport,
@@ -57,6 +62,7 @@ export function useCanvasProjectLifecycle({
setConnections,
setChatSessions,
setActiveChatId,
setCanvasAppearance,
setBackgroundMode,
setShowImageInfo,
setViewport,
@@ -87,11 +93,16 @@ export function useCanvasProjectLifecycle({
const applyRestoredProject = (restoredNodes: CanvasNodeData[], restoredSessions: CanvasAssistantSession[]) => {
if (cancelled) return;
const fallbackTheme = useThemeStore.getState().theme;
const restoredAppearance = project.appearance
? normalizeCanvasAppearance(project.appearance, fallbackTheme)
: canvasAppearanceForTheme(fallbackTheme);
const snapshot: CanvasHistorySnapshot = {
nodes: restoredNodes,
connections: project.connections,
chatSessions: restoredSessions,
activeChatId: project.activeChatId || null,
canvasAppearance: restoredAppearance,
backgroundMode: project.backgroundMode || "dots",
showImageInfo: project.showImageInfo || false,
};
@@ -102,6 +113,8 @@ export function useCanvasProjectLifecycle({
setConnections(snapshot.connections);
setChatSessions(snapshot.chatSessions);
setActiveChatId(snapshot.activeChatId);
setCanvasAppearance(snapshot.canvasAppearance);
useThemeStore.getState().setTheme(canvasAppearanceBaseTheme(snapshot.canvasAppearance, fallbackTheme));
setBackgroundMode(snapshot.backgroundMode);
setShowImageInfo(snapshot.showImageInfo);
setViewport(project.viewport);
@@ -128,7 +141,7 @@ export function useCanvasProjectLifecycle({
return () => {
cancelled = true;
};
}, [hydrated, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]);
}, [hydrated, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setCanvasAppearance, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]);
useEffect(() => {
if (!projectLoaded) return;
@@ -147,8 +160,8 @@ export function useCanvasProjectLifecycle({
useEffect(() => {
if (!projectLoaded || historyPausedRef.current) return;
updateProject(projectId, { nodes, connections, chatSessions, activeChatId, backgroundMode, showImageInfo });
}, [activeChatId, backgroundMode, chatSessions, connections, historyPausedRef, nodes, projectId, projectLoaded, showImageInfo, updateProject]);
updateProject(projectId, { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo });
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connections, historyPausedRef, nodes, projectId, projectLoaded, showImageInfo, updateProject]);
useEffect(() => {
if (!projectLoaded) return;
@@ -202,6 +215,7 @@ export function useCanvasProjectLifecycle({
connections: connectionsRef.current,
chatSessions,
activeChatId,
appearance: canvasAppearance,
backgroundMode,
showImageInfo,
viewport: viewportRef.current,
@@ -220,7 +234,7 @@ export function useCanvasProjectLifecycle({
message.warning(`本地画布布局已保存,云端同步失败:${detail}`);
}
return true;
}, [activeChatId, backgroundMode, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
const clearCanvasFiles = useCallback(() => {
cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] });

View File

@@ -2,10 +2,12 @@ import { create } from "zustand";
import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
import { nanoid } from "nanoid";
import { normalizeCanvasAppearance, readCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import { parseCanvasStorageDocument, rebaseCanvasProjects, serializeCanvasStorageDocument, type CanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision";
import { localForageStorageForScope } from "@/lib/localforage-storage";
import { getActiveUserScope } from "@/lib/user-scope";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import type { CanvasStarterMode } from "@/lib/canvas/canvas-starter";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
import type { DirectorScene } from "@/types/director";
import type { TimelineProject } from "@/types/timeline";
@@ -20,6 +22,8 @@ export type CanvasProject = {
connections: CanvasConnection[];
chatSessions: CanvasAssistantSession[];
activeChatId: string | null;
starterMode?: CanvasStarterMode;
appearance?: CanvasAppearance;
backgroundMode: CanvasBackgroundMode;
showImageInfo: boolean;
viewport: ViewportTransform;
@@ -36,7 +40,7 @@ type CanvasStore = {
renameProject: (id: string, title: string) => void;
deleteProjects: (ids: string[]) => void;
replaceProjects: (projects: CanvasProject[]) => void;
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "projectId" | "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "showImageInfo" | "viewport" | "directorScenes" | "timeline">>) => void;
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "projectId" | "nodes" | "connections" | "chatSessions" | "activeChatId" | "starterMode" | "appearance" | "backgroundMode" | "showImageInfo" | "viewport" | "directorScenes" | "timeline">>) => void;
};
const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 };
@@ -418,6 +422,7 @@ export const useCanvasStore = create<CanvasStore>()(
createProject: (title = "未命名画布", projectId) => {
const now = new Date().toISOString();
const id = nanoid();
const appearanceDefault = readCanvasAppearanceDefault();
const project: CanvasProject = {
id,
projectId,
@@ -428,7 +433,8 @@ export const useCanvasStore = create<CanvasStore>()(
connections: [],
chatSessions: [],
activeChatId: null,
backgroundMode: "lines",
appearance: appearanceDefault?.appearance,
backgroundMode: appearanceDefault?.backgroundMode || "lines",
showImageInfo: false,
viewport: initialViewport,
directorScenes: [],
@@ -448,6 +454,8 @@ export const useCanvasStore = create<CanvasStore>()(
connections: source.connections || [],
chatSessions: source.chatSessions || [],
activeChatId: source.activeChatId || null,
starterMode: source.starterMode,
appearance: source.appearance ? normalizeCanvasAppearance(source.appearance, "dark") : undefined,
backgroundMode: source.backgroundMode || "lines",
showImageInfo: source.showImageInfo || false,
viewport: source.viewport || initialViewport,