From 188bfbbf7c902211ab0d9aad3d4952d156fd5c36 Mon Sep 17 00:00:00 2001 From: bia Date: Mon, 8 Jun 2026 18:39:04 +0800 Subject: [PATCH] =?UTF-8?q?=E8=8A=82=E7=82=B9=E7=BC=96=E8=BE=91=E4=B8=89?= =?UTF-8?q?=E9=A1=B9=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除中间节点自动缝合:线性节点删除后把前驱接到其后继 - 撤销/重做:Ctrl+Z / Ctrl+Y(含连线、删除、移动、改字段,防抖快照) - 开头节点(nodes[0]):绿色边框+「▶开头」标识,选中事件时自动定位到它 --- web/static/app.js | 76 +++++++++++++++++++++++++++++++++++++++++--- web/static/form.js | 5 ++- web/static/graph.js | 37 +++++++++++++++------ web/static/style.css | 3 ++ 4 files changed, 104 insertions(+), 17 deletions(-) diff --git a/web/static/app.js b/web/static/app.js index 9671cb4..036d901 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -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(); } diff --git a/web/static/form.js b/web/static/form.js index 35e5893..a43c975 100644 --- a/web/static/form.js +++ b/web/static/form.js @@ -257,9 +257,8 @@ // 删除节点 host.appendChild(el("div", { class: "fld" }, [ el("button", { class: "mini", onclick: () => { - if (!confirm("删除节点 " + id + "?指向它的跳转需手动修复(校验会提示)。")) return; - ir.nodes = ir.nodes.filter(n => n.id !== id); - ctx.selectNode(null); ctx.onChange(true); + if (!confirm("删除节点 " + id + "?(若是直线中间节点,会自动把前后接上)")) return; + ctx.deleteNode(id); } }, ["删除此节点"]), ])); }; diff --git a/web/static/graph.js b/web/static/graph.js index c59b3fe..03f4bb9 100644 --- a/web/static/graph.js +++ b/web/static/graph.js @@ -61,10 +61,10 @@ } // ---------- 节点 HTML ---------- - function nodeInner(ir, node) { + function nodeInner(ir, node, isStart) { const names = roleNames(ir), end = isEnding(node); const sm = summary(ir, names, end ? Object.assign({ kind: "ending" }, node) : node); - let h = '
#' + esc(node.id) + '
' + let h = '
' + (isStart ? '▶ 开头 ' : '') + '#' + esc(node.id) + '
' + '
' + esc(sm[0]) + '
' + '
' + esc(sm[1] || "") + '
'; if (end) { @@ -173,11 +173,15 @@ editor.on("connectionCreated", info => { if (building) return; handleConnect(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); }); - // 防误删:焦点在右栏输入框时按 Del/Backspace 不应删画布选中节点 + // 键盘 Del:删画布选中节点 → 交给 app 做「缝合删除」;焦点在输入框时放行文本编辑;选中连线时交给 Drawflow document.addEventListener("keydown", e => { - if (e.key === "Delete" || e.key === "Backspace") { - const a = document.activeElement; - if (a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.isContentEditable)) e.stopPropagation(); + if (e.key !== "Delete" && e.key !== "Backspace") return; + const a = document.activeElement; + 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); }, @@ -186,13 +190,14 @@ editor.clear(); dfId2ir = {}; ir2dfId = {}; 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 })) .concat((ir.endings || []).map(e => ({ node: e, end: true }))); all.forEach(({ node, end }) => { const outN = end ? 0 : getOutlets(node).length; const pos = layout[node.id] || { x: 40, y: 30 }; - const dfId = editor.addNode(node.id, 1, outN, pos.x, pos.y, - "irnode kind-" + (end ? "ending" : node.kind), { irId: node.id }, nodeInner(ir, node)); + const cls = "irnode kind-" + (end ? "ending" : node.kind) + (node.id === startId ? " isstart" : ""); + 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; }); all.forEach(({ node, end }) => { @@ -210,8 +215,22 @@ _ir = ir; const dfId = ir2dfId[irId]; if (!dfId) 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"); - 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) { document.querySelectorAll("#drawflow .drawflow-node.selected").forEach(n => n.classList.remove("selected")); diff --git a/web/static/style.css b/web/static/style.css index 9027109..acd9b0e 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -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:hover { stroke:#e6c878; } +/* 开头节点标识 */ +#drawflow .drawflow-node.isstart { border-color:#7ad88a; } +.drawflow-node .startflag { color:#7ad88a; font-weight:bold; } /* ---- toast ---- */ #toast { position:fixed; left:50%; bottom:38px; transform:translateX(-50%) translateY(10px);