perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
133
web/src/lib/canvas/canvas-selection.ts
Normal file
133
web/src/lib/canvas/canvas-selection.ts
Normal 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 };
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!),
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
/** 危险操作——渲染时隔离到独立分组 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user