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:
parent
94b3ac9cb3
commit
8b2955eb0b
@ -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({
|
||||
<ShortcutPanel onClose={() => setActivePanel(null)} />
|
||||
) : null}
|
||||
|
||||
{nodeSearchOpen ? (
|
||||
<CanvasNodeSearchPanel
|
||||
nodes={nodes}
|
||||
onClose={() => setNodeSearchOpen(false)}
|
||||
onSelect={(nodeId) => {
|
||||
setNodeSearchOpen(false);
|
||||
locateCanvasNode(nodeId);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activePanel === "tutorial" ? (
|
||||
<TutorialHelpPanel onClose={() => 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<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 }) {
|
||||
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 (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user