Add canvas undo/redo and full shortcut system

Snapshot-based undo/redo (max 50 steps) with Cmd+Z / Cmd+Shift+Z,
ignoring volatile generation progress fields. Bind the remaining
reference shortcuts: Cmd+D duplicate, Cmd+Enter generate selection,
Tab new-node palette, Cmd+F canvas node search panel, Cmd+0 fit view,
Cmd+=/- zoom, V pan-tool toggle, H hold-to-pan, plain G to group.
Expand the shortcut help panel to the full four-group layout.
This commit is contained in:
134 2026-09-19 12:53:07 +08:00
parent 94b3ac9cb3
commit 8b2955eb0b

View File

@ -9664,7 +9664,7 @@ export function LiblibVideoCanvasReplica({
if (smartEditNodeId) return; if (smartEditNodeId) return;
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
if (target?.closest("input, textarea, select, [contenteditable='true']")) return; if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
if((event.metaKey||event.ctrlKey)&&event.key.toLowerCase()==="g"){event.preventDefault();if(event.shiftKey&&selectedNodeId&&nodes.some(n=>n.id===selectedNodeId&&isCanvasGroup(n)))releaseGroup();else createSelectedGroup();return;} if (event.key.toLowerCase() === "g" && !event.altKey && !(event.metaKey && !event.shiftKey)) {event.preventDefault();if((event.metaKey||event.ctrlKey)&&event.shiftKey&&selectedNodeId&&nodes.some(n=>n.id===selectedNodeId&&isCanvasGroup(n)))releaseGroup();else if(!event.metaKey&&!event.ctrlKey)createSelectedGroup();return;}
if (event.code === "Space") { event.preventDefault(); spacePanRef.current = true; return; } if (event.code === "Space") { event.preventDefault(); spacePanRef.current = true; return; }
if (!event.metaKey && !event.ctrlKey && !event.altKey && event.key.toLowerCase() === "l") { if (!event.metaKey && !event.ctrlKey && !event.altKey && event.key.toLowerCase() === "l") {
event.preventDefault(); event.preventDefault();
@ -9979,6 +9979,156 @@ export function LiblibVideoCanvasReplica({
}, []); }, []);
const isBusy = isLoading || isProjectLoading; const isBusy = isLoading || isProjectLoading;
// ── 画布撤销/重做(快照式,忽略生成进度等易变字段)──
const canvasHistoryRef = useRef<{
undo: Array<{ nodes: CloneNode[]; edges: CloneEdge[] }>;
redo: Array<{ nodes: CloneNode[]; edges: CloneEdge[] }>;
}>({ undo: [], redo: [] });
const historyLastSnapshotRef = useRef<{ nodes: CloneNode[]; edges: CloneEdge[] } | null>(null);
const historySignatureRef = useRef("");
const [, setHistoryVersion] = useState(0);
const canvasHistorySignature = useCallback((snapshot: { nodes: CloneNode[]; edges: CloneEdge[] }) => JSON.stringify([
snapshot.nodes.map(node => [
node.id, node.x, node.y, node.width, node.height, node.title, node.subtitle, node.kind, node.inputType,
node.draftValues
? JSON.stringify({ ...node.draftValues, generationProgress: 0, generationError: undefined, uploadStatus: undefined, uploadError: undefined })
: null,
]),
snapshot.edges.map(edge => [edge.id, edge.source, edge.target]),
]), []);
useEffect(() => {
if (isProjectLoading) return;
const timer = window.setTimeout(() => {
const snapshot = {
nodes: JSON.parse(JSON.stringify(nodes)) as CloneNode[],
edges: JSON.parse(JSON.stringify(edges)) as CloneEdge[],
};
const signature = canvasHistorySignature(snapshot);
if (signature === historySignatureRef.current) return;
const last = historyLastSnapshotRef.current;
if (last && canvasHistorySignature(last) !== signature) {
const undoStack = canvasHistoryRef.current.undo;
undoStack.push(last);
if (undoStack.length > 50) undoStack.shift();
canvasHistoryRef.current.redo.length = 0;
setHistoryVersion(current => current + 1);
}
historyLastSnapshotRef.current = snapshot;
historySignatureRef.current = signature;
}, 800);
return () => window.clearTimeout(timer);
}, [nodes, edges, isProjectLoading, canvasHistorySignature]);
const undoCanvas = useCallback(() => {
const previous = canvasHistoryRef.current.undo.pop();
if (!previous) { toast.info("没有可撤销的操作"); return; }
canvasHistoryRef.current.redo.push({
nodes: JSON.parse(JSON.stringify(nodes)) as CloneNode[],
edges: JSON.parse(JSON.stringify(edges)) as CloneEdge[],
});
historyLastSnapshotRef.current = previous;
historySignatureRef.current = canvasHistorySignature(previous);
setNodes(previous.nodes);
setEdges(previous.edges);
setHistoryVersion(current => current + 1);
toast.success("已撤销");
}, [nodes, edges]);
const redoCanvas = useCallback(() => {
const next = canvasHistoryRef.current.redo.pop();
if (!next) { toast.info("没有可重做的操作"); return; }
canvasHistoryRef.current.undo.push({
nodes: JSON.parse(JSON.stringify(nodes)) as CloneNode[],
edges: JSON.parse(JSON.stringify(edges)) as CloneEdge[],
});
historyLastSnapshotRef.current = next;
historySignatureRef.current = canvasHistorySignature(next);
setNodes(next.nodes);
setEdges(next.edges);
setHistoryVersion(current => current + 1);
toast.success("已重做");
}, [nodes, edges]);
// ── 画布节点搜索(⌘F)──
const [nodeSearchOpen, setNodeSearchOpen] = useState(false);
// ── 扩展快捷键:撤销/重做/复制/生成/新建/搜索/适应画布/拖动工具 ──
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (smartEditNodeId) return;
const target = event.target as HTMLElement | null;
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
const command = event.metaKey || event.ctrlKey;
if (command && event.key.toLowerCase() === "z") {
event.preventDefault();
if (event.shiftKey) redoCanvas();
else undoCanvas();
return;
}
if (command && event.key.toLowerCase() === "d" && selectedNodeId) {
event.preventDefault();
duplicateNode(selectedNodeId);
return;
}
if (command && event.key === "Enter" && selectedNodeId) {
event.preventDefault();
void runNode(selectedNodeId);
return;
}
if (command && event.key === "0") {
event.preventDefault();
fitDockTarget();
return;
}
if (command && (event.key === "=" || event.key === "+")) {
event.preventDefault();
zoomAtPoint(0.08, Math.round(window.innerWidth / 2), Math.round(window.innerHeight / 2));
return;
}
if (command && event.key === "-") {
event.preventDefault();
zoomAtPoint(-0.08, Math.round(window.innerWidth / 2), Math.round(window.innerHeight / 2));
return;
}
if (command && event.key.toLowerCase() === "f") {
event.preventDefault();
setNodeSearchOpen(current => !current);
return;
}
if (command) return;
if (event.key === "Tab") {
event.preventDefault();
const screenX = Math.round(window.innerWidth / 2);
const screenY = Math.round(window.innerHeight / 2);
const point = toCanvasPoint(screenX, screenY);
setCanvasCommand({ canvasX: point.x, canvasY: point.y, screenX, screenY });
return;
}
if (event.key.toLowerCase() === "h") {
spacePanRef.current = true;
return;
}
if (event.key.toLowerCase() === "v") {
setPanMode(current => {
toast.info(current ? "已切换回选择工具" : "已切换为拖动画布工具");
return !current;
});
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key.toLowerCase() === "h") spacePanRef.current = false;
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
};
}, [smartEditNodeId, redoCanvas, undoCanvas, selectedNodeId, duplicateNode, runNode, fitDockTarget, toCanvasPoint, zoomAtPoint]);
const selectedPresetControlNode = isOfficialImageControlNode(selectedNode) ? selectedNode : null; const selectedPresetControlNode = isOfficialImageControlNode(selectedNode) ? selectedNode : null;
const selectedGenericImageNode = const selectedGenericImageNode =
selectedNode && selectedNode &&
@ -11103,6 +11253,17 @@ export function LiblibVideoCanvasReplica({
<ShortcutPanel onClose={() => setActivePanel(null)} /> <ShortcutPanel onClose={() => setActivePanel(null)} />
) : null} ) : null}
{nodeSearchOpen ? (
<CanvasNodeSearchPanel
nodes={nodes}
onClose={() => setNodeSearchOpen(false)}
onSelect={(nodeId) => {
setNodeSearchOpen(false);
locateCanvasNode(nodeId);
}}
/>
) : null}
{activePanel === "tutorial" ? ( {activePanel === "tutorial" ? (
<TutorialHelpPanel onClose={() => setActivePanel(null)} /> <TutorialHelpPanel onClose={() => setActivePanel(null)} />
) : null} ) : null}
@ -21837,12 +21998,78 @@ function CanvasAssetManagerPanel({
); );
} }
function CanvasNodeSearchPanel({
nodes,
onClose,
onSelect,
}: {
nodes: CloneNode[];
onClose: () => void;
onSelect: (nodeId: string) => void;
}) {
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { inputRef.current?.focus(); }, []);
const keyword = query.trim().toLowerCase();
const matched = keyword
? nodes.filter((node) => (node.title || "").toLowerCase().includes(keyword)).slice(0, 30)
: nodes.slice(0, 30);
return (
<section
className="fixed left-1/2 top-[96px] z-[300] w-[min(92vw,560px)] -translate-x-1/2 overflow-hidden rounded-2xl border border-white/10 bg-[#262626]/97 text-white shadow-[0_24px_80px_rgba(0,0,0,0.52)] backdrop-blur-lg"
data-canvas-overlay="true"
>
<div className="flex items-center gap-2 border-white/8 border-b px-4 py-3">
<Search className="h-4 w-4 shrink-0 text-white/45" />
<input
autoFocus
className="h-8 w-full bg-transparent text-[14px] text-white/90 outline-none placeholder:text-white/32"
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") { event.preventDefault(); onClose(); }
if (event.key === "Enter" && matched.length > 0) { event.preventDefault(); onSelect(matched[0].id); }
}}
placeholder="搜索画布节点名称(⌘F)"
value={query}
/>
<button
aria-label="关闭节点搜索"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-white/44 hover:bg-white/8"
onClick={onClose}
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[46vh] overflow-y-auto p-2">
{matched.length === 0 ? (
<p className="px-3 py-6 text-center text-[13px] text-white/40">没有匹配的节点</p>
) : (
matched.map((node) => (
<button
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-[13px] text-white/82 transition hover:bg-white/8"
key={node.id}
onClick={() => onSelect(node.id)}
type="button"
>
<span className="w-[64px] shrink-0 truncate text-[11px] text-white/38">
{node.inputType === "video" ? "视频" : node.inputType === "audio" ? "音频" : node.inputType === "text" ? "文本" : node.kind === "script" ? "脚本" : "图片"}
</span>
<span className="min-w-0 flex-1 truncate">{node.title}</span>
</button>
))
)}
</div>
</section>
);
}
function ShortcutPanel({ onClose }: { onClose: () => void }) { function ShortcutPanel({ onClose }: { onClose: () => void }) {
const groups = [ const groups = [
["连线", [["连线模式", "L"], ["退出/取消选择", "Escape"]]], ["创作", [["成组", "G"], ["解组", "⌘ + ⇧ + G"], ["连线模式", "L"], ["复制节点", "⌘ + D"], ["生成所选节点", "⌘ + Enter"], ["新建节点", "Tab"], ["节点搜索", "⌘ + F"]]],
["保存", [["保存画布", "Ctrl/⌘ + S"]]], ["缩放", [["放大", "⌘ + +"], ["缩小", "⌘ + -"], ["适应画布", "⌘ + 0"]]],
["移动画布", [["临时平移", "Space + 拖动"]]], ["移动画布", [["临时平移", "Space + 拖动"], ["拖动画布工具", "V"], ["抓手平移", "H"], ["整理画布", "Option + ⇧ + F"]]],
["删除", [["删除所选节点或连线", "Delete / Backspace"]]], ["其他", [["撤销", "⌘ + Z"], ["重做", "⌘ + ⇧ + Z"], ["保存画布", "Ctrl/⌘ + S"], ["删除所选", "Del / Backspace"], ["退出/取消选择", "Escape"]]],
] as const; ] as const;
return ( return (