diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 8898ed3ac..5b72801cd 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -9664,7 +9664,7 @@ export function LiblibVideoCanvasReplica({ if (smartEditNodeId) return; const target = event.target as HTMLElement | null; 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.metaKey && !event.ctrlKey && !event.altKey && event.key.toLowerCase() === "l") { event.preventDefault(); @@ -9979,6 +9979,156 @@ export function LiblibVideoCanvasReplica({ }, []); 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 selectedGenericImageNode = selectedNode && @@ -11103,6 +11253,17 @@ export function LiblibVideoCanvasReplica({ setActivePanel(null)} /> ) : null} + {nodeSearchOpen ? ( + setNodeSearchOpen(false)} + onSelect={(nodeId) => { + setNodeSearchOpen(false); + locateCanvasNode(nodeId); + }} + /> + ) : null} + {activePanel === "tutorial" ? ( setActivePanel(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(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 ( +
+
+ + 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} + /> + +
+
+ {matched.length === 0 ? ( +

没有匹配的节点

+ ) : ( + matched.map((node) => ( + + )) + )} +
+
+ ); +} + function ShortcutPanel({ onClose }: { onClose: () => void }) { const groups = [ - ["连线", [["连线模式", "L"], ["退出/取消选择", "Escape"]]], - ["保存", [["保存画布", "Ctrl/⌘ + S"]]], - ["移动画布", [["临时平移", "Space + 拖动"]]], - ["删除", [["删除所选节点或连线", "Delete / Backspace"]]], + ["创作", [["成组", "G"], ["解组", "⌘ + ⇧ + G"], ["连线模式", "L"], ["复制节点", "⌘ + D"], ["生成所选节点", "⌘ + Enter"], ["新建节点", "Tab"], ["节点搜索", "⌘ + F"]]], + ["缩放", [["放大", "⌘ + +"], ["缩小", "⌘ + -"], ["适应画布", "⌘ + 0"]]], + ["移动画布", [["临时平移", "Space + 拖动"], ["拖动画布工具", "V"], ["抓手平移", "H"], ["整理画布", "Option + ⇧ + F"]]], + ["其他", [["撤销", "⌘ + Z"], ["重做", "⌘ + ⇧ + Z"], ["保存画布", "Ctrl/⌘ + S"], ["删除所选", "Del / Backspace"], ["退出/取消选择", "Escape"]]], ] as const; return (