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

@@ -258,8 +258,8 @@ function applySuccessfulVersionSelection(nodes: CanvasNodeData[], updatedNode: C
export async function syncGenerationTaskToCanvasStore(task: GenerationTask) {
if (task.status !== "succeeded" || !task.projectId) return false;
const store = useCanvasStore.getState();
const project = store.projects.find((item) => item.id === task.projectId);
const { loadCanvasProjectForEditing } = await import("@/services/user-data-sync");
const project = await loadCanvasProjectForEditing(task.projectId);
if (!project) return false;
const node = findGenerationTaskNode(project.nodes, task);
if (!node) return false;

View File

@@ -16,7 +16,18 @@ type NodeDragPreviewDomState = {
previousIds: Set<string>;
};
type NodeSelectionPreviewDomState = {
elementsById: Map<string, HTMLElement>;
previousStates: Map<string, "include" | "remove">;
};
export type CanvasNodeSelectionPreview = {
includeNodeIds: ReadonlySet<string>;
removeNodeIds: ReadonlySet<string>;
};
const nodeDragPreviewDomStates = new WeakMap<HTMLDivElement, NodeDragPreviewDomState>();
const nodeSelectionPreviewDomStates = new WeakMap<HTMLDivElement, NodeSelectionPreviewDomState>();
const liveViewportElements = new WeakMap<HTMLDivElement, { worldLayer: HTMLElement | null; gridLayer: HTMLElement | null }>();
// 空间网格点模式的点半径(像素单位)。远距时使用更小半径,避免点阵糊成一团。
@@ -131,6 +142,47 @@ export function subscribeCanvasNodeDragPreview(container: HTMLDivElement, listen
return () => container.removeEventListener(CANVAS_NODE_DRAG_PREVIEW_EVENT, handlePreview);
}
/**
* Shows the selection delta on mounted nodes without changing React state.
* The controller commits the final Set once on pointer-up.
*/
export function applyCanvasNodeSelectionPreview(container: HTMLDivElement | null, preview: CanvasNodeSelectionPreview | null) {
if (!container) return;
let state = nodeSelectionPreviewDomStates.get(container);
if (!state) {
state = { elementsById: new Map(), previousStates: new Map() };
nodeSelectionPreviewDomStates.set(container, state);
}
const nextStates = new Map<string, "include" | "remove">();
if (preview) {
for (const nodeId of preview.includeNodeIds) nextStates.set(nodeId, "include");
for (const nodeId of preview.removeNodeIds) nextStates.set(nodeId, "remove");
}
if (state.elementsById.size === 0 && nextStates.size > 0) {
container.querySelectorAll<HTMLElement>("[data-node-id]").forEach((element) => {
const nodeId = element.dataset.nodeId;
if (nodeId) state?.elementsById.set(nodeId, element);
});
}
for (const [nodeId, previousState] of state.previousStates) {
if (nextStates.get(nodeId) === previousState) continue;
state.elementsById.get(nodeId)?.removeAttribute("data-canvas-selection-preview");
}
for (const [nodeId, nextState] of nextStates) {
if (state.previousStates.get(nodeId) === nextState) continue;
const element = state.elementsById.get(nodeId);
if (!element || !element.isConnected) continue;
element.dataset.canvasSelectionPreview = nextState;
}
state.previousStates = nextStates;
if (!preview) state.elementsById.clear();
}
export function applyCanvasSelectionPreview(container: HTMLDivElement | null, selection: SelectionBox) {
container?.dispatchEvent(new CustomEvent<SelectionBox>(CANVAS_SELECTION_PREVIEW_EVENT, { detail: selection }));
}

View File

@@ -0,0 +1,133 @@
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialBounds, type CanvasSpatialIndex } from "@/lib/canvas/canvas-spatial-index";
import type { CanvasNodeData, CanvasSelectionHitMode, CanvasSelectionStrategy } from "@/types/canvas";
type SelectionModifiers = {
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
};
type CanvasPointerIntentInput = SelectionModifiers & {
background: boolean;
boxSelectEnabled: boolean;
button: number;
pointerType: string;
spacePressed: boolean;
};
export type CanvasPointerIntent = "ignore" | "pan" | "select";
/**
* Keeps desktop selection and navigation on separate event paths: mouse drag
* selects by default, while touch, Space + drag and middle-button drag pan.
* Trackpad gestures arrive as wheel events and never pass through this router.
*/
export function resolveCanvasPointerIntent(input: CanvasPointerIntentInput): CanvasPointerIntent {
if (input.pointerType === "touch") return input.background ? "pan" : "ignore";
if (input.button === 1) return "pan";
if (input.button !== 0) return "ignore";
if (input.spacePressed) return "pan";
if (!input.background) return "ignore";
if (input.boxSelectEnabled || input.altKey || input.ctrlKey || input.metaKey || input.shiftKey) return "select";
return "pan";
}
export type CanvasSelectionSpatialIndexCache = {
get: (nodes: CanvasNodeData[]) => CanvasSpatialIndex<CanvasNodeData>;
};
/** Reuses the selectable-node index until the immutable node array changes. */
export function createCanvasSelectionSpatialIndexCache(): CanvasSelectionSpatialIndexCache {
let source: CanvasNodeData[] | null = null;
let index = buildCanvasSpatialIndex<CanvasNodeData>([]);
return {
get(nodes) {
if (source === nodes) return index;
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const hiddenBatchChildIds = new Set(nodes.flatMap((node) => {
const rootId = node.metadata?.batchRootId;
const root = rootId ? nodeById.get(rootId) : undefined;
return root && !root.metadata?.imageBatchExpanded ? [node.id] : [];
}));
index = buildCanvasSpatialIndex(nodes
.filter((node) => !hiddenBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed))
.map((node) => ({ id: node.id, bounds: canvasNodeBounds(node), value: node })));
source = nodes;
return index;
},
};
}
export function resolveCanvasSelectionStrategy(modifiers: SelectionModifiers): CanvasSelectionStrategy {
if (modifiers.altKey) return "subtract";
if (modifiers.ctrlKey || modifiers.metaKey) return "toggle";
if (modifiers.shiftKey) return "add";
return "replace";
}
export function resolveCanvasSelectionHitMode(startWorldX: number, currentWorldX: number): CanvasSelectionHitMode {
return currentWorldX >= startWorldX ? "contain" : "intersect";
}
export function createCanvasSelectionBounds(startWorldX: number, startWorldY: number, currentWorldX: number, currentWorldY: number): CanvasSpatialBounds {
return {
left: Math.min(startWorldX, currentWorldX),
top: Math.min(startWorldY, currentWorldY),
right: Math.max(startWorldX, currentWorldX),
bottom: Math.max(startWorldY, currentWorldY),
};
}
export function canvasSelectionHitsBounds(selectionBounds: CanvasSpatialBounds, nodeBounds: CanvasSpatialBounds, hitMode: CanvasSelectionHitMode): boolean {
if (hitMode === "contain") {
return nodeBounds.left >= selectionBounds.left
&& nodeBounds.top >= selectionBounds.top
&& nodeBounds.right <= selectionBounds.right
&& nodeBounds.bottom <= selectionBounds.bottom;
}
return nodeBounds.right > selectionBounds.left
&& nodeBounds.left < selectionBounds.right
&& nodeBounds.bottom > selectionBounds.top
&& nodeBounds.top < selectionBounds.bottom;
}
export function applyCanvasSelectionStrategy(initialSelection: Iterable<string>, hitNodeIds: Iterable<string>, strategy: CanvasSelectionStrategy): Set<string> {
const hits = Array.from(hitNodeIds);
if (strategy === "replace") return new Set(hits);
const nextSelection = new Set(initialSelection);
for (const nodeId of hits) {
if (strategy === "add") nextSelection.add(nodeId);
else if (strategy === "subtract") nextSelection.delete(nodeId);
else if (nextSelection.has(nodeId)) nextSelection.delete(nodeId);
else nextSelection.add(nodeId);
}
return nextSelection;
}
export function resolveCanvasSelectionPreviewDelta(initialSelection: ReadonlySet<string>, hitNodeIds: ReadonlySet<string>, strategy: CanvasSelectionStrategy) {
const includeNodeIds = new Set<string>();
const removeNodeIds = new Set<string>();
if (strategy === "replace") {
for (const nodeId of hitNodeIds) {
if (!initialSelection.has(nodeId)) includeNodeIds.add(nodeId);
}
for (const nodeId of initialSelection) {
if (!hitNodeIds.has(nodeId)) removeNodeIds.add(nodeId);
}
return { includeNodeIds, removeNodeIds };
}
for (const nodeId of hitNodeIds) {
if (strategy === "add") {
if (!initialSelection.has(nodeId)) includeNodeIds.add(nodeId);
} else if (strategy === "subtract") {
if (initialSelection.has(nodeId)) removeNodeIds.add(nodeId);
} else if (initialSelection.has(nodeId)) removeNodeIds.add(nodeId);
else includeNodeIds.add(nodeId);
}
return { includeNodeIds, removeNodeIds };
}

View File

@@ -61,9 +61,9 @@ export const CANVAS_SHORTCUTS: CanvasShortcutItem[] = [
id: "pan",
category: "navigation",
title: "平移视图",
description: "在画布空白处拖动,或使用空格键与中键拖动",
keys: [["空白处拖动"], ["Space", "拖动"], ["中键拖动"]],
keywords: ["移动", "画布", "pan"],
description: "触控板双指滑动,或按住空格键 / 中键拖动画布",
keys: [["触控板双指"], ["Space", "左键拖动"], ["中键拖动"]],
keywords: ["移动", "画布", "触控板", "pan"],
},
{
id: "zoom-wheel",
@@ -101,16 +101,16 @@ export const CANVAS_SHORTCUTS: CanvasShortcutItem[] = [
id: "box-select",
category: "selection",
title: "框选多个节点",
description: "按住修饰键后拖动选区",
keys: [["Shift", "拖动"], [CANVAS_MODIFIER_KEY, "拖动"]],
description: "空白处左键拖动默认框选;Shift 追加、Command/Ctrl 切换、Alt 移除",
keys: [["空白处左键拖动"], ["Shift", "拖动"], [CANVAS_MODIFIER_KEY, "拖动"], ["Alt", "拖动"]],
keywords: ["多选", "范围", "selection"],
},
{
id: "box-select-tool",
category: "selection",
title: "使用框选工具",
description: "完成框选后自动回到移动与选择工具",
keys: [["框选工具", "拖动"]],
title: "使用区域选择工具",
description: "保持区域选择模式,可连续框选多个范围",
keys: [["区域选择", "拖动"]],
keywords: ["工具栏", "多选", "selection"],
},
{

View File

@@ -1,4 +1,4 @@
import { Eraser, FolderOpen, Hand, Palette, Plus, Redo2, Settings2, SquareDashedMousePointer, Trash2, Undo2, X } from "lucide-react";
import { Eraser, FolderOpen, Hand, Palette, Plus, Redo2, Settings2, SquareDashedMousePointer, Trash2, Undo2 } from "lucide-react";
import { registerToolbarTools, type ToolDefinition } from "@/lib/canvas/tool-registry";
@@ -7,26 +7,23 @@ export const mainToolbarTools: ToolDefinition[] = [
id: "tool-move",
toolbar: "main",
category: "navigation",
label: (ctx) => ctx.canvasTool === "box-select" ? "移动与选择" : ctx.selectedCount ? `取消选择${ctx.selectedCount > 1 ? ` ${ctx.selectedCount} 个节点` : ""}` : "移动与选择",
icon: (ctx) => ctx.canvasTool === "box-select" ? <Hand /> : ctx.selectedCount ? <X /> : <Hand />,
label: "抓手工具",
icon: <Hand />,
defaultVisible: true,
defaultOrder: 10,
active: (ctx) => ctx.canvasTool === "move",
run: (ctx) => {
if (ctx.canvasTool !== "move") ctx.handlers.onToolChange("move");
else ctx.handlers.onDeselect();
},
run: (ctx) => ctx.handlers.onToolChange("move"),
},
{
id: "tool-box-select",
toolbar: "main",
category: "navigation",
label: "框选",
label: "区域选择",
icon: <SquareDashedMousePointer />,
defaultVisible: true,
defaultOrder: 20,
active: (ctx) => ctx.canvasTool === "box-select",
run: (ctx) => ctx.handlers.onToolChange(ctx.canvasTool === "box-select" ? "move" : "box-select"),
run: (ctx) => ctx.handlers.onToolChange("box-select"),
},
{
id: "tool-undo",

View File

@@ -31,10 +31,15 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
toolbar: "node-hover",
category: "node-state",
label: (ctx) => isCharacterReference(ctx) ? "查看角色详情" : "查看节点信息",
displayLabel: (ctx) => isCharacterReference(ctx) ? "角色详情" : "信息",
displayLabel: (ctx) => isCharacterReference(ctx) ? "角色详情" : "节点信息",
icon: (ctx) => isCharacterReference(ctx) ? <UserRound className="size-3.5" /> : <Info className="size-3.5" />,
defaultVisible: true,
defaultOrder: 10,
nodeToolbar: {
group: (ctx) => isCharacterReference(ctx) || (!isImage(ctx) && !isVideo(ctx) && !isAudio(ctx) && !isEditableText(ctx) && !isConfig(ctx)) ? "primary" : "more",
order: 10,
section: "节点管理",
},
run: (ctx) => ctx.handlers.onNodeInfo(ctx.node!),
},
{
@@ -42,10 +47,11 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
toolbar: "node-hover",
category: "node-state",
label: "移除节点",
displayLabel: "删除",
displayLabel: "移除节点",
icon: <Trash2 className="size-3.5" />,
defaultVisible: true,
defaultOrder: 20,
nodeToolbar: { group: "more", order: 1000, section: "危险操作" },
danger: true,
run: (ctx) => ctx.handlers.onNodeDelete(ctx.node!),
},
@@ -59,6 +65,7 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
icon: <RefreshCw className="size-3.5" />,
defaultVisible: true,
defaultOrder: 30,
nodeToolbar: { group: "primary", order: 5 },
applicable: canRetry,
run: (ctx) => ctx.handlers.onNodeRetry(ctx.node!),
},
@@ -66,11 +73,12 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
id: "extractFrames",
toolbar: "node-hover",
category: "node-state",
label: (ctx) => ctx.extractingVideoFrames ? "正在提取视频画面" : "提取一个或多个视频画面",
label: (ctx) => ctx.extractingVideoFrames ? "正在截取视频画面" : "截取一个或多个视频画面",
displayLabel: (ctx) => ctx.extractingVideoFrames ? "提取中" : "提取画面",
icon: (ctx) => ctx.extractingVideoFrames ? <LoaderCircle className="size-3.5 animate-spin" /> : <Images className="size-3.5" />,
defaultVisible: true,
defaultOrder: 40,
nodeToolbar: { group: "process", order: 10, section: "提取素材", description: "选取画面,保存为图片节点" },
applicable: (ctx) => hasVideo(ctx) && !simpleMode(ctx),
disabled: (ctx) => ctx.extractingVideoFrames,
run: (ctx) => ctx.handlers.onNodeExtractVideoFrames(ctx.node!),
@@ -79,11 +87,12 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
id: "extractAudio",
toolbar: "node-hover",
category: "node-state",
label: (ctx) => ctx.extractingAudio ? "正在提取声音为 MP3" : "提取声音为 MP3",
label: (ctx) => ctx.extractingAudio ? "正在提取音频" : "提取视频声音为 MP3,保留原视频声音",
displayLabel: (ctx) => ctx.extractingAudio ? "提取中" : "提取音频",
icon: (ctx) => ctx.extractingAudio ? <LoaderCircle className="size-3.5 animate-spin" /> : <AudioLines className="size-3.5" />,
defaultVisible: true,
defaultOrder: 42,
nodeToolbar: { group: "process", order: 20, section: "提取素材", description: "生成音频节点,保留原视频声音" },
applicable: (ctx) => hasVideo(ctx) && !simpleMode(ctx),
disabled: (ctx) => ctx.extractingAudio || ctx.trimmingVideo,
run: (ctx) => ctx.handlers.onNodeExtractAudioFromVideo(ctx.node!),
@@ -97,6 +106,7 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
icon: (ctx) => ctx.trimmingVideo ? <LoaderCircle className="size-3.5 animate-spin" /> : <Scissors className="size-3.5" />,
defaultVisible: true,
defaultOrder: 44,
nodeToolbar: { group: "primary", order: 10, description: "截取一段或多段,生成新视频节点" },
applicable: (ctx) => hasVideo(ctx) && !simpleMode(ctx),
disabled: (ctx) => ctx.extractingAudio || ctx.trimmingVideo,
run: (ctx) => ctx.handlers.onNodeTrimVideoSegments(ctx.node!),
@@ -106,10 +116,11 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
toolbar: "node-hover",
category: "node-state",
label: "加入我的素材",
displayLabel: "存素材",
displayLabel: "保存到素材库",
icon: <FolderPlus className="size-3.5" />,
defaultVisible: true,
defaultOrder: 50,
nodeToolbar: { group: "more", order: 40, section: "素材" },
applicable: (ctx) => hasImage(ctx) || hasVideo(ctx) || isEditableText(ctx),
run: (ctx) => ctx.handlers.onNodeSaveAsset(ctx.node!),
},
@@ -122,6 +133,7 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
icon: <Download className="size-3.5" />,
defaultVisible: true,
defaultOrder: 60,
nodeToolbar: { group: "utility", order: 20 },
applicable: (ctx) => hasImage(ctx) || hasVideo(ctx) || hasAudio(ctx),
run: (ctx) => ctx.handlers.onNodeDownload(ctx.node!),
},
@@ -130,10 +142,11 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
toolbar: "node-hover",
category: "node-state",
label: (ctx) => isEditableText(ctx) ? "调用文本模型生成内容" : "编辑",
displayLabel: (ctx) => isEditableText(ctx) ? "文本生成" : "编辑",
displayLabel: (ctx) => isEditableText(ctx) ? "文本生成" : "生成设置",
icon: <MessageSquare className="size-3.5" />,
defaultVisible: true,
defaultOrder: 70,
nodeToolbar: { group: (ctx) => isEditableText(ctx) ? "primary" : "more", order: 20, section: "生成信息" },
applicable: canOpenDialog,
run: (ctx) => ctx.handlers.onNodeToggleDialog(ctx.node!),
},
@@ -146,6 +159,7 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
icon: <Maximize2 className="size-3.5" />,
defaultVisible: true,
defaultOrder: 80,
nodeToolbar: { group: "primary", order: 30 },
applicable: isEditableText,
run: (ctx) => ctx.handlers.onNodeEditText(ctx.node!),
},
@@ -158,6 +172,7 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
icon: <ImageIcon className="size-3.5" />,
defaultVisible: true,
defaultOrder: 90,
nodeToolbar: { group: "primary", order: 40 },
applicable: isEditableText,
run: (ctx) => ctx.handlers.onNodeGenerateImage(ctx.node!),
},
@@ -170,6 +185,7 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
icon: <Settings2 className="size-3.5" />,
defaultVisible: true,
defaultOrder: 100,
nodeToolbar: { group: "primary", order: 10 },
applicable: (ctx) => isConfig(ctx) && !simpleMode(ctx),
run: (ctx) => ctx.handlers.onNodeToggleDialog(ctx.node!),
},
@@ -182,6 +198,7 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
icon: <Minus className="size-3.5" />,
defaultVisible: true,
defaultOrder: 110,
nodeToolbar: { group: "process", order: 10 },
applicable: (ctx) => isEditableText(ctx) && !simpleMode(ctx),
run: (ctx) => ctx.handlers.onNodeDecreaseFont(ctx.node!),
},
@@ -194,6 +211,7 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
icon: <Plus className="size-3.5" />,
defaultVisible: true,
defaultOrder: 120,
nodeToolbar: { group: "process", order: 20 },
applicable: (ctx) => isEditableText(ctx) && !simpleMode(ctx),
run: (ctx) => ctx.handlers.onNodeIncreaseFont(ctx.node!),
},
@@ -206,6 +224,7 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
icon: <Upload className="size-3.5" />,
defaultVisible: true,
defaultOrder: 130,
nodeToolbar: { group: "primary", order: 10 },
applicable: (ctx) => isImage(ctx) && !hasImage(ctx),
run: (ctx) => ctx.handlers.onNodeUpload(ctx.node!),
},
@@ -218,6 +237,7 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
icon: <Video className="size-3.5" />,
defaultVisible: true,
defaultOrder: 140,
nodeToolbar: { group: (ctx) => hasVideo(ctx) ? "more" : "primary", order: 45, section: "素材" },
applicable: isVideo,
run: (ctx) => ctx.handlers.onNodeUpload(ctx.node!),
},
@@ -230,6 +250,7 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
icon: <Captions className="size-3.5" />,
defaultVisible: true,
defaultOrder: 145,
nodeToolbar: { group: "primary", order: 20 },
applicable: (ctx) => hasVideo(ctx),
run: (ctx) => ctx.handlers.onNodeSubtitles(ctx.node!),
},
@@ -238,10 +259,11 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
toolbar: "node-hover",
category: "node-state",
label: "时间线编辑",
displayLabel: "时间线",
displayLabel: "进入剪辑",
icon: <Clapperboard className="size-3.5" />,
defaultVisible: true,
defaultOrder: 148,
nodeToolbar: { group: "workspace", order: 30, section: "项目剪辑", description: "打开项目时间线,定位到当前素材" },
applicable: (ctx) => hasVideo(ctx) || hasAudio(ctx),
run: (ctx) => ctx.handlers.onNodeTimeline(ctx.node!),
},
@@ -254,6 +276,7 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
icon: <Music2 className="size-3.5" />,
defaultVisible: true,
defaultOrder: 150,
nodeToolbar: { group: (ctx) => hasAudio(ctx) ? "more" : "primary", order: 45, section: "素材" },
applicable: isAudio,
run: (ctx) => ctx.handlers.onNodeUpload(ctx.node!),
},
@@ -263,10 +286,11 @@ export const nodeHoverToolbarTools: ToolDefinition[] = [
toolbar: "node-hover",
category: "navigation",
label: (ctx) => ctx.nodeMetadata?.locked ? "解锁节点" : "锁定位置和尺寸",
displayLabel: (ctx) => ctx.nodeMetadata?.locked ? "解锁" : "锁定",
displayLabel: "锁定位置与尺寸",
icon: (ctx) => ctx.nodeMetadata?.locked ? <Unlock className="size-3.5" /> : <Lock className="size-3.5" />,
defaultVisible: true,
defaultOrder: 160,
nodeToolbar: { group: "more", order: 900, section: "节点管理" },
active: (ctx) => Boolean(ctx.nodeMetadata?.locked),
run: (ctx) => ctx.handlers.onNodeToggleLocked(ctx.node!),
},

View File

@@ -1,4 +1,4 @@
export type { AddNodeMenuCommand, AddNodeMenuContext, ToolbarHandlers, ToolbarId, ToolbarPrefs, ToolCategory, ToolContext, ToolDefinition } from "./tool-definition";
export type { AddNodeMenuCommand, AddNodeMenuContext, NodeToolbarGroup, ToolbarHandlers, ToolbarId, ToolbarPrefs, ToolCategory, ToolContext, ToolDefinition } from "./tool-definition";
export { clearToolbarPrefs, persistToolbarPrefs, readToolbarPrefs } from "./tool-persistence";
export { defaultToolbarPrefs, getAddNodeMenuCommands, getToolbarTools, registerAddNodeMenuCommands, registerToolbarTools, resolveAddNodeMenuCommands, resolveToolbarEntries, resolveToolbarTools } from "./tool-registry";
export { defaultToolbarPrefs, getAddNodeMenuCommands, getToolbarTools, registerAddNodeMenuCommands, registerToolbarTools, resolveAddNodeMenuCommands, resolveNodeToolbarPlacement, resolveToolbarEntries, resolveToolbarTools } from "./tool-registry";
import "./definitions";

View File

@@ -7,6 +7,7 @@ import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeTypeId, CanvasToolMo
/** 工具栏标识——每个工具栏有独立的注册表与偏好 */
export type ToolbarId = "main" | "selection" | "node-hover" | "add-node-menu";
export type NodeToolbarGroup = "primary" | "portrait" | "viewpoint" | "process" | "workspace" | "utility" | "more";
/** 工具分类——用于分组渲染、危险隔离与 separator 自动插入 */
export type ToolCategory =
@@ -166,6 +167,13 @@ export type ToolDefinition = {
defaultVisible: boolean;
/** 默认排序权重,升序 */
defaultOrder: number;
/** 节点工具条的展示层级;由注册表统一决定,避免组件按工具 ID 二次编排。 */
nodeToolbar?: {
group: NodeToolbarGroup | ((ctx: ToolContext) => NodeToolbarGroup);
order?: number | ((ctx: ToolContext) => number);
section?: string;
description?: string;
};
active?: (ctx: ToolContext) => boolean;
disabled?: (ctx: ToolContext) => boolean;
/** 危险操作——渲染时隔离到独立分组 */

View File

@@ -5,7 +5,7 @@ import type { FloatingDockEntry } from "@/components/ui/aceternity/floating-dock
import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts";
import { listCreatableNodeDefinitions } from "@/lib/canvas/node-registry";
import type { AddNodeMenuCommand, AddNodeMenuContext, ToolCategory, ToolContext, ToolDefinition, ToolbarId, ToolbarPrefs } from "./tool-definition";
import type { AddNodeMenuCommand, AddNodeMenuContext, NodeToolbarGroup, ToolCategory, ToolContext, ToolDefinition, ToolbarId, ToolbarPrefs } from "./tool-definition";
/** 模块级注册表 */
const registry = new Map<ToolbarId, ToolDefinition[]>();
@@ -93,6 +93,15 @@ export function resolveToolbarTools(toolbar: ToolbarId, ctx: ToolContext, prefs:
});
}
/** 将节点工具定义解析为唯一的 Dock 展示层级和排序。 */
export function resolveNodeToolbarPlacement(tool: ToolDefinition, ctx: ToolContext): { group: NodeToolbarGroup; order: number } {
const placement = tool.nodeToolbar;
return {
group: typeof placement?.group === "function" ? placement.group(ctx) : placement?.group || "more",
order: typeof placement?.order === "function" ? placement.order(ctx) : placement?.order ?? tool.defaultOrder,
};
}
/** 解析添加节点菜单命令——合并插件节点后按 applicable 过滤并排序。 */
export function resolveAddNodeMenuCommands(ctx: AddNodeMenuContext): AddNodeMenuCommand[] {
return [...getAddNodeMenuCommands(), ...getPluginNodeMenuCommands()].filter((command) => !command.applicable || command.applicable(ctx)).sort((a, b) => a.defaultOrder - b.defaultOrder);

View File

@@ -11,7 +11,7 @@ import { CREATION_PREFERENCES_STORE_KEY, useCreationPreferencesStore } from "@/s
import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { useUserStore } from "@/stores/use-user-store";
import { PLUGIN_STORE_KEY, usePluginStore } from "@/stores/use-plugin-store";
import { installRemoteUserDataAutoSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncExclusive } from "@/services/user-data-sync";
import { initializeRemoteUserDataSession, installRemoteUserDataAutoSync, resetRemoteUserDataSync, withRemoteUserDataSyncExclusive } from "@/services/user-data-sync";
import { withGenerationConsumersPaused } from "@/services/generation-consumer-lifecycle";
export async function switchUserStorageScope(userId?: string | null) {
@@ -82,9 +82,7 @@ export async function applyUserSession(payload: AuthSessionPayload) {
}
installRemoteUserDataAutoSync();
if (payload.user?.id) {
// 登录后的服务端快照是实体基线;基线完成前不开放工作区写操作。
// 拉取失败时保留本地缓存供只读降级,但远端写入口会明确拒绝,不能把旧缓存上传成真相。
await syncRemoteUserData(payload.user.id).catch((error) => console.warn("登录后云端数据基线建立失败,已停止远端写入", error));
await initializeRemoteUserDataSession(payload.user.id);
} else resetRemoteUserDataSync();
} finally {
useUserStore.getState().setHydrated(true);

View File

@@ -3,6 +3,7 @@ const workspaceRouteLoaders = {
canvas: () => import("@/pages/canvas"),
create: () => import("@/pages/create"),
projects: () => import("@/pages/projects"),
projectDetail: () => import("@/pages/projects/detail"),
wallet: () => import("@/pages/wallet"),
};
@@ -10,12 +11,18 @@ export const loadAssetsPage = workspaceRouteLoaders.assets;
export const loadCanvasPage = workspaceRouteLoaders.canvas;
export const loadCanvasProjectPage = () => import("@/pages/canvas/project");
export const loadCreatePage = workspaceRouteLoaders.create;
export const loadProjectDetailPage = workspaceRouteLoaders.projectDetail;
export const loadProjectsPage = workspaceRouteLoaders.projects;
export const loadWalletPage = workspaceRouteLoaders.wallet;
export function preloadWorkspaceRoute(pathnameOrSlug: string) {
// 根路径就是创作页,预加载时仍映射到其内部模块名。
const slug = pathnameOrSlug.replace(/^\//, "").split("/", 1)[0] || "create";
const segments = pathnameOrSlug.replace(/^\//, "").split("/").filter(Boolean);
const slug = segments[0] || "create";
if (slug === "projects" && segments.length > 1) {
void workspaceRouteLoaders.projectDetail();
return;
}
const load = workspaceRouteLoaders[slug as keyof typeof workspaceRouteLoaders];
if (load) void load();
}