feat(canvas): 自由画布 - 增强空白起点、外观设置与节点连接体验 (#356)
This commit is contained in:
170
web/src/components/canvas/canvas-appearance-controls.tsx
Normal file
170
web/src/components/canvas/canvas-appearance-controls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
23
web/src/components/canvas/use-canvas-create-commands.ts
Normal file
23
web/src/components/canvas/use-canvas-create-commands.ts
Normal 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();
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
174
web/src/lib/canvas/canvas-appearance.ts
Normal file
174
web/src/lib/canvas/canvas-appearance.ts
Normal 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)})`;
|
||||
}
|
||||
13
web/src/lib/canvas/canvas-connected-node-placement.ts
Normal file
13
web/src/lib/canvas/canvas-connected-node-placement.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
20
web/src/lib/canvas/canvas-starter.ts
Normal file
20
web/src/lib/canvas/canvas-starter.ts
Normal 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";
|
||||
}
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} · 视频`;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user