feat(canvas): 画布工具 - 抓手框选改开关并精简多选整理
将底部抓手与区域选择收成胶囊开关,精简框选悬浮栏,并在右键提供按相对布局加大边距的自适应整理。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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")} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user