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

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

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

View File

@@ -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)),

View File

@@ -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")} />;
}