perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突

This commit is contained in:
ddCat
2026-09-05 13:24:10 +08:00
parent 165b4443ab
commit 58f01d1eb6
107 changed files with 3025 additions and 1089 deletions

View File

@@ -1,13 +1,13 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { App, Button, Dropdown, Input, Modal, Tag } from "antd";
import { App, Button, Dropdown, Input, Modal, Tag, Tooltip } from "antd";
import type { MenuProps } from "antd";
import { ChevronDown, Ellipsis, Lock, Plus, Unlock } from "lucide-react";
import { Check, ChevronDown, Ellipsis, Images, Plus, SlidersHorizontal, UserRound } from "lucide-react";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
import { canvasThemes } from "@/lib/canvas-theme";
import { resolveToolbarTools, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { resolveNodeToolbarPlacement, resolveToolbarTools, type NodeToolbarGroup, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
import { subscribeCanvasGraphicsViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
import { generationErrorMessage } from "@/lib/generation-error";
@@ -62,10 +62,14 @@ type CanvasAssetCategory = NonNullable<NonNullable<CanvasNodeData["metadata"]>["
const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = ASSET_CATEGORY_OPTIONS;
type ToolbarTool = {
section?: string;
description?: string;
id: string;
label: string;
icon: ReactNode;
onClick: () => void;
group: NodeToolbarGroup;
order: number;
active?: boolean;
danger?: boolean;
disabled?: boolean;
@@ -112,6 +116,7 @@ export function CanvasNodeToolbar({
workspaceMode = "professional",
}: CanvasNodeToolbarProps) {
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [containerWidth, setContainerWidth] = useState(1000);
const [anchor, setAnchor] = useState<{ left: number; top: number } | null>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const { message } = App.useApp();
@@ -135,37 +140,64 @@ export function CanvasNodeToolbar({
setAnchor(null);
return;
}
let disposed = false;
let queued = false;
let containerRect = container.getBoundingClientRect();
let toolbarWidth = toolbarRef.current?.offsetWidth || 0;
let toolbarHeight = toolbarRef.current?.offsetHeight || 44;
const update = () => {
const nodeRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const preferredLeft = nodeRect.left - containerRect.left + nodeRect.width / 2;
const toolbarWidth = toolbarRef.current?.offsetWidth || 0;
const halfToolbar = toolbarWidth / 2;
const canClamp = toolbarWidth > 0 && toolbarWidth <= containerRect.width - 20;
const left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft;
const top = nodeRect.top - containerRect.top - 30;
let left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft;
const above = nodeRect.top - containerRect.top - 30;
let top = Math.max(toolbarHeight + 8, Math.min(above, containerRect.height - 8));
for (const panel of container.querySelectorAll<HTMLElement>("[data-canvas-node-panel]")) {
const panelRect = panel.getBoundingClientRect();
const panelLeft = panelRect.left - containerRect.left;
const panelRight = panelRect.right - containerRect.left;
const panelTop = panelRect.top - containerRect.top;
const panelBottom = panelRect.bottom - containerRect.top;
if (left + halfToolbar <= panelLeft || left - halfToolbar >= panelRight || top <= panelTop || top - toolbarHeight >= panelBottom) continue;
if (panelLeft >= toolbarWidth + 18) left = panelLeft - halfToolbar - 8;
else if (containerRect.width - panelRight >= toolbarWidth + 18) left = panelRight + halfToolbar + 8;
else if (panelTop >= toolbarHeight + 16) top = panelTop - 8;
else if (containerRect.height - panelBottom >= toolbarHeight + 16) top = panelBottom + toolbarHeight + 8;
}
if (toolbarRef.current) {
toolbarRef.current.style.left = `${left}px`;
toolbarRef.current.style.top = `${top}px`;
toolbarRef.current.style.transform = `translate3d(${left}px, ${top}px, 0)`;
return;
}
setAnchor((current) => current?.left === left && current.top === top ? current : { left, top });
};
update();
const resizeObserver = new ResizeObserver(update);
const scheduleUpdate = () => {
if (queued || disposed) return;
queued = true;
queueMicrotask(() => {
queued = false;
if (!disposed) update();
});
};
const measure = () => {
containerRect = container.getBoundingClientRect();
toolbarWidth = toolbarRef.current?.offsetWidth || 0;
toolbarHeight = toolbarRef.current?.offsetHeight || 44;
setContainerWidth(containerRect.width);
scheduleUpdate();
};
measure();
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(element);
resizeObserver.observe(container);
if (toolbarRef.current) resizeObserver.observe(toolbarRef.current);
const viewportLayer = element.parentElement;
const mutationObserver = new MutationObserver(update);
if (viewportLayer) mutationObserver.observe(viewportLayer, { attributes: true, attributeFilter: ["style"] });
const unsubscribeViewport = subscribeCanvasViewportPreview(container, update);
window.addEventListener("resize", update);
const unsubscribeViewport = subscribeCanvasGraphicsViewportPreview(container, scheduleUpdate);
window.addEventListener("resize", measure);
return () => {
disposed = true;
resizeObserver.disconnect();
mutationObserver.disconnect();
unsubscribeViewport();
window.removeEventListener("resize", update);
window.removeEventListener("resize", measure);
};
}, [anchor === null, containerRef, node, viewport.k, viewport.x, viewport.y]);
@@ -175,9 +207,6 @@ export function CanvasNodeToolbar({
const isVideo = node.type === CanvasNodeType.Video;
const isAudio = node.type === CanvasNodeType.Audio;
const hasImage = isImage && Boolean(node.metadata?.content);
const isText = node.type === CanvasNodeType.Text;
const isCharacterReference = isText && node.metadata?.workflowKind === "character" && Boolean(node.metadata.characterAssetId);
const isEditableText = isText && !isCharacterReference;
const copyImagePrompt = (target: CanvasNodeData) => {
const prompt = target.metadata?.prompt?.trim();
if (!prompt) {
@@ -219,52 +248,43 @@ export function CanvasNodeToolbar({
handlers: nodeHoverHandlers,
};
// 注册表只负责动作合同与适用性,Dock 的业务分组在此处唯一确定。
// 注册表统一提供动作合同、适用性和节点 Dock 层级。
const registryTools = resolveToolbarTools("node-hover", nodeHoverCtx, null);
// 锁定始终放在菜单末尾,避免与业务工具混排。
const otherRegistryTools = registryTools.filter((tool) => tool.id !== "node-lock");
// 转为 ToolbarTool 供组件内部逻辑使用
const otherTools: ToolbarTool[] = otherRegistryTools.map((tool) => ({
id: tool.id,
label: tool.displayLabel ? (typeof tool.displayLabel === "function" ? tool.displayLabel(nodeHoverCtx) : tool.displayLabel) : (typeof tool.label === "function" ? tool.label(nodeHoverCtx) : tool.label),
icon: typeof tool.icon === "function" ? tool.icon(nodeHoverCtx) : tool.icon,
active: tool.active?.(nodeHoverCtx),
danger: tool.danger,
disabled: tool.disabled?.(nodeHoverCtx),
onClick: () => tool.run(nodeHoverCtx),
}));
const allTools: ToolbarTool[] = hasImage && !simpleMode
? [...otherTools, ...imageTools.map((tool) => ({ id: tool.id, label: tool.label, icon: tool.icon, onClick: tool.onClick }))]
: otherTools;
const toolById = new Map(allTools.map((tool) => [tool.id, tool]));
const takeTools = (ids: string[]) => ids.map((id) => toolById.get(id)).filter((tool): tool is ToolbarTool => Boolean(tool));
const imageBaseTools = takeTools(hasImage ? ["delete", "download"] : ["delete", "uploadImage"]);
const imageEditTools = takeTools(["maskEdit", "crop", "split"]);
const imagePortraitTools = takeTools(["emotion", "portraitTexture"]).map((tool) => tool.id === "emotion" ? { ...tool, label: "人物情绪" } : tool);
const imageAngleTool = toolById.get("angle");
const videoTools = takeTools(["delete", "download", "subtitles", "timeline", "extractFrames", "extractAudio", "trimRegenerate", "uploadVideo"]).map((tool) => {
if (tool.id === "extractFrames") return { ...tool, label: "提取画面" };
if (tool.id === "trimRegenerate") return { ...tool, label: "截取片段" };
return tool;
const registryToolbarTools: ToolbarTool[] = registryTools.map((tool) => {
const placement = resolveNodeToolbarPlacement(tool, nodeHoverCtx);
return {
id: tool.id,
label: tool.displayLabel ? (typeof tool.displayLabel === "function" ? tool.displayLabel(nodeHoverCtx) : tool.displayLabel) : (typeof tool.label === "function" ? tool.label(nodeHoverCtx) : tool.label),
icon: typeof tool.icon === "function" ? tool.icon(nodeHoverCtx) : tool.icon,
group: placement.group,
order: placement.order,
section: tool.nodeToolbar?.section,
description: tool.nodeToolbar?.description,
active: tool.active?.(nodeHoverCtx),
danger: tool.danger,
disabled: tool.disabled?.(nodeHoverCtx),
onClick: () => tool.run(nodeHoverCtx),
};
});
const genericTools = takeTools(isAudio ? ["delete", "download", "timeline", "uploadAudio"] : isEditableText ? ["delete", "edit", "editText", "generateImage", "saveAsset"] : ["delete", "info", "config"]);
const visibleToolIds = new Set([
...(isImage ? [...imageBaseTools, ...imageEditTools, ...imagePortraitTools, ...(imageAngleTool ? [imageAngleTool] : [])] : isVideo ? videoTools : genericTools).map((tool) => tool.id),
]);
const overflowTools = allTools
.filter((tool) => !visibleToolIds.has(tool.id))
.map((tool) => tool.id === "edit" && (isImage || isVideo) ? { ...tool, label: "生成设置" } : tool);
const lockTool: ToolbarTool = {
id: "node-lock",
label: node.metadata?.locked ? "解锁" : "锁定",
icon: node.metadata?.locked ? <Unlock className="size-3.5" /> : <Lock className="size-3.5" />,
active: Boolean(node.metadata?.locked),
onClick: () => onToggleLocked(node),
};
const allTools: ToolbarTool[] = hasImage && !simpleMode
? [...registryToolbarTools, ...imageTools]
: registryToolbarTools;
const compact = containerWidth < 640;
const narrow = containerWidth < 420;
const inGroup = (group: NodeToolbarGroup) => allTools.filter((tool) => tool.group === group).sort(compareToolbarTools);
const primary = inGroup("primary");
const primaryTools = narrow ? primary.slice(0, 1) : primary;
const portraitTools = compact ? [] : inGroup("portrait");
const viewpointTools = compact ? [] : inGroup("viewpoint");
const processTools = compact ? [...inGroup("portrait"), ...inGroup("viewpoint"), ...inGroup("process")] : inGroup("process");
const workspaceTools = narrow ? [] : inGroup("workspace");
const utilityTools = inGroup("utility");
const moreTools = [...(narrow ? [...primary.slice(1), ...inGroup("workspace")] : []), ...inGroup("more")];
const processMenuLabel = compact ? "工具" : isVideo ? "提取素材" : isImage ? "图片工具" : isAudio ? "音频处理" : "文本调整";
const handleMenuOpenChange = (menuId: string, open: boolean) => {
setOpenMenuId((current) => open ? menuId : current === menuId ? null : current);
if (open) onKeep(node.id);
else onLeave();
else if (!toolbarRef.current?.contains(document.activeElement)) onLeave();
};
const dockStyle = canvasDockStyle(theme, theme.node.text);
@@ -272,66 +292,102 @@ export function CanvasNodeToolbar({
<div
ref={toolbarRef}
className="canvas-node-toolbar absolute z-[var(--z-node-toolbar)] -translate-x-1/2 -translate-y-full"
style={{ left: anchor.left, top: anchor.top, width: "max-content", maxWidth: "min(calc(100% - 20px), 960px)", color: theme.node.text }}
style={{ left: 0, top: 0, transform: `translate3d(${anchor.left}px, ${anchor.top}px, 0)`, width: "max-content", maxWidth: "calc(100% - 20px)", color: theme.node.text }}
onMouseEnter={() => onKeep(node.id)}
onMouseLeave={() => { if (!openMenuId) onLeave(); }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
data-canvas-no-zoom
onKeyDown={(event) => event.stopPropagation()}
onFocus={() => onKeep(node.id)}
onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget) && !openMenuId) onLeave(); }}
>
<div
role="toolbar"
aria-label="节点快捷工具"
className="thin-scrollbar flex h-11 max-w-full items-center gap-0.5 overflow-x-auto rounded-[var(--dock-radius-tight)] px-2 backdrop-blur-2xl"
className="flex h-11 max-w-full items-center gap-0.5 overflow-visible rounded-[var(--dock-radius-tight)] px-2 backdrop-blur-2xl"
style={{ ...dockStyle, border: 0 }}
>
{isImage ? (
<>
{imageBaseTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{imageEditTools.length ? <NodeDockMenuButton menuId="image-edit" label="编辑" icon={imageEditTools[0].icon} tools={imageEditTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{imagePortraitTools.length ? <NodeDockMenuButton menuId="image-portrait" label="人物调整" icon={imagePortraitTools[0].icon} tools={imagePortraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{imageAngleTool ? <NodeDockToolButton tool={imageAngleTool} /> : null}
</>
) : isVideo ? videoTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />) : genericTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
<span aria-hidden className="aceternity-dock-separator mx-1.5 h-6 w-px shrink-0" />
<NodeDockToolButton tool={lockTool} />
{overflowTools.length ? (
<NodeDockMenuButton menuId="more" label="更多" icon={<Ellipsis className="size-3.5" />} tools={overflowTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} placement="topRight" />
{primaryTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{portraitTools.length ? <NodeDockMenuButton menuId="portrait" label="人像调整" icon={<UserRound className="size-3.5" />} tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{viewpointTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{processTools.length ? <NodeDockMenuButton menuId="process" label={processMenuLabel} icon={isVideo ? <Images className="size-3.5" /> : <SlidersHorizontal className="size-3.5" />} tools={processTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{workspaceTools.length ? <span aria-hidden className="aceternity-dock-separator mx-1 h-5 w-px shrink-0" /> : null}
{workspaceTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{utilityTools.length || moreTools.length ? <span aria-hidden className="aceternity-dock-separator mx-1 h-5 w-px shrink-0" /> : null}
{utilityTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} iconOnly />)}
{moreTools.length ? (
<NodeDockMenuButton menuId="more" label="更多" icon={<Ellipsis className="size-3.5" />} tools={moreTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} placement="topRight" iconOnly />
) : null}
</div>
</div>
);
}
function NodeDockToolButton({ tool }: { tool: ToolbarTool }) {
function NodeDockToolButton({ tool, iconOnly = false }: { tool: ToolbarTool; iconOnly?: boolean }) {
return (
<Tooltip title={tool.description ? `${tool.label}:${tool.description}` : tool.label}>
<button
type="button"
className={`aceternity-dock-command is-labeled pointer-events-auto inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2.5 outline-none ${tool.active ? "is-active" : ""} ${tool.danger ? "is-danger" : ""}`}
aria-label={tool.label}
aria-pressed={tool.active || undefined}
aria-pressed={tool.active}
disabled={tool.disabled}
onClick={tool.onClick}
>
<span className="grid size-3.5 shrink-0 place-items-center">{tool.icon}</span>
<span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{tool.label}</span>
{!iconOnly ? <span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{tool.label}</span> : null}
</button>
</Tooltip>
);
}
function NodeDockMenuButton({ menuId, label, icon, tools, openMenuId, onOpenChange, placement = "top" }: { menuId: string; label: string; icon: ReactNode; tools: ToolbarTool[]; openMenuId: string | null; onOpenChange: (menuId: string, open: boolean) => void; placement?: "top" | "topRight" }) {
function compareToolbarTools(left: ToolbarTool, right: ToolbarTool) {
if (left.danger !== right.danger) return left.danger ? 1 : -1;
return left.order - right.order;
}
function NodeDockMenuButton({ menuId, label, icon, tools, openMenuId, onOpenChange, placement = "top", iconOnly = false }: { menuId: string; label: string; icon: ReactNode; tools: ToolbarTool[]; openMenuId: string | null; onOpenChange: (menuId: string, open: boolean) => void; placement?: "top" | "topRight"; iconOnly?: boolean }) {
const open = openMenuId === menuId;
const items: MenuProps["items"] = tools.map((tool) => ({ key: tool.id, icon: tool.icon, label: tool.label, disabled: tool.disabled, onClick: tool.onClick }));
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => triggerRef.current?.focus());
return () => cancelAnimationFrame(frame);
}, [open]);
const sections = new Map<string, ToolbarTool[]>();
for (const tool of tools) {
const section = tool.danger ? "危险操作" : tool.section || "常用操作";
sections.set(section, [...(sections.get(section) || []), tool]);
}
const items: MenuProps["items"] = [...sections].sort(([left], [right]) => Number(left === "危险操作") - Number(right === "危险操作")).map(([section, entries]) => ({
type: "group", key: section, label: section,
children: entries.map((tool) => ({ key: tool.id, icon: tool.icon, label: <div><span className="inline-flex items-center gap-2">{tool.label}{tool.active ? <Check className="size-3.5" /> : null}</span>{tool.description ? <div className="text-[var(--fs-tiny)] opacity-60">{tool.description}</div> : null}</div>, disabled: tool.disabled, danger: tool.danger, onClick: () => { onOpenChange(menuId, false); tool.onClick(); } })),
}));
return (
<Dropdown open={open} trigger={["click"]} placement={placement} onOpenChange={(nextOpen) => onOpenChange(menuId, nextOpen)} menu={{ items }}>
<Dropdown open={open} trigger={["click"]} placement={placement} onOpenChange={(nextOpen) => onOpenChange(menuId, nextOpen)} menu={{ items }} autoFocus popupRender={(menu) => <div className="canvas-node-toolbar-menu" data-canvas-no-zoom data-canvas-wheel-scroll onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} onKeyDownCapture={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); triggerRef.current?.focus(); onOpenChange(menuId, false); } }} onKeyDown={(event) => event.stopPropagation()}>{menu}</div>}>
<button
ref={triggerRef}
type="button"
className={`aceternity-dock-command is-labeled pointer-events-auto inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2.5 outline-none ${open ? "is-active" : ""}`}
aria-label={label}
aria-expanded={open}
aria-haspopup="menu"
title={label}
onKeyDown={(event) => {
if (event.key === "ArrowDown" && open) {
event.preventDefault();
document.querySelector<HTMLElement>(".ant-dropdown:not(.ant-dropdown-hidden) .canvas-node-toolbar-menu [role='menuitem']:not([aria-disabled='true'])")?.focus();
}
if (event.key === "Escape" && open) {
event.preventDefault();
event.stopPropagation();
onOpenChange(menuId, false);
}
}}
>
<span className="grid size-3.5 shrink-0 place-items-center">{icon}</span>
<span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{label}</span>
<ChevronDown className="size-3 shrink-0 opacity-55" />
{!iconOnly ? <><span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{label}</span><ChevronDown className="size-3 shrink-0 opacity-55" /></> : null}
</button>
</Dropdown>
);