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();
|
||||
},
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user