节点编辑三项增强

- 删除中间节点自动缝合:线性节点删除后把前驱接到其后继
- 撤销/重做:Ctrl+Z / Ctrl+Y(含连线、删除、移动、改字段,防抖快照)
- 开头节点(nodes[0]):绿色边框+「▶开头」标识,选中事件时自动定位到它
This commit is contained in:
bia
2026-06-08 18:39:04 +08:00
parent 2de308c1e1
commit 188bfbbf7c
4 changed files with 104 additions and 17 deletions

View File

@ -81,6 +81,8 @@
$("graph-empty").style.display = "none"; $("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); ["btn-save", "btn-validate", "btn-playtest", "btn-confirm", "btn-discard", "btn-addnode", "btn-autolayout", "btn-addsucc"].forEach(b => $(b).disabled = false);
renderAll(true); renderAll(true);
GraphUI.focusStart(App.ir); // 定位到开头节点
snapReset(); // 初始化撤销栈
renderList(); renderList();
updateDirty(); updateDirty();
} }
@ -92,8 +94,10 @@
App.dirty = true; updateDirty(); App.dirty = true; updateDirty();
if (structural) { GraphUI.render(App.ir, App.selectedNode); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); } 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); } else { GraphUI.updateLabel(App.ir, App.selectedNode); }
scheduleSnapshot();
}, },
selectNode: id => { App.selectedNode = id; }, selectNode: id => { App.selectedNode = id; },
deleteNode: id => deleteNode(id),
}); });
function selectNode(id) { App.selectedNode = id; GraphUI.select(id); FormUI.renderNode(App.ir, id, ctx()); } 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; 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(); GraphUI.render(App.ir, r.id); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, r.id, ctx()); updateDirty();
} }
// 纯数据删除节点/结局(供右栏删除按钮与画布 Del 键共用) // 纯数据删除节点/结局
function removeFromIr(id) { function removeFromIr(id) {
App.ir.nodes = (App.ir.nodes || []).filter(n => n.id !== id); App.ir.nodes = (App.ir.nodes || []).filter(n => n.id !== id);
App.ir.endings = (App.ir.endings || []).filter(e => e.id !== id); App.ir.endings = (App.ir.endings || []).filter(e => e.id !== id);
@ -114,6 +118,32 @@
if (App.selectedNode === id) App.selectedNode = null; if (App.selectedNode === id) App.selectedNode = null;
App.dirty = true; updateDirty(); 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 renderAll() { GraphUI.render(App.ir, App.selectedNode); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); }
function updateDirty() { function updateDirty() {
@ -252,6 +282,42 @@
toastTimer = setTimeout(() => el.classList.remove("show"), 2600); 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 = () => { $("btn-autolayout").onclick = () => {
if (!App.ir) return; if (!App.ir) return;
@ -267,10 +333,10 @@
// ---------- 启动 ---------- // ---------- 启动 ----------
GraphUI.init("drawflow", { GraphUI.init("drawflow", {
onSelect: id => selectNode(id), 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(); }, 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()); }, 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()); }, onDisconnect: () => { App.dirty = true; updateDirty(); FormUI.renderNode(App.ir, App.selectedNode, ctx()); scheduleSnapshot(); },
onRemove: id => { removeFromIr(id); FormUI.renderNode(App.ir, App.selectedNode, ctx()); }, onDeleteSelected: id => deleteNode(id),
}); });
(async function () { (async function () {
try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); } try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); }

View File

@ -257,9 +257,8 @@
// 删除节点 // 删除节点
host.appendChild(el("div", { class: "fld" }, [ host.appendChild(el("div", { class: "fld" }, [
el("button", { class: "mini", onclick: () => { el("button", { class: "mini", onclick: () => {
if (!confirm("删除节点 " + id + "指向它的跳转需手动修复(校验会提示)。")) return; if (!confirm("删除节点 " + id + "(若是直线中间节点,会自动把前后接上)")) return;
ir.nodes = ir.nodes.filter(n => n.id !== id); ctx.deleteNode(id);
ctx.selectNode(null); ctx.onChange(true);
} }, ["删除此节点"]), } }, ["删除此节点"]),
])); ]));
}; };

View File

@ -61,10 +61,10 @@
} }
// ---------- 节点 HTML ---------- // ---------- 节点 HTML ----------
function nodeInner(ir, node) { function nodeInner(ir, node, isStart) {
const names = roleNames(ir), end = isEnding(node); const names = roleNames(ir), end = isEnding(node);
const sm = summary(ir, names, end ? Object.assign({ kind: "ending" }, node) : node); const sm = summary(ir, names, end ? Object.assign({ kind: "ending" }, node) : node);
let h = '<div class="nid">#' + esc(node.id) + '</div>' let h = '<div class="nid">' + (isStart ? '<span class="startflag">▶ 开头</span> ' : '') + '#' + esc(node.id) + '</div>'
+ '<div class="k">' + esc(sm[0]) + '</div>' + '<div class="k">' + esc(sm[0]) + '</div>'
+ '<div class="t">' + esc(sm[1] || "") + '</div>'; + '<div class="t">' + esc(sm[1] || "") + '</div>';
if (end) { if (end) {
@ -173,11 +173,15 @@
editor.on("connectionCreated", info => { if (building) return; handleConnect(info); }); editor.on("connectionCreated", info => { if (building) return; handleConnect(info); });
editor.on("connectionRemoved", info => { if (building) return; handleDisconnect(info); }); editor.on("connectionRemoved", info => { if (building) return; handleDisconnect(info); });
editor.on("nodeRemoved", id => { if (building) return; const ir = dfId2ir[id]; if (ir && cb.onRemove) cb.onRemove(ir); }); editor.on("nodeRemoved", id => { if (building) return; const ir = dfId2ir[id]; if (ir && cb.onRemove) cb.onRemove(ir); });
// 防误删:焦点在右栏输入框时按 Del/Backspace 不应删画布选中节点 // 键盘 Del删画布选中节点 → 交给 app 做「缝合删除」;焦点在输入框时放行文本编辑;选中连线时交给 Drawflow
document.addEventListener("keydown", e => { document.addEventListener("keydown", e => {
if (e.key === "Delete" || e.key === "Backspace") { if (e.key !== "Delete" && e.key !== "Backspace") return;
const a = document.activeElement; const a = document.activeElement;
if (a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.isContentEditable)) e.stopPropagation(); if (a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.isContentEditable)) return;
const sel = document.querySelector("#drawflow .drawflow-node.selected");
if (sel) {
const irId = dfId2ir[sel.id.replace("node-", "")];
if (irId && cb.onDeleteSelected) { e.stopPropagation(); e.preventDefault(); cb.onDeleteSelected(irId); }
} }
}, true); }, true);
}, },
@ -186,13 +190,14 @@
editor.clear(); editor.clear();
dfId2ir = {}; ir2dfId = {}; dfId2ir = {}; ir2dfId = {};
const layout = ensureLayout(ir); const layout = ensureLayout(ir);
const startId = (ir.nodes && ir.nodes[0]) ? ir.nodes[0].id : null;
const all = (ir.nodes || []).map(n => ({ node: n, end: false })) const all = (ir.nodes || []).map(n => ({ node: n, end: false }))
.concat((ir.endings || []).map(e => ({ node: e, end: true }))); .concat((ir.endings || []).map(e => ({ node: e, end: true })));
all.forEach(({ node, end }) => { all.forEach(({ node, end }) => {
const outN = end ? 0 : getOutlets(node).length; const outN = end ? 0 : getOutlets(node).length;
const pos = layout[node.id] || { x: 40, y: 30 }; const pos = layout[node.id] || { x: 40, y: 30 };
const dfId = editor.addNode(node.id, 1, outN, pos.x, pos.y, const cls = "irnode kind-" + (end ? "ending" : node.kind) + (node.id === startId ? " isstart" : "");
"irnode kind-" + (end ? "ending" : node.kind), { irId: node.id }, nodeInner(ir, node)); const dfId = editor.addNode(node.id, 1, outN, pos.x, pos.y, cls, { irId: node.id }, nodeInner(ir, node, node.id === startId));
dfId2ir[dfId] = node.id; ir2dfId[node.id] = dfId; dfId2ir[dfId] = node.id; ir2dfId[node.id] = dfId;
}); });
all.forEach(({ node, end }) => { all.forEach(({ node, end }) => {
@ -210,8 +215,22 @@
_ir = ir; _ir = ir;
const dfId = ir2dfId[irId]; if (!dfId) return; const dfId = ir2dfId[irId]; if (!dfId) return;
const node = findNode(irId); if (!node) return; const node = findNode(irId); if (!node) return;
const startId = (ir.nodes && ir.nodes[0]) ? ir.nodes[0].id : null;
const box = document.querySelector("#node-" + dfId + " .drawflow_content_node"); const box = document.querySelector("#node-" + dfId + " .drawflow_content_node");
if (box) box.innerHTML = nodeInner(ir, node); if (box) box.innerHTML = nodeInner(ir, node, irId === startId);
},
// 把画布平移到开头节点ir.nodes[0])附近
focusStart(ir) {
const startId = (ir.nodes && ir.nodes[0]) ? ir.nodes[0].id : null;
if (!startId) return;
const p = (ir._layout || {})[startId]; if (!p) return;
const z = editor.zoom || 1;
const tx = 70 - p.x * z, ty = 90 - p.y * z;
try {
editor.canvas_x = tx; editor.canvas_y = ty;
const pc = editor.precanvas || document.querySelector("#drawflow .drawflow");
if (pc) pc.style.transform = "translate(" + tx + "px, " + ty + "px) scale(" + z + ")";
} catch (e) {}
}, },
select(irId) { select(irId) {
document.querySelectorAll("#drawflow .drawflow-node.selected").forEach(n => n.classList.remove("selected")); document.querySelectorAll("#drawflow .drawflow-node.selected").forEach(n => n.classList.remove("selected"));

View File

@ -113,6 +113,9 @@ button.mini { padding:2px 8px; font-size:12px; }
/* 连线 */ /* 连线 */
#drawflow .connection .main-path { stroke:#7a96c8; stroke-width:2.4px; } #drawflow .connection .main-path { stroke:#7a96c8; stroke-width:2.4px; }
#drawflow .connection .main-path:hover { stroke:#e6c878; } #drawflow .connection .main-path:hover { stroke:#e6c878; }
/* 开头节点标识 */
#drawflow .drawflow-node.isstart { border-color:#7ad88a; }
.drawflow-node .startflag { color:#7ad88a; font-weight:bold; }
/* ---- toast ---- */ /* ---- toast ---- */
#toast { position:fixed; left:50%; bottom:38px; transform:translateX(-50%) translateY(10px); #toast { position:fixed; left:50%; bottom:38px; transform:translateX(-50%) translateY(10px);