feat(canvas): 画布工具 - 抓手框选改开关并精简多选整理

将底部抓手与区域选择收成胶囊开关,精简框选悬浮栏,并在右键提供按相对布局加大边距的自适应整理。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCatandCursor committed 2026-09-08 23:53:22 +08:00
1 parent f176043ac4
commit 94dba61a6c
19 files changed
+533 -39

No files matched your search

@@ -1,6 +1,6 @@
import { AnimatePresence } from "motion/react";
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { ArrowLeft, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react";
import { ArrowLeft, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, LayoutTemplate, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react";
import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu";
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
@@ -26,6 +26,7 @@ type CanvasNodeContextMenuProps = {
canUndo: boolean;
canRedo: boolean;
canPaste: boolean;
selectedCount?: number;
onClose: () => void;
onAddNode: (type: CanvasNodeTypeId) => void;
onAddFolder: () => void;
@@ -51,6 +52,9 @@ type CanvasNodeContextMenuProps = {
onUploadToArkPrivateAsset: () => void;
onSetAssetCategory: (category: CanvasAssetCategory) => void;
onToggleFrame: () => void;
onSpreadSelection?: () => void;
onCopySelection?: () => void;
onDeleteSelection?: () => void;
};
export function CanvasNodeContextMenu({
@@ -61,6 +65,7 @@ export function CanvasNodeContextMenu({
canUndo,
canRedo,
canPaste,
selectedCount = 0,
onClose,
onAddNode,
onAddFolder,
@@ -86,6 +91,9 @@ export function CanvasNodeContextMenu({
onUploadToArkPrivateAsset,
onSetAssetCategory,
onToggleFrame,
onSpreadSelection,
onCopySelection,
onDeleteSelection,
}: CanvasNodeContextMenuProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [addOpen, setAddOpen] = useState(false);
@@ -135,7 +143,8 @@ export function CanvasNodeContextMenu({
const canGenerateFromText = Boolean(isText && !isCharacterReference && hasNodeContent);
const canCopyMediaUrl = Boolean(isMedia && hasNodeContent);
const assetCategory = node ? canvasNodeAssetCategory(node) : "other";
const position = getContextMenuPosition(menu);
const hasMultiSelection = selectedCount >= 2;
const position = getContextMenuPosition(menu, hasMultiSelection);
return (
<>
@@ -169,6 +178,12 @@ export function CanvasNodeContextMenu({
) : menu.type === "canvas" ? (
<>
<MenuHeader title="画布命令" />
{hasMultiSelection ? (
<>
<MenuButton icon={<LayoutTemplate className="size-4" />} label="自适应整理画布" detail="保持相对布局并加大边距" disabled={!onSpreadSelection} onClick={() => onSpreadSelection && runAction(onSpreadSelection)} />
<MenuDivider />
</>
) : null}
<MenuButton icon={<Plus className="size-4" />} label="添加节点" chevron active={addOpen} onClick={() => setAddOpen((value) => !value)} />
<MenuButton icon={<Upload className="size-4" />} label="上传到这里" onClick={() => runAction(onUpload)} />
{!isProjectLinked ? <MenuButton icon={<FolderOpen className="size-4" />} label="从素材库插入" onClick={() => runAction(onOpenAssets)} /> : null}
@@ -178,6 +193,15 @@ export function CanvasNodeContextMenu({
<MenuButton icon={<Redo2 className="size-4" />} label="重做" shortcut="⇧⌘Z" disabled={!canRedo} onClick={() => runAction(onRedo)} />
<MenuButton icon={<Clipboard className="size-4" />} label="粘贴" shortcut="⌘V" disabled={!canPaste} onClick={() => runAction(onPaste)} />
</>
) : menu.type === "node" && hasMultiSelection ? (
<>
<MenuHeader title={`已选 ${selectedCount} 个节点`} />
<MenuSection label="选区" />
<MenuButton icon={<LayoutTemplate />} label="自适应整理画布" detail="保持相对布局并加大边距" disabled={!onSpreadSelection} onClick={() => onSpreadSelection && runAction(onSpreadSelection)} />
<MenuDivider />
<MenuButton icon={<Copy />} label={`复制 ${selectedCount} 个节点`} shortcut="⌘C" disabled={!onCopySelection} onClick={() => onCopySelection && runAction(onCopySelection)} />
<MenuButton icon={<Trash2 />} label={`删除 ${selectedCount} 个节点`} danger disabled={!onDeleteSelection} onClick={() => onDeleteSelection && runAction(onDeleteSelection)} />
</>
) : menu.type === "node" ? (
<>
{isCharacterReference ? (
@@ -345,10 +369,10 @@ function MenuDivider() {
return <div className="mx-1.5 my-1 h-px" style={{ background: `linear-gradient(90deg, transparent, ${theme.toolbar.border}, transparent)` }} />;
}
function getContextMenuPosition(menu: ContextMenuState) {
function getContextMenuPosition(menu: ContextMenuState, hasMultiSelection = false) {
if (typeof window === "undefined") return { left: menu.x, top: menu.y };
const width = 224;
const estimatedHeight = menu.type === "node" ? Math.min(360, window.innerHeight - 72) : menu.type === "canvas" ? 250 : 84;
const estimatedHeight = menu.type === "node" ? Math.min(hasMultiSelection ? 240 : 360, window.innerHeight - 72) : menu.type === "canvas" ? (hasMultiSelection ? 300 : 250) : 84;
return {
left: clamp(menu.x, 12, Math.max(12, window.innerWidth - width - 12)),
top: clamp(menu.y, 68, Math.max(68, window.innerHeight - estimatedHeight - 12)),
@@ -20,7 +20,23 @@ export type FloatingDockCommand = {
expands?: boolean;
};
export type FloatingDockEntry = FloatingDockCommand | { kind: "separator"; id: string };
export type FloatingDockSwitchOption = {
id: string;
label: string;
icon: ReactNode;
value: string;
};
export type FloatingDockSwitch = {
kind: "switch";
id: string;
label: string;
value: string;
options: FloatingDockSwitchOption[];
onChange: (value: string) => void;
};
export type FloatingDockEntry = FloatingDockCommand | FloatingDockSwitch | { kind: "separator"; id: string };
type FloatingDockProps = {
items: FloatingDockEntry[];
@@ -160,6 +176,12 @@ function renderDockItems(items: FloatingDockEntry[], props: DockItemRenderProps)
result.push(<DockSeparator key={item.id} compact={props.compact} labeled={props.showLabel} />);
continue;
}
if (item.kind === "switch") {
flushDangerGroup();
result.push(<DockSwitch key={item.id} entry={item} compact={props.compact} showLabel={props.showLabel} motionEnabled={props.motionEnabled} metrics={props.metrics} />);
index += 1;
continue;
}
if (item.danger) {
dangerGroup.push(item);
index += 1;
@@ -264,6 +286,91 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s
);
}
function DockSwitch({ entry, compact, showLabel, motionEnabled, metrics }: { entry: FloatingDockSwitch; compact: boolean; showLabel: boolean; motionEnabled: boolean; metrics: DockMetrics }) {
const reducedMotion = useReducedMotion();
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [focusedId, setFocusedId] = useState<string | null>(null);
const selectedIndex = Math.max(0, entry.options.findIndex((option) => option.value === entry.value));
const touch = metrics.base >= 40;
const slot = touch ? 32 : compact ? 24 : 26;
const gap = touch ? 10 : compact ? 8 : 10;
const padX = touch ? 7 : compact ? 6 : 7;
return (
<span
role="radiogroup"
aria-label={entry.label}
className="aceternity-dock-switch relative flex shrink-0 self-end items-center"
onKeyDown={(event) => {
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
event.preventDefault();
const direction = event.key === "ArrowRight" ? 1 : -1;
const next = entry.options[(selectedIndex + direction + entry.options.length) % entry.options.length];
if (next) entry.onChange(next.value);
}}
>
<span
className="aceternity-dock-switch-track relative inline-flex items-center"
style={{ gap, padding: `${touch ? 4 : 3}px ${padX}px` }}
>
<motion.span
aria-hidden
className="aceternity-dock-switch-thumb pointer-events-none absolute top-1/2 rounded-full"
initial={false}
animate={{ x: selectedIndex * (slot + gap), y: "-50%" }}
transition={reducedMotion || !motionEnabled ? { duration: 0 } : aceternityMotion.spring.dock}
style={{ width: slot, height: slot, left: padX }}
/>
{entry.options.map((option) => {
const checked = option.value === entry.value;
const showTooltip = !showLabel && (hoveredId === option.id || focusedId === option.id);
return (
<span key={option.id} className="relative">
<button
type="button"
role="radio"
aria-checked={checked}
aria-label={option.label}
title={!motionEnabled ? option.label : undefined}
className="aceternity-dock-switch-option relative z-[1] grid place-items-center rounded-full border-0 outline-none"
style={{ width: slot, height: slot }}
onMouseEnter={() => setHoveredId(option.id)}
onMouseLeave={() => setHoveredId((current) => current === option.id ? null : current)}
onFocus={(event) => {
if (event.currentTarget.matches(":focus-visible")) setFocusedId(option.id);
}}
onBlur={() => setFocusedId((current) => current === option.id ? null : current)}
onMouseDown={() => setFocusedId(null)}
onClick={() => {
if (!checked) entry.onChange(option.value);
}}
>
<span className={cn("grid place-items-center", touch ? "[&_svg]:size-4" : "[&_svg]:size-[15px]")}>{option.icon}</span>
</button>
<AnimatePresence>
{showTooltip ? (
<motion.span
initial={{ opacity: 0, y: 7, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 4, scale: 0.96, transition: { duration: 0 } }}
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
className={cn(
"aceternity-dock-tooltip pointer-events-none absolute left-1/2 z-[var(--dock-tooltip-z)] -translate-x-1/2 whitespace-nowrap border font-medium shadow-xl backdrop-blur-xl",
compact ? "-top-7 rounded-md px-1.5 py-0.5 text-[var(--fs-micro)]" : "-top-8 rounded-md px-2 py-1 text-[var(--fs-tiny)]",
)}
>
{option.label}
</motion.span>
) : null}
</AnimatePresence>
</span>
);
})}
</span>
</span>
);
}
function DockSeparator({ compact, labeled }: { compact: boolean; labeled: boolean }) {
return <span aria-hidden className={cn("aceternity-dock-separator shrink-0 self-center", labeled ? "mx-1.5 h-6 w-px" : compact ? "mx-0.5 mb-0.5 h-3.5 w-px" : "mx-0.5 mb-0.5 h-4 w-px")} />;
}
@@ -15,5 +15,8 @@ export function canvasDockStyle(theme: CanvasTheme, color: string = theme.toolba
"--dock-command-danger": theme.accent.danger,
"--dock-tooltip-bg": theme.spatial.elevated,
"--dock-tooltip-border": theme.toolbar.border,
"--dock-switch-track": "#000000",
"--dock-switch-thumb": "#ffffff",
"--dock-switch-thumb-text": "#131313",
} as CSSProperties;
}
+60
View File
@@ -141,6 +141,66 @@ export function layoutCanvasAuto(nodes: CanvasNodeData[], connections: CanvasCon
return hasConnections ? layoutCanvasFlow(candidates, connections) : layoutCanvasNodesByMediaType(candidates);
}
export const CANVAS_SPREAD_SCALE = 1.4;
export const CANVAS_SPREAD_MIN_GAP = 56;
/**
* 保持选区里节点的相对占位,只把彼此间距拉开。
* 先按选区左上角等比展开,再把仍然过近的盒子沿原有主方向推开,最后把整组钉回原来的左上角。
*/
export function spreadCanvasNodes(nodes: CanvasNodeData[], options?: { scale?: number; minGap?: number }) {
const result = new Map<string, Position>();
if (nodes.length < 2) return result;
const scale = options?.scale ?? CANVAS_SPREAD_SCALE;
const minGap = options?.minGap ?? CANVAS_SPREAD_MIN_GAP;
const originX = Math.min(...nodes.map((node) => node.position.x));
const originY = Math.min(...nodes.map((node) => node.position.y));
const items = nodes.map((node) => ({
id: node.id,
width: node.width,
height: node.height,
x: originX + (node.position.x - originX) * scale,
y: originY + (node.position.y - originY) * scale,
}));
for (let pass = 0; pass < 12; pass++) {
let moved = false;
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
const first = items[i]!;
const second = items[j]!;
const overlapX = Math.min(first.x + first.width + minGap, second.x + second.width + minGap) - Math.max(first.x, second.x);
const overlapY = Math.min(first.y + first.height + minGap, second.y + second.height + minGap) - Math.max(first.y, second.y);
if (overlapX <= 0 || overlapY <= 0) continue;
const centerDx = first.x + first.width / 2 - (second.x + second.width / 2);
const centerDy = first.y + first.height / 2 - (second.y + second.height / 2);
if (Math.abs(centerDx) >= Math.abs(centerDy)) {
const direction = centerDx === 0 ? (first.id < second.id ? 1 : -1) : centerDx > 0 ? 1 : -1;
const push = overlapX / 2;
first.x += direction * push;
second.x -= direction * push;
} else {
const direction = centerDy === 0 ? (first.id < second.id ? 1 : -1) : centerDy > 0 ? 1 : -1;
const push = overlapY / 2;
first.y += direction * push;
second.y -= direction * push;
}
moved = true;
}
}
if (!moved) break;
}
const nextOriginX = Math.min(...items.map((item) => item.x));
const nextOriginY = Math.min(...items.map((item) => item.y));
const deltaX = originX - nextOriginX;
const deltaY = originY - nextOriginY;
items.forEach((item) => result.set(item.id, { x: item.x + deltaX, y: item.y + deltaY }));
return result;
}
export function canvasLayoutLane(node: CanvasNodeData): CanvasLayoutLane {
if (node.type === CanvasNodeType.Video) return "video";
if (node.type === CanvasNodeType.Audio) return "audio";
+2 -2
View File
@@ -61,7 +61,7 @@ export const CANVAS_SHORTCUTS: CanvasShortcutItem[] = [
id: "pan",
category: "navigation",
title: "平移视图",
description: "触控板双指滑动,或按住空格键 / 中键拖动画布",
description: "触控板双指滑动,按住空格键 / 中键拖动,或切到抓手工具后拖动画布",
keys: [["触控板双指"], ["Space", "左键拖动"], ["中键拖动"]],
keywords: ["移动", "画布", "触控板", "pan"],
},
@@ -109,7 +109,7 @@ export const CANVAS_SHORTCUTS: CanvasShortcutItem[] = [
id: "box-select-tool",
category: "selection",
title: "使用区域选择工具",
description: "保持区域选择模式,可连续框选多个范围",
description: "底部工具栏的胶囊开关切到区域选择后,空白处拖动可连续框选",
keys: [["区域选择", "拖动"]],
keywords: ["工具栏", "多选", "selection"],
},
@@ -1,29 +1,28 @@
import { Eraser, FolderOpen, Hand, Palette, Plus, Redo2, Settings2, SquareDashedMousePointer, Trash2, Undo2 } from "lucide-react";
import { Eraser, FolderOpen, Hand, MousePointer2, Palette, Plus, Redo2, Settings2, Trash2, Undo2 } from "lucide-react";
import { registerToolbarTools, type ToolDefinition } from "@/lib/canvas/tool-registry";
import type { CanvasToolMode } from "@/types/canvas";
const canvasModeOptions = [
{ id: "box-select", label: "区域选择", icon: <MousePointer2 />, value: "box-select" },
{ id: "move", label: "抓手工具", icon: <Hand />, value: "move" },
];
export const mainToolbarTools: ToolDefinition[] = [
{
id: "tool-move",
id: "tool-canvas-mode",
toolbar: "main",
category: "navigation",
label: "抓手工具",
icon: <Hand />,
label: "抓手 / 框选",
icon: <MousePointer2 />,
defaultVisible: true,
defaultOrder: 10,
active: (ctx) => ctx.canvasTool === "move",
run: (ctx) => ctx.handlers.onToolChange("move"),
},
{
id: "tool-box-select",
toolbar: "main",
category: "navigation",
label: "区域选择",
icon: <SquareDashedMousePointer />,
defaultVisible: true,
defaultOrder: 20,
active: (ctx) => ctx.canvasTool === "box-select",
run: (ctx) => ctx.handlers.onToolChange("box-select"),
switchGroup: {
value: (ctx) => ctx.canvasTool,
options: canvasModeOptions,
onChange: (ctx, value) => ctx.handlers.onToolChange(value as CanvasToolMode),
},
run: (ctx) => ctx.handlers.onToolChange(ctx.canvasTool === "move" ? "box-select" : "move"),
},
{
id: "tool-undo",
@@ -3,7 +3,6 @@ import { AlignHorizontalJustifyCenter, AlignHorizontalJustifyEnd, AlignHorizonta
import { registerToolbarTools, type ToolDefinition } from "@/lib/canvas/tool-registry";
export const selectionToolbarTools: ToolDefinition[] = [
// 对齐组
{ id: "selection-align-left", toolbar: "selection", category: "layout", label: "左对齐", icon: <AlignHorizontalJustifyStart />, defaultVisible: true, defaultOrder: 10, run: (ctx) => ctx.handlers.onAlign("left") },
{ id: "selection-align-center-x", toolbar: "selection", category: "layout", label: "水平居中", icon: <AlignHorizontalJustifyCenter />, defaultVisible: true, defaultOrder: 20, run: (ctx) => ctx.handlers.onAlign("centerX") },
{ id: "selection-align-right", toolbar: "selection", category: "layout", label: "右对齐", icon: <AlignHorizontalJustifyEnd />, defaultVisible: true, defaultOrder: 30, run: (ctx) => ctx.handlers.onAlign("right") },
@@ -12,14 +11,12 @@ export const selectionToolbarTools: ToolDefinition[] = [
{ id: "selection-align-bottom", toolbar: "selection", category: "layout", label: "底对齐", icon: <AlignVerticalJustifyEnd />, defaultVisible: true, defaultOrder: 60, run: (ctx) => ctx.handlers.onAlign("bottom") },
{ id: "selection-distribute-x", toolbar: "selection", category: "layout", label: "水平等距", icon: <AlignHorizontalSpaceBetween />, defaultVisible: true, defaultOrder: 70, disabled: (ctx) => ctx.selectedCount < 3, run: (ctx) => ctx.handlers.onAlign("distributeX") },
{ id: "selection-distribute-y", toolbar: "selection", category: "layout", label: "垂直等距", icon: <AlignVerticalSpaceBetween />, defaultVisible: true, defaultOrder: 80, disabled: (ctx) => ctx.selectedCount < 3, run: (ctx) => ctx.handlers.onAlign("distributeY") },
// 排列组
{ id: "selection-arrange-row", toolbar: "selection", category: "arrange", label: "横向排列", icon: <AlignHorizontalSpaceAround />, defaultVisible: true, defaultOrder: 90, run: (ctx) => ctx.handlers.onArrange("row") },
{ id: "selection-arrange-column", toolbar: "selection", category: "arrange", label: "纵向排列", icon: <AlignVerticalSpaceAround />, defaultVisible: true, defaultOrder: 100, run: (ctx) => ctx.handlers.onArrange("column") },
{ id: "selection-arrange-grid", toolbar: "selection", category: "arrange", label: "宫格排列", icon: <Grid3X3 />, defaultVisible: true, defaultOrder: 110, run: (ctx) => ctx.handlers.onArrange("grid") },
{ id: "selection-arrange-flow", toolbar: "selection", category: "arrange", label: "按连线整理", icon: <Workflow />, defaultVisible: true, defaultOrder: 120, run: (ctx) => ctx.handlers.onArrange("flow") },
// 分组组
{ id: "selection-arrange-row", toolbar: "selection", category: "arrange", label: "横向排列", icon: <AlignHorizontalSpaceAround />, defaultVisible: false, defaultOrder: 90, run: (ctx) => ctx.handlers.onArrange("row") },
{ id: "selection-arrange-column", toolbar: "selection", category: "arrange", label: "纵向排列", icon: <AlignVerticalSpaceAround />, defaultVisible: false, defaultOrder: 100, run: (ctx) => ctx.handlers.onArrange("column") },
{ id: "selection-arrange-grid", toolbar: "selection", category: "arrange", label: "宫格排列", icon: <Grid3X3 />, defaultVisible: false, defaultOrder: 110, run: (ctx) => ctx.handlers.onArrange("grid") },
{ id: "selection-arrange-flow", toolbar: "selection", category: "arrange", label: "按连线整理", icon: <Workflow />, defaultVisible: false, defaultOrder: 120, run: (ctx) => ctx.handlers.onArrange("flow") },
{ id: "selection-create-storyboard", toolbar: "selection", category: "selection", label: "创建分镜组", icon: <LayoutTemplate />, defaultVisible: true, defaultOrder: 130, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateStoryboard() },
{ id: "selection-create-reference-group", toolbar: "selection", category: "selection", label: "创建引用组", icon: <FolderTree />, defaultVisible: true, defaultOrder: 140, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateReferenceGroup() },
{ id: "selection-create-reference-group", toolbar: "selection", category: "selection", label: "创建引用组", icon: <FolderTree />, defaultVisible: false, defaultOrder: 140, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onCreateReferenceGroup() },
{ id: "selection-batch-connect", toolbar: "selection", category: "selection", label: "批量连接", icon: <Link2 />, defaultVisible: true, defaultOrder: 145, disabled: (ctx) => ctx.selectedCount < 2, run: (ctx) => ctx.handlers.onBatchConnect() },
{
id: "selection-merge-videos",
+1 -1
View File
@@ -1,4 +1,4 @@
export type { AddNodeMenuCommand, AddNodeMenuContext, NodeToolbarGroup, ToolbarHandlers, ToolbarId, ToolbarPrefs, ToolCategory, ToolContext, ToolDefinition } from "./tool-definition";
export { clearToolbarPrefs, persistToolbarPrefs, readToolbarPrefs } from "./tool-persistence";
export { CANVAS_MODE_TOOL_ID, LEGACY_CANVAS_MODE_TOOL_IDS, clearToolbarPrefs, migrateToolbarPrefs, persistToolbarPrefs, readToolbarPrefs } from "./tool-persistence";
export { defaultToolbarPrefs, getAddNodeMenuCommands, getToolbarTools, registerAddNodeMenuCommands, registerToolbarTools, resolveAddNodeMenuCommands, resolveNodeToolbarPlacement, resolveToolbarEntries, resolveToolbarTools } from "./tool-registry";
import "./definitions";
@@ -182,6 +182,12 @@ export type ToolDefinition = {
danger?: boolean;
/** 面板展开型工具——使用 aria-expanded 而非 aria-pressed */
expands?: boolean;
/** 互斥开关:在 dock 中渲染为分段切换,而不是两个独立按钮 */
switchGroup?: {
value: (ctx: ToolContext) => string;
options: Array<{ id: string; label: string; icon: ReactNode; value: string }>;
onChange: (ctx: ToolContext, value: string) => void;
};
/** 上下文可见性谓词——返回 false 时工具不渲染(不受 prefs 控制) */
applicable?: (ctx: ToolContext) => boolean;
/** 执行动作。event 来自 Dock 按钮点击 */
@@ -5,6 +5,9 @@ import type { ToolbarId, ToolbarPrefs } from "./tool-definition";
const STORAGE_VERSION = "v1";
const storageKey = (toolbar: ToolbarId) => `canvas-toolbar-prefs-${toolbar}-${STORAGE_VERSION}`;
export const CANVAS_MODE_TOOL_ID = "tool-canvas-mode";
export const LEGACY_CANVAS_MODE_TOOL_IDS = ["tool-move", "tool-box-select"] as const;
/**
* 读取工具栏偏好。缺失或解析失败时返回 null(由调用方决定是否用默认值)
*/
@@ -14,7 +17,8 @@ export function readToolbarPrefs(toolbar: ToolbarId): ToolbarPrefs | null {
if (!stored) return null;
const parsed = JSON.parse(stored) as unknown;
if (!isPrefsShape(parsed)) return null;
return { order: Array.isArray(parsed.order) ? parsed.order.filter((id) => typeof id === "string") : [], hidden: Array.isArray(parsed.hidden) ? parsed.hidden.filter((id) => typeof id === "string") : [] };
const prefs: ToolbarPrefs = { order: Array.isArray(parsed.order) ? parsed.order.filter((id) => typeof id === "string") : [], hidden: Array.isArray(parsed.hidden) ? parsed.hidden.filter((id) => typeof id === "string") : [] };
return migrateToolbarPrefs(toolbar, prefs);
} catch {
return null;
}
@@ -39,3 +43,22 @@ export function clearToolbarPrefs(toolbar: ToolbarId) {
function isPrefsShape(value: unknown): value is Partial<ToolbarPrefs> {
return typeof value === "object" && value !== null;
}
/** 把旧的抓手/框选两个独立按钮偏好合并成一个开关项 */
export function migrateToolbarPrefs(toolbar: ToolbarId, prefs: ToolbarPrefs): ToolbarPrefs {
if (toolbar !== "main") return prefs;
const legacyIds = new Set<string>(LEGACY_CANVAS_MODE_TOOL_IDS);
const hasLegacy = prefs.order.some((id) => legacyIds.has(id)) || prefs.hidden.some((id) => legacyIds.has(id));
if (!hasLegacy) return prefs;
const order = prefs.order.filter((id) => id !== CANVAS_MODE_TOOL_ID && !legacyIds.has(id));
const firstLegacyIndex = prefs.order.findIndex((id) => legacyIds.has(id));
const insertAt = firstLegacyIndex >= 0 ? Math.min(firstLegacyIndex, order.length) : 0;
order.splice(insertAt, 0, CANVAS_MODE_TOOL_ID);
const hiddenLegacy = LEGACY_CANVAS_MODE_TOOL_IDS.filter((id) => prefs.hidden.includes(id));
const hidden = prefs.hidden.filter((id) => id !== CANVAS_MODE_TOOL_ID && !legacyIds.has(id));
if (hiddenLegacy.length === LEGACY_CANVAS_MODE_TOOL_IDS.length) hidden.push(CANVAS_MODE_TOOL_ID);
return { order, hidden };
}
@@ -58,10 +58,13 @@ function getPluginNodeMenuCommands(): AddNodeMenuCommand[] {
});
}
/** 默认偏好:全部工具按 defaultOrder 排列,全部可见 */
/** 默认偏好:全部工具按 defaultOrder 排列;defaultVisible 为 false 的进入 hidden */
export function defaultToolbarPrefs(toolbar: ToolbarId): ToolbarPrefs {
const tools = getToolbarTools(toolbar);
return { order: tools.map((tool) => tool.id), hidden: [] };
return {
order: tools.map((tool) => tool.id),
hidden: tools.filter((tool) => !tool.defaultVisible).map((tool) => tool.id),
};
}
/**
@@ -127,6 +130,16 @@ function buildEntriesWithSeparators(tools: ToolDefinition[], ctx: ToolContext):
}
function toolToEntry(tool: ToolDefinition, ctx: ToolContext): FloatingDockEntry {
if (tool.switchGroup) {
return {
kind: "switch",
id: tool.id,
label: resolveText(tool.label, ctx),
value: tool.switchGroup.value(ctx),
options: tool.switchGroup.options,
onChange: (value) => tool.switchGroup?.onChange(ctx, value),
};
}
return {
kind: "command",
id: tool.id,
@@ -11,6 +11,7 @@ type CanvasProjectContextMenuProps = {
canUndo: boolean;
canRedo: boolean;
canPaste: boolean;
selectedCount: number;
screenToCanvas: (clientX: number, clientY: number) => Position;
onClose: () => void;
onAddNode: (type: CanvasNodeTypeId, position: Position) => void;
@@ -38,6 +39,9 @@ type CanvasProjectContextMenuProps = {
onUploadToArkPrivateAsset: (node: CanvasNodeData) => void;
onSetAssetCategory: (nodeId: string, category: CanvasAssetCategory) => void;
onToggleFrame: (node: CanvasNodeData) => void;
onSpreadSelection: () => void;
onCopySelection: () => void;
onDeleteSelection: () => void;
};
export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props }: CanvasProjectContextMenuProps) {
@@ -52,6 +56,7 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
canUndo={props.canUndo}
canRedo={props.canRedo}
canPaste={props.canPaste}
selectedCount={props.selectedCount}
onClose={props.onClose}
onAddNode={(type) => {
if (menu.type === "canvas") props.onAddNode(type, menu.position);
@@ -106,6 +111,9 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
onToggleFrame={() => {
if (node?.type === CanvasNodeType.Frame) props.onToggleFrame(node);
}}
onSpreadSelection={props.onSpreadSelection}
onCopySelection={props.onCopySelection}
onDeleteSelection={props.onDeleteSelection}
/>
);
}
+9 -1
View File
@@ -947,6 +947,7 @@ function InfiniteCanvasPage() {
alignSelectedNodes,
autoArrangeCanvasNodes,
arrangeSelectedNodes,
spreadSelectedNodes,
copyNodesToClipboard,
copySelectedNodes,
createFolder,
@@ -1769,7 +1770,10 @@ function InfiniteCanvasPage() {
(event: ReactMouseEvent, id: string) => {
event.preventDefault();
event.stopPropagation();
setSelectedNodeIds(new Set([id]));
setSelectedNodeIds((current) => {
if (current.has(id) && current.size > 1) return current;
return new Set([id]);
});
setSelectedConnectionId(null);
closeConnectionCreateMenu();
setToolbarNodeId(null);
@@ -2660,6 +2664,7 @@ function InfiniteCanvasPage() {
canUndo={historyState.canUndo}
canRedo={historyState.canRedo}
canPaste={hasCopiedNodes || Boolean(navigator.clipboard)}
selectedCount={selectedNodeIds.size}
screenToCanvas={screenToCanvas}
onClose={() => setContextMenu(null)}
onAddNode={(type, position) => createNode(type, position)}
@@ -2693,6 +2698,9 @@ function InfiniteCanvasPage() {
onUploadToArkPrivateAsset={confirmUploadNodeImageToArkPrivateAsset}
onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })}
onToggleFrame={(node) => handleFrameToggle(node.id)}
onSpreadSelection={spreadSelectedNodes}
onCopySelection={copySelectedNodes}
onDeleteSelection={() => deleteNodes(selectedNodeIds)}
/>
<CanvasUploadModal open={uploadModalOpen} onClose={closeUploadModal} onUpload={handleUploadFiles} />
@@ -5,7 +5,7 @@ import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame";
import { alignCanvasNodes, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import { alignCanvasNodes, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, spreadCanvasNodes, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import { applyCanvasConnectionPromptSync } from "@/lib/canvas/canvas-resource-references";
import { createCanvasNode, isHiddenBatchChild, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
import { isolateCopiedNodeMetadata, nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy";
@@ -206,6 +206,19 @@ export function useCanvasNodeOperations({
message.success(hasSelection ? "已按媒体分类整理选中节点" : "已按媒体分类整理画布");
}, [commitNodes, connectionsRef, message, nodesRef, selectedNodeIdsRef]);
const spreadSelectedNodes = useCallback(() => {
const currentNodes = nodesRef.current;
const selected = currentNodes.filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && !isFrameNode(node) && !isHiddenBatchChild(node, currentNodes));
if (selected.length < 2) {
message.info("请至少选择两个可整理节点");
return;
}
const positions = spreadCanvasNodes(selected);
if (!positions.size) return;
commitNodes(currentNodes.map((node) => positions.has(node.id) ? { ...node, position: positions.get(node.id)! } : node));
message.success("已按相对布局加大间距");
}, [commitNodes, message, nodesRef, selectedNodeIdsRef]);
const alignSelectedNodes = useCallback((mode: CanvasAlignmentMode) => {
const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && !isFrameNode(node));
if (selected.length < 2 || ((mode === "distributeX" || mode === "distributeY") && selected.length < 3)) return;
@@ -541,6 +554,7 @@ export function useCanvasNodeOperations({
alignSelectedNodes,
autoArrangeCanvasNodes,
arrangeSelectedNodes,
spreadSelectedNodes,
copyNodesToClipboard,
copySelectedNodes,
createFolder,
+38
View File
@@ -951,6 +951,7 @@
--dock-tooltip-z: 140;
--dock-precision-width: var(--space-12);
--dock-z-popover: 30; /* dock 子面板在 toolbar 根 stacking context 内的层级(见 ADR-001)*/
--dock-switch-track: #000000; /* 抓手/框选胶囊轨道:纯黑,和工具栏表面分开 */
/* 页面顶部栏(PageHeader / 工作台头部)*/
--page-topbar-h: 64px;
@@ -6740,6 +6741,43 @@
background: color-mix(in srgb, currentColor 13%, transparent);
}
.aceternity-dock-switch-track {
border-radius: 999px;
background: var(--dock-switch-track, #000000);
}
.aceternity-dock-switch-thumb {
background: var(--dock-switch-thumb, #ffffff);
}
.aceternity-dock-switch-option {
color: #ffffff;
background: transparent;
opacity: 0.62;
transition:
color var(--motion-instant) ease-out,
opacity var(--motion-instant) ease-out;
}
.aceternity-dock-switch-option:hover:not([aria-checked="true"]) {
opacity: 0.88;
}
.aceternity-dock-switch-option[aria-checked="true"] {
color: var(--dock-switch-thumb-text, var(--background));
opacity: 1;
}
.aceternity-dock-switch-option > span svg {
width: 100%;
height: 100%;
stroke-width: 1.9;
}
.aceternity-dock-switch-option:focus-visible {
box-shadow: 0 0 0 2px var(--dock-command-bg), 0 0 0 4px var(--dock-switch-thumb, var(--workspace-accent));
}
.aceternity-dock-tooltip {
border-color: var(--dock-tooltip-border);
background: var(--dock-tooltip-bg);
+27 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { canvasLayoutLane, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodesByMediaType } from "@/lib/canvas/canvas-layout";
import { canvasLayoutLane, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodesByMediaType, spreadCanvasNodes } from "@/lib/canvas/canvas-layout";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
function node(id: string, type: CanvasNodeType, width = 320, height = 180, locked = false): CanvasNodeData {
@@ -63,3 +63,29 @@ describe("canvas media-aware layout", () => {
expect(canvasLayoutLane(node("audio", CanvasNodeType.Audio))).toBe("audio");
});
});
describe("canvas relative-layout spread", () => {
test("keeps relative occupancy and increases the gap between neighbors", () => {
const left: CanvasNodeData = { ...node("left", CanvasNodeType.Image, 100, 80), position: { x: 0, y: 0 } };
const right: CanvasNodeData = { ...node("right", CanvasNodeType.Image, 100, 80), position: { x: 120, y: 10 } };
const positions = spreadCanvasNodes([left, right], { scale: 1.5, minGap: 40 });
expect(positions.get("left")!.x).toBe(0);
expect(positions.get("right")!.x).toBeGreaterThan(120);
expect(positions.get("right")!.x).toBeGreaterThan(positions.get("left")!.x);
expect(positions.get("right")!.x - (positions.get("left")!.x + left.width)).toBeGreaterThanOrEqual(40);
});
test("keeps a left node left of a right node after resolving overlap", () => {
const first: CanvasNodeData = { ...node("first", CanvasNodeType.Image, 120, 90), position: { x: 40, y: 20 } };
const second: CanvasNodeData = { ...node("second", CanvasNodeType.Image, 120, 90), position: { x: 80, y: 30 } };
const positions = spreadCanvasNodes([first, second], { scale: 1, minGap: 48 });
expect(positions.get("first")!.x).toBeLessThan(positions.get("second")!.x);
expect(positions.get("second")!.x - (positions.get("first")!.x + first.width)).toBeGreaterThanOrEqual(48);
});
test("returns no moves for a single node", () => {
expect(spreadCanvasNodes([node("only", CanvasNodeType.Text)]).size).toBe(0);
});
});
@@ -0,0 +1,126 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { Hand, MousePointer2 } from "lucide-react";
import { FloatingDock } from "@/components/ui/aceternity/floating-dock";
import { CANVAS_MODE_TOOL_ID, defaultToolbarPrefs, migrateToolbarPrefs, resolveToolbarEntries, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
function createMainContext(canvasTool: "move" | "box-select" = "box-select"): ToolContext {
return {
selectedCount: 0,
selectedNodeTypes: new Set(),
selectedVideoCount: 0,
canvasTool,
workspaceMode: "professional",
isProjectLinked: false,
canUndo: false,
canRedo: false,
extractingVideoFrames: false,
extractingAudio: false,
trimmingVideo: false,
mergingVideos: false,
addPanelOpen: false,
appearancePanelOpen: false,
settingsPanelOpen: false,
handlers: {} as ToolbarHandlers,
};
}
describe("canvas toolbar mode switch", () => {
test("renders grab and box-select as one dock switch", () => {
const entries = resolveToolbarEntries("main", createMainContext("box-select"), null);
const modeSwitch = entries.find((entry) => entry.kind === "switch" && entry.id === CANVAS_MODE_TOOL_ID);
expect(modeSwitch?.kind).toBe("switch");
if (modeSwitch?.kind !== "switch") return;
expect(modeSwitch.value).toBe("box-select");
expect(modeSwitch.options.map((option) => option.value)).toEqual(["box-select", "move"]);
expect(entries.some((entry) => entry.id === "tool-move" || entry.id === "tool-box-select")).toBe(false);
});
test("reflects the active canvas tool on the switch", () => {
const entries = resolveToolbarEntries("main", createMainContext("move"), null);
const modeSwitch = entries.find((entry) => entry.kind === "switch");
expect(modeSwitch?.kind === "switch" && modeSwitch.value).toBe("move");
});
test("migrates legacy separate tool prefs into the switch item", () => {
const migrated = migrateToolbarPrefs("main", {
order: ["tool-move", "tool-box-select", "tool-undo", "tool-add"],
hidden: [],
});
expect(migrated.order[0]).toBe(CANVAS_MODE_TOOL_ID);
expect(migrated.order).not.toContain("tool-move");
expect(migrated.order).not.toContain("tool-box-select");
expect(migrated.hidden).toEqual([]);
});
test("hides the switch only when both legacy tools were hidden", () => {
const oneHidden = migrateToolbarPrefs("main", {
order: ["tool-move", "tool-box-select", "tool-undo"],
hidden: ["tool-move"],
});
expect(oneHidden.hidden).toEqual([]);
const bothHidden = migrateToolbarPrefs("main", {
order: ["tool-undo", "tool-move", "tool-box-select"],
hidden: ["tool-move", "tool-box-select"],
});
expect(bothHidden.order[1]).toBe(CANVAS_MODE_TOOL_ID);
expect(bothHidden.hidden).toEqual([CANVAS_MODE_TOOL_ID]);
});
test("leaves non-main toolbar prefs unchanged", () => {
const prefs = { order: ["tool-move"], hidden: ["tool-box-select"] };
expect(migrateToolbarPrefs("selection", prefs)).toEqual(prefs);
});
test("keeps the switch visible in default main toolbar prefs", () => {
expect(defaultToolbarPrefs("main").order[0]).toBe(CANVAS_MODE_TOOL_ID);
expect(defaultToolbarPrefs("main").hidden).toEqual([]);
});
test("hides rarely used arrange tools from the selection toolbar by default", () => {
const prefs = defaultToolbarPrefs("selection");
expect(prefs.hidden).toEqual(expect.arrayContaining([
"selection-arrange-row",
"selection-arrange-column",
"selection-arrange-grid",
"selection-arrange-flow",
"selection-create-reference-group",
]));
const entries = resolveToolbarEntries("selection", createMainContext(), null);
expect(entries.some((entry) => entry.id === "selection-align-left")).toBe(true);
expect(entries.some((entry) => entry.id === "selection-distribute-x")).toBe(true);
expect(entries.some((entry) => entry.id === "selection-batch-connect")).toBe(true);
expect(entries.some((entry) => entry.id === "selection-arrange-grid")).toBe(false);
expect(entries.some((entry) => entry.id === "selection-create-reference-group")).toBe(false);
});
test("renders the pill switch with an active circular thumb", () => {
const html = renderToStaticMarkup(
<FloatingDock
items={[{
kind: "switch",
id: CANVAS_MODE_TOOL_ID,
label: "抓手 / 框选",
value: "box-select",
options: [
{ id: "box-select", label: "区域选择", icon: <MousePointer2 />, value: "box-select" },
{ id: "move", label: "抓手工具", icon: <Hand />, value: "move" },
],
onChange: () => {},
}]}
/>,
);
expect(html).toContain('role="radiogroup"');
expect(html).toContain("aceternity-dock-switch-track");
expect(html).toContain("aceternity-dock-switch-thumb");
expect(html).toContain('aria-label="区域选择"');
expect(html).toContain('aria-label="抓手工具"');
expect(html).toContain('aria-checked="true"');
expect(html).toContain('aria-checked="false"');
});
});