节点视觉重构 + 右栏选项折叠 + 撤销按钮/快捷键

- kind 名做成顶边框标牌(legend,边框在文字处断开)
- 去掉「开头」字(仅绿框)、去掉选择标题的项数
- 多出口节点每出口一行严格对齐右侧黄点
- 开头节点改为视口垂直居中(左侧)
- 选择节点右栏选项改为可折叠,点开编辑单个
- 撤销/重做按钮(不可用时灰)+ R自动整理 + Enter加后继
This commit is contained in:
bia
2026-06-08 19:17:38 +08:00
parent 2b66374e90
commit 2fa4c34bb1
5 changed files with 92 additions and 68 deletions

View File

@ -289,14 +289,19 @@
try { toast("⚠ 操作失败:" + m); } catch (_) {} try { toast("⚠ 操作失败:" + m); } catch (_) {}
}); });
// ---------- 撤销 / 重做Ctrl+Z / Ctrl+Y ---------- // ---------- 撤销 / 重做 ----------
let undoStack = [], redoStack = [], snapTimer = null; let undoStack = [], redoStack = [], snapTimer = null;
function snapReset() { undoStack = App.ir ? [JSON.stringify(App.ir)] : []; redoStack = []; } function updateUndoButtons() {
const u = $("btn-undo"), r = $("btn-redo");
if (u) u.disabled = undoStack.length < 2;
if (r) r.disabled = redoStack.length === 0;
}
function snapReset() { undoStack = App.ir ? [JSON.stringify(App.ir)] : []; redoStack = []; updateUndoButtons(); }
function flushSnapshot() { function flushSnapshot() {
if (!App.ir) return; if (!App.ir) return;
const cur = JSON.stringify(App.ir); const cur = JSON.stringify(App.ir);
if (!undoStack.length || undoStack[undoStack.length - 1] !== cur) { if (!undoStack.length || undoStack[undoStack.length - 1] !== cur) {
undoStack.push(cur); if (undoStack.length > 60) undoStack.shift(); redoStack = []; undoStack.push(cur); if (undoStack.length > 60) undoStack.shift(); redoStack = []; updateUndoButtons();
} }
} }
function scheduleSnapshot() { clearTimeout(snapTimer); snapTimer = setTimeout(flushSnapshot, 450); } function scheduleSnapshot() { clearTimeout(snapTimer); snapTimer = setTimeout(flushSnapshot, 450); }
@ -309,29 +314,42 @@
if (undoStack.length < 2) return; if (undoStack.length < 2) return;
redoStack.push(undoStack.pop()); redoStack.push(undoStack.pop());
restoreState(undoStack[undoStack.length - 1]); restoreState(undoStack[undoStack.length - 1]);
toast("已撤销"); updateUndoButtons(); toast("已撤销");
} }
function redo() { function redo() {
if (!redoStack.length) return; if (!redoStack.length) return;
const s = redoStack.pop(); undoStack.push(s); restoreState(s); const s = redoStack.pop(); undoStack.push(s); restoreState(s);
toast("已重做"); updateUndoButtons(); toast("已重做");
} }
$("btn-undo").onclick = undo;
$("btn-redo").onclick = redo;
// ---------- 快捷键Ctrl+Z/Y 撤销重做、R 自动整理、Enter 加后继 ----------
document.addEventListener("keydown", e => { document.addEventListener("keydown", e => {
if (!(e.ctrlKey || e.metaKey) || !App.ir) return; if (!App.ir) return;
const a = document.activeElement; const a = document.activeElement;
if (a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA")) return; // 输入框内交给浏览器 const inInput = a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA");
if (e.ctrlKey || e.metaKey) {
if (inInput) return; // 输入框内的 Ctrl+Z 交给浏览器
const k = e.key.toLowerCase(); const k = e.key.toLowerCase();
if (k === "z" && !e.shiftKey) { e.preventDefault(); undo(); } if (k === "z" && !e.shiftKey) { e.preventDefault(); undo(); }
else if (k === "y" || (k === "z" && e.shiftKey)) { e.preventDefault(); redo(); } else if (k === "y" || (k === "z" && e.shiftKey)) { e.preventDefault(); redo(); }
return;
}
if (e.altKey || inInput) return;
if (e.key.toLowerCase() === "r") { e.preventDefault(); doAutoLayout(); }
else if (e.key === "Enter" && App.selectedNode) { e.preventDefault(); addSuccessor(App.selectedNode); }
}); });
// ---------- 画布工具栏 ---------- // ---------- 画布工具栏 ----------
$("btn-autolayout").onclick = () => { function doAutoLayout() {
if (!App.ir) return; if (!App.ir) return;
App.ir._layout = null; // 清坐标 → render 时按自动布局重排 App.ir._layout = null; // 清坐标 → render 时按自动布局重排
GraphUI.render(App.ir, App.selectedNode); GraphUI.render(App.ir, App.selectedNode);
App.dirty = true; updateDirty(); GraphUI.focusStart(App.ir);
}; App.dirty = true; updateDirty(); scheduleSnapshot(); toast("已自动整理");
}
$("btn-autolayout").onclick = doAutoLayout;
$("btn-addsucc").onclick = () => { $("btn-addsucc").onclick = () => {
if (!App.ir || !App.selectedNode) { alert("先在画布上点选一个节点,再点「加后继」。"); return; } if (!App.ir || !App.selectedNode) { alert("先在画布上点选一个节点,再点「加后继」。"); return; }
addSuccessor(App.selectedNode); addSuccessor(App.selectedNode);

View File

@ -182,11 +182,9 @@
el("button", { class: "mini", onclick: () => { (node.options = node.options || []).push({ text: "新选项", goto: "" }); ctx.onChange(true); } }, ["+选项"]), el("button", { class: "mini", onclick: () => { (node.options = node.options || []).push({ text: "新选项", goto: "" }); ctx.onChange(true); } }, ["+选项"]),
])); ]));
(node.options || []).forEach((o, i) => { (node.options || []).forEach((o, i) => {
const ob = el("div", { class: "subbox" }); const det = el("details", { class: "optdet" });
ob.appendChild(el("div", { class: "hd" }, [ det.appendChild(el("summary", {}, ["选项 " + (i + 1) + "" + (o.text || "(空)")]));
el("span", {}, ["选项 " + (i + 1)]), const ob = el("div", { class: "optbody" });
el("button", { class: "mini", onclick: () => { node.options.splice(i, 1); ctx.onChange(true); } }, ["删"]),
]));
ob.appendChild(field("文本", txt(o.text, v => { o.text = v; ctx.onChange(false); }))); ob.appendChild(field("文本", txt(o.text, v => { o.text = v; ctx.onChange(false); })));
ob.appendChild(field("跳转 goto", sel(o.goto, tgt, v => { o.goto = v; ctx.onChange(true); }))); ob.appendChild(field("跳转 goto", sel(o.goto, tgt, v => { o.goto = v; ctx.onChange(true); })));
ob.appendChild(condEditor(ir, ctx, o.condition, (c, valOnly) => { if (c) o.condition = c; else delete o.condition; ctx.onChange(!valOnly); })); ob.appendChild(condEditor(ir, ctx, o.condition, (c, valOnly) => { if (c) o.condition = c; else delete o.condition; ctx.onChange(!valOnly); }));
@ -202,7 +200,9 @@
skBox.appendChild(grantsEditor(ir, ctx, (o.skip.reward || {}).grants, (gr, valOnly) => { o.skip.reward = { grants: gr }; ctx.onChange(!valOnly); })); skBox.appendChild(grantsEditor(ir, ctx, (o.skip.reward || {}).grants, (gr, valOnly) => { o.skip.reward = { grants: gr }; ctx.onChange(!valOnly); }));
} }
ob.appendChild(skBox); ob.appendChild(skBox);
box.appendChild(ob); ob.appendChild(el("div", { class: "fld" }, [el("button", { class: "mini", onclick: () => { node.options.splice(i, 1); ctx.onChange(true); } }, ["删除此选项"])]));
det.appendChild(ob);
box.appendChild(det);
}); });
host.appendChild(box); host.appendChild(box);
} else if (node.kind === "random") { } else if (node.kind === "random") {

View File

@ -61,27 +61,26 @@
} }
// ---------- 节点 HTML ---------- // ---------- 节点 HTML ----------
function nodeInner(ir, node, isStart) { const KIND_CN = { narration: "旁白", dialogue: "对话", choice: "选择", choice_once: "一次性选择", random: "随机", fight: "战斗", move: "走位", anim: "动画", reward: "奖励", out_ref: "引用", ending: "结局" };
function nodeInner(ir, node) {
const names = roleNames(ir), end = isEnding(node); const names = roleNames(ir), end = isEnding(node);
const startTag = isStart ? '<span class="startflag">▶ 开头</span> ' : ''; const kind = end ? "ending" : node.kind;
const label = '<div class="nlabel">' + esc(KIND_CN[kind] || kind) + '</div>';
if (end) { if (end) {
const sm = summary(ir, names, Object.assign({ kind: "ending" }, node));
const g = (node.grants && node.grants.length) ? node.grants.map(gr => grantStr(ir, names, gr)).join("") : "无奖励"; const g = (node.grants && node.grants.length) ? node.grants.map(gr => grantStr(ir, names, gr)).join("") : "无奖励";
return '<div class="nid">' + startTag + '#' + esc(node.id) + '</div>' return label + '<div class="nbody"><div class="t">' + esc(node.summary || "") + '</div><div class="rw">' + esc(g) + '</div></div>';
+ '<div class="k">' + esc(sm[0]) + '</div><div class="t">' + esc(sm[1] || "") + '</div>'
+ '<div class="rw">' + esc(g) + '</div>';
} }
const outs = getOutlets(node); const outs = getOutlets(node);
// 多出口节点(选择/随机/战斗):顶部角标 + 每出口一行(右对齐,行高匹配端口间距 → 与黄点平齐 // 多出口(选择/随机/战斗):每出口一行(右对齐),是唯一流内容 → 垂直居中 → 与居中的黄点逐行平齐
if (outs.length > 1) { if (outs.length > 1) {
const head = summary(ir, names, node)[0]; return label + '<div class="ch-opts">' + outs.map(o => '<div class="ch-opt" title="' + esc(o.label) + '">' + esc(o.label) + '</div>').join("") + '</div>';
return '<div class="ch-tag">' + (isStart ? '<span class="startflag">▶</span> ' : '') + '<span class="nid">#' + esc(node.id) + '</span> ' + esc(head) + '</div>'
+ '<div class="ch-opts">' + outs.map(o => '<div class="ch-opt" title="' + esc(o.label) + '">' + esc(o.label) + '</div>').join("") + '</div>';
} }
// 线性 / 单出口节点 // 线性 / 单出口:角色(可选)+ 文本
const sm = summary(ir, names, node); const sm = summary(ir, names, node);
return '<div class="nid">' + startTag + '#' + esc(node.id) + '</div>' const actor = node.speaker || node.actor;
+ '<div class="k">' + esc(sm[0]) + '</div><div class="t">' + esc(sm[1] || "") + '</div>'; let body = actor ? ('<div class="who">' + esc(nameOf(ir, names, actor)) + '</div>') : "";
body += '<div class="t">' + esc(sm[1] || "") + '</div>';
return label + '<div class="nbody">' + body + '</div>';
} }
// ---------- 自动布局:最长路径分层 + 每层顺序铺开 ---------- // ---------- 自动布局:最长路径分层 + 每层顺序铺开 ----------
@ -203,7 +202,7 @@
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 cls = "irnode kind-" + (end ? "ending" : node.kind) + (node.id === startId ? " isstart" : ""); 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)); const dfId = editor.addNode(node.id, 1, outN, pos.x, pos.y, cls, { irId: node.id }, nodeInner(ir, node));
dfId2ir[dfId] = node.id; ir2dfId[node.id] = dfId; dfId2ir[dfId] = node.id; ir2dfId[node.id] = dfId;
}); });
all.forEach(({ node, end }) => { all.forEach(({ node, end }) => {
@ -221,22 +220,27 @@
_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, irId === startId); if (box) box.innerHTML = nodeInner(ir, node);
}, },
// 把画布平移到开头节点ir.nodes[0]附近 // 把画布平移到开头节点ir.nodes[0]:水平靠左、垂直居中
focusStart(ir) { focusStart(ir) {
const startId = (ir.nodes && ir.nodes[0]) ? ir.nodes[0].id : null; const startId = (ir.nodes && ir.nodes[0]) ? ir.nodes[0].id : null;
if (!startId) return; if (!startId) return;
const apply = () => {
const p = (ir._layout || {})[startId]; if (!p) return; const p = (ir._layout || {})[startId]; if (!p) return;
const z = editor.zoom || 1; const z = editor.zoom || 1;
const tx = 70 - p.x * z, ty = 90 - p.y * z; const host = document.getElementById("drawflow");
const H = (host && host.clientHeight) || 500;
const tx = 70 - p.x * z, ty = H / 2 - p.y * z - 45; // 左侧 + 垂直居中(-45≈半节点高
try { try {
editor.canvas_x = tx; editor.canvas_y = ty; editor.canvas_x = tx; editor.canvas_y = ty;
const pc = editor.precanvas || document.querySelector("#drawflow .drawflow"); const pc = editor.precanvas || document.querySelector("#drawflow .drawflow");
if (pc) pc.style.transform = "translate(" + tx + "px, " + ty + "px) scale(" + z + ")"; if (pc) pc.style.transform = "translate(" + tx + "px, " + ty + "px) scale(" + z + ")";
} catch (e) {} } catch (e) {}
};
apply();
if (typeof requestAnimationFrame === "function") requestAnimationFrame(apply);
}, },
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

@ -55,9 +55,12 @@
<!--分支图Drawflow 可拖拽连线) --> <!--分支图Drawflow 可拖拽连线) -->
<main id="graph-pane"> <main id="graph-pane">
<div id="graph-tools"> <div id="graph-tools">
<button id="btn-autolayout" class="mini" disabled>自动整理</button> <button id="btn-undo" class="mini" disabled title="撤销 (Ctrl+Z)">↶ 撤销</button>
<button id="btn-addsucc" class="mini" disabled>加后继</button> <button id="btn-redo" class="mini" disabled title="重做 (Ctrl+Y)">↷ 重做</button>
<span class="tip">拖出口圆点→目标节点 = 连跳转 · 拖动摆位 · 选中按 Del 删除</span> <span class="gsep"></span>
<button id="btn-autolayout" class="mini" disabled title="自动整理布局 (R)">自动整理</button>
<button id="btn-addsucc" class="mini" disabled title="给选中节点加后继 (Enter)">加后继</button>
<span class="tip">拖出口圆点→目标=连跳转 · 拖动摆位 · Del 删除 · R 整理 · Enter 加后继 · Ctrl+Z/Y 撤销重做</span>
</div> </div>
<div id="drawflow"></div> <div id="drawflow"></div>
<div id="graph-empty" class="empty-center">从左侧选择一个事件</div> <div id="graph-empty" class="empty-center">从左侧选择一个事件</div>

View File

@ -43,6 +43,7 @@ button.mini { padding:2px 8px; font-size:12px; }
#graph-tools { flex:none; display:flex; align-items:center; gap:8px; padding:6px 10px; #graph-tools { flex:none; display:flex; align-items:center; gap:8px; padding:6px 10px;
background:#19150f; border-bottom:1px solid #3a322a; } background:#19150f; border-bottom:1px solid #3a322a; }
#graph-tools .tip { font-size:11.5px; color:#7a7264; } #graph-tools .tip { font-size:11.5px; color:#7a7264; }
#graph-tools .gsep { width:1px; height:16px; background:#3a322a; margin:0 3px; }
#drawflow { position:relative; flex:1; min-height:0; background:#161310; #drawflow { position:relative; flex:1; min-height:0; background:#161310;
background-image:radial-gradient(#2a2419 1.1px, transparent 1.1px); background-image:radial-gradient(#2a2419 1.1px, transparent 1.1px);
background-size:22px 22px; } background-size:22px 22px; }
@ -91,21 +92,24 @@ button.mini { padding:2px 8px; font-size:12px; }
#drawflow .drawflow-node:hover { border-color:#e6c878; } #drawflow .drawflow-node:hover { border-color:#e6c878; }
#drawflow .drawflow-node.selected { border-color:#e6c878; box-shadow:0 0 0 2px rgba(230,200,120,.45); } #drawflow .drawflow-node.selected { border-color:#e6c878; box-shadow:0 0 0 2px rgba(230,200,120,.45); }
#drawflow .drawflow_content_node { width:100%; } #drawflow .drawflow_content_node { width:100%; }
.drawflow-node .nid { font-size:10px; color:#6a6256; } /* 顶边框标牌legend边框在文字处断开 */
.drawflow-node .k { font-size:11px; color:#b89a5a; font-weight:bold; } #drawflow .drawflow_content_node { position:relative; }
.drawflow-node .t { font-size:12.5px; margin-top:2px; line-height:1.35; color:#ddd3c2; word-break:break-all; } .drawflow-node .nlabel { position:absolute; top:-18px; left:50%; transform:translateX(-50%);
.drawflow-node .outs { margin-top:5px; display:flex; flex-direction:column; gap:2px; } background:#161310; padding:0 7px; font-size:11.5px; font-weight:bold; white-space:nowrap; color:#b89a5a; }
.drawflow-node .outs span { font-size:10.5px; color:#9ec0f0; } .drawflow-node .nbody { padding-top:3px; }
.drawflow-node .who { font-size:11px; color:#b89a5a; margin-bottom:2px; }
.drawflow-node .t { font-size:12.5px; line-height:1.35; color:#ddd3c2; word-break:break-all; }
.drawflow-node .rw { font-size:11px; color:#c9a86a; margin-top:4px; .drawflow-node .rw { font-size:11px; color:#c9a86a; margin-top:4px;
border-top:1px dashed #6a5630; padding-top:4px; } border-top:1px dashed #6a5630; padding-top:4px; }
#drawflow .kind-ending { background:#3a2a17; border-color:#e0a850; } #drawflow .kind-ending { background:#3a2a17; border-color:#e0a850; }
#drawflow .kind-ending .k { color:#f2c463; } #drawflow .kind-ending .nlabel { color:#f2c463; }
#drawflow .kind-fight { border-color:#7a4a4a; background:#2a1c1c; } #drawflow .kind-fight { border-color:#7a4a4a; background:#2a1c1c; }
#drawflow .kind-fight .k { color:#d87878; } #drawflow .kind-fight .nlabel { color:#d87878; }
#drawflow .kind-out_ref { border-style:dashed; border-color:#7a7ad8; background:#1d1d2a; } #drawflow .kind-out_ref { border-style:dashed; border-color:#7a7ad8; background:#1d1d2a; }
#drawflow .kind-out_ref .k { color:#9e9ef0; } #drawflow .kind-out_ref .nlabel { color:#9e9ef0; }
#drawflow .kind-choice, #drawflow .kind-choice_once { background:#1d2840; border-color:#3a527a; } #drawflow .kind-choice, #drawflow .kind-choice_once { background:#1d2840; border-color:#3a527a; }
#drawflow .kind-choice .k, #drawflow .kind-choice_once .k { color:#9ec0f0; } #drawflow .kind-choice .nlabel, #drawflow .kind-choice_once .nlabel { color:#9ec0f0; }
#drawflow .kind-random .nlabel { color:#c0a0e0; }
/* 端口圆点 */ /* 端口圆点 */
#drawflow .drawflow-node .input, #drawflow .drawflow-node .output { #drawflow .drawflow-node .input, #drawflow .drawflow-node .output {
background:#e6c878; border:2px solid #161310; width:15px; height:15px; } background:#e6c878; border:2px solid #161310; width:15px; height:15px; }
@ -117,18 +121,8 @@ button.mini { padding:2px 8px; font-size:12px; }
#drawflow .drawflow-node.isstart { border-color:#7ad88a; } #drawflow .drawflow-node.isstart { border-color:#7ad88a; }
.drawflow-node .startflag { color:#7ad88a; font-weight:bold; } .drawflow-node .startflag { color:#7ad88a; font-weight:bold; }
/* 多出口节点:每出口一行与右侧端口间距25px、垂直居中平齐 */ /* 多出口节点:每出口一行唯一流内容→垂直居中→与居中黄点逐行平齐top:2px 对齐端口偏移 */
#drawflow .kind-choice .drawflow_content_node, .ch-opts { position:relative; top:2px; }
#drawflow .kind-choice_once .drawflow_content_node,
#drawflow .kind-random .drawflow_content_node,
#drawflow .kind-fight .drawflow_content_node { position:relative; }
.ch-tag { position:absolute; top:-14px; left:0; right:0; font-size:11px; font-weight:bold;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ch-tag .nid { color:#6a6256; font-weight:normal; }
#drawflow .kind-choice .ch-tag, #drawflow .kind-choice_once .ch-tag { color:#9ec0f0; }
#drawflow .kind-fight .ch-tag { color:#d87878; }
#drawflow .kind-random .ch-tag { color:#c0a0e0; }
.ch-opts { margin-top:2px; } /* 微调对齐端口 top:2px */
.ch-opt { height:25px; line-height:25px; text-align:right; font-size:12px; color:#dfe7f2; .ch-opt { height:25px; line-height:25px; text-align:right; font-size:12px; color:#dfe7f2;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:3px; } overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:3px; }
@ -159,6 +153,11 @@ button.mini { padding:2px 8px; font-size:12px; }
padding:3px 8px; border-radius:11px; cursor:pointer; } padding:3px 8px; border-radius:11px; cursor:pointer; }
.tag-pick input { margin-right:4px; } .tag-pick input { margin-right:4px; }
.node-id { color:#7a7264; font-size:11px; } .node-id { color:#7a7264; font-size:11px; }
.optdet { border:1px solid #3a322a; border-radius:6px; margin:6px 0; background:#19150f; }
.optdet > summary { cursor:pointer; padding:7px 10px; font-size:12.5px; color:#cdbf9a; user-select:none;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.optdet[open] > summary { border-bottom:1px solid #3a322a; color:#e6c878; margin-bottom:4px; }
.optbody { padding:0 10px 8px; }
/* ---- overlays / modals ---- */ /* ---- overlays / modals ---- */
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:100; .overlay { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:100;