节点编辑三项增强
- 删除中间节点自动缝合:线性节点删除后把前驱接到其后继 - 撤销/重做:Ctrl+Z / Ctrl+Y(含连线、删除、移动、改字段,防抖快照) - 开头节点(nodes[0]):绿色边框+「▶开头」标识,选中事件时自动定位到它
This commit is contained in:
@ -81,6 +81,8 @@
|
||||
$("graph-empty").style.display = "none";
|
||||
["btn-save", "btn-validate", "btn-playtest", "btn-confirm", "btn-discard", "btn-addnode", "btn-autolayout", "btn-addsucc"].forEach(b => $(b).disabled = false);
|
||||
renderAll(true);
|
||||
GraphUI.focusStart(App.ir); // 定位到开头节点
|
||||
snapReset(); // 初始化撤销栈
|
||||
renderList();
|
||||
updateDirty();
|
||||
}
|
||||
@ -92,8 +94,10 @@
|
||||
App.dirty = true; updateDirty();
|
||||
if (structural) { GraphUI.render(App.ir, App.selectedNode); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); }
|
||||
else { GraphUI.updateLabel(App.ir, App.selectedNode); }
|
||||
scheduleSnapshot();
|
||||
},
|
||||
selectNode: id => { App.selectedNode = id; },
|
||||
deleteNode: id => deleteNode(id),
|
||||
});
|
||||
|
||||
function selectNode(id) { App.selectedNode = id; GraphUI.select(id); FormUI.renderNode(App.ir, id, ctx()); }
|
||||
@ -106,7 +110,7 @@
|
||||
App.dirty = true; App.selectedNode = r.id;
|
||||
GraphUI.render(App.ir, r.id); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, r.id, ctx()); updateDirty();
|
||||
}
|
||||
// 纯数据删除节点/结局(供右栏删除按钮与画布 Del 键共用)
|
||||
// 纯数据删除节点/结局
|
||||
function removeFromIr(id) {
|
||||
App.ir.nodes = (App.ir.nodes || []).filter(n => n.id !== id);
|
||||
App.ir.endings = (App.ir.endings || []).filter(e => e.id !== id);
|
||||
@ -114,6 +118,32 @@
|
||||
if (App.selectedNode === id) App.selectedNode = null;
|
||||
App.dirty = true; updateDirty();
|
||||
}
|
||||
// 线性节点的唯一后继(多出口/结局返回 null,不缝合)
|
||||
function uniqueSuccessor(node) {
|
||||
if (!node) return null;
|
||||
const k = node.kind;
|
||||
if (k === "choice" || k === "choice_once" || k === "random" || k === "fight") return null;
|
||||
if ((App.ir.endings || []).some(e => e.id === node.id)) return null;
|
||||
return node.next || null;
|
||||
}
|
||||
// 把所有指向 from 的跳转改接到 to
|
||||
function retarget(from, to) {
|
||||
(App.ir.nodes || []).forEach(n => {
|
||||
if (n.next === from) n.next = to;
|
||||
if (n.win === from) n.win = to;
|
||||
if (n.lose === from) n.lose = to;
|
||||
(n.options || []).forEach(o => { if (o.goto === from) o.goto = to; if (o.skip && o.skip.node === from) o.skip.node = to; });
|
||||
(n.branches || []).forEach(b => { if (b.goto === from) b.goto = to; });
|
||||
});
|
||||
}
|
||||
// 删除节点:若是线性中间节点,删除后把前驱缝合到它的后继
|
||||
function deleteNode(id) {
|
||||
const node = (App.ir.nodes || []).find(n => n.id === id);
|
||||
const succ = uniqueSuccessor(node);
|
||||
if (succ && succ !== id) retarget(id, succ);
|
||||
removeFromIr(id);
|
||||
renderAll(); scheduleSnapshot();
|
||||
}
|
||||
function renderAll() { GraphUI.render(App.ir, App.selectedNode); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); }
|
||||
|
||||
function updateDirty() {
|
||||
@ -252,6 +282,42 @@
|
||||
toastTimer = setTimeout(() => el.classList.remove("show"), 2600);
|
||||
}
|
||||
|
||||
// ---------- 撤销 / 重做(Ctrl+Z / Ctrl+Y) ----------
|
||||
let undoStack = [], redoStack = [], snapTimer = null;
|
||||
function snapReset() { undoStack = App.ir ? [JSON.stringify(App.ir)] : []; redoStack = []; }
|
||||
function flushSnapshot() {
|
||||
if (!App.ir) return;
|
||||
const cur = JSON.stringify(App.ir);
|
||||
if (!undoStack.length || undoStack[undoStack.length - 1] !== cur) {
|
||||
undoStack.push(cur); if (undoStack.length > 60) undoStack.shift(); redoStack = [];
|
||||
}
|
||||
}
|
||||
function scheduleSnapshot() { clearTimeout(snapTimer); snapTimer = setTimeout(flushSnapshot, 450); }
|
||||
function restoreState(json) {
|
||||
App.ir = JSON.parse(json); App.selectedNode = null; App.dirty = true;
|
||||
renderAll(); updateDirty();
|
||||
}
|
||||
function undo() {
|
||||
flushSnapshot();
|
||||
if (undoStack.length < 2) return;
|
||||
redoStack.push(undoStack.pop());
|
||||
restoreState(undoStack[undoStack.length - 1]);
|
||||
toast("已撤销");
|
||||
}
|
||||
function redo() {
|
||||
if (!redoStack.length) return;
|
||||
const s = redoStack.pop(); undoStack.push(s); restoreState(s);
|
||||
toast("已重做");
|
||||
}
|
||||
document.addEventListener("keydown", e => {
|
||||
if (!(e.ctrlKey || e.metaKey) || !App.ir) return;
|
||||
const a = document.activeElement;
|
||||
if (a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA")) return; // 输入框内交给浏览器
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === "z" && !e.shiftKey) { e.preventDefault(); undo(); }
|
||||
else if (k === "y" || (k === "z" && e.shiftKey)) { e.preventDefault(); redo(); }
|
||||
});
|
||||
|
||||
// ---------- 画布工具栏 ----------
|
||||
$("btn-autolayout").onclick = () => {
|
||||
if (!App.ir) return;
|
||||
@ -267,10 +333,10 @@
|
||||
// ---------- 启动 ----------
|
||||
GraphUI.init("drawflow", {
|
||||
onSelect: id => selectNode(id),
|
||||
onMove: (id, x, y) => { if (!App.ir) return; (App.ir._layout = App.ir._layout || {})[id] = { x: x, y: y }; App.dirty = true; updateDirty(); },
|
||||
onConnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); },
|
||||
onDisconnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); },
|
||||
onRemove: id => { removeFromIr(id); FormUI.renderNode(App.ir, App.selectedNode, ctx()); },
|
||||
onMove: (id, x, y) => { if (!App.ir) return; (App.ir._layout = App.ir._layout || {})[id] = { x: x, y: y }; App.dirty = true; updateDirty(); scheduleSnapshot(); },
|
||||
onConnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); scheduleSnapshot(); },
|
||||
onDisconnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); scheduleSnapshot(); },
|
||||
onDeleteSelected: id => deleteNode(id),
|
||||
});
|
||||
(async function () {
|
||||
try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); }
|
||||
|
||||
Reference in New Issue
Block a user