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;
|
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 (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user