diff --git a/web/static/app.js b/web/static/app.js
index 772d02b..b17227a 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -289,14 +289,19 @@
try { toast("⚠ 操作失败:" + m); } catch (_) {}
});
- // ---------- 撤销 / 重做(Ctrl+Z / Ctrl+Y) ----------
+ // ---------- 撤销 / 重做 ----------
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() {
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 = [];
+ undoStack.push(cur); if (undoStack.length > 60) undoStack.shift(); redoStack = []; updateUndoButtons();
}
}
function scheduleSnapshot() { clearTimeout(snapTimer); snapTimer = setTimeout(flushSnapshot, 450); }
@@ -309,29 +314,42 @@
if (undoStack.length < 2) return;
redoStack.push(undoStack.pop());
restoreState(undoStack[undoStack.length - 1]);
- toast("已撤销");
+ updateUndoButtons(); toast("已撤销");
}
function redo() {
if (!redoStack.length) return;
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 => {
- if (!(e.ctrlKey || e.metaKey) || !App.ir) return;
+ if (!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(); }
+ const inInput = a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA");
+ if (e.ctrlKey || e.metaKey) {
+ if (inInput) return; // 输入框内的 Ctrl+Z 交给浏览器
+ const k = e.key.toLowerCase();
+ if (k === "z" && !e.shiftKey) { e.preventDefault(); undo(); }
+ 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;
App.ir._layout = null; // 清坐标 → render 时按自动布局重排
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 = () => {
if (!App.ir || !App.selectedNode) { alert("先在画布上点选一个节点,再点「加后继」。"); return; }
addSuccessor(App.selectedNode);
diff --git a/web/static/form.js b/web/static/form.js
index a43c975..e807b91 100644
--- a/web/static/form.js
+++ b/web/static/form.js
@@ -182,11 +182,9 @@
el("button", { class: "mini", onclick: () => { (node.options = node.options || []).push({ text: "新选项", goto: "" }); ctx.onChange(true); } }, ["+选项"]),
]));
(node.options || []).forEach((o, i) => {
- const ob = el("div", { class: "subbox" });
- ob.appendChild(el("div", { class: "hd" }, [
- el("span", {}, ["选项 " + (i + 1)]),
- el("button", { class: "mini", onclick: () => { node.options.splice(i, 1); ctx.onChange(true); } }, ["删"]),
- ]));
+ const det = el("details", { class: "optdet" });
+ det.appendChild(el("summary", {}, ["选项 " + (i + 1) + ":" + (o.text || "(空)")]));
+ const ob = el("div", { class: "optbody" });
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(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); }));
}
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);
} else if (node.kind === "random") {
diff --git a/web/static/graph.js b/web/static/graph.js
index c0d3014..ef69fa4 100644
--- a/web/static/graph.js
+++ b/web/static/graph.js
@@ -61,27 +61,26 @@
}
// ---------- 节点 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 startTag = isStart ? '▶ 开头 ' : '';
+ const kind = end ? "ending" : node.kind;
+ const label = '
' + esc(KIND_CN[kind] || kind) + '
';
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(",") : "无奖励";
- return '' + startTag + '#' + esc(node.id) + '
'
- + '' + esc(sm[0]) + '
' + esc(sm[1] || "") + '
'
- + '' + esc(g) + '
';
+ return label + '' + esc(node.summary || "") + '
' + esc(g) + '
';
}
const outs = getOutlets(node);
- // 多出口节点(选择/随机/战斗):顶部角标 + 每出口一行(右对齐,行高匹配端口间距 → 与黄点平齐)
+ // 多出口(选择/随机/战斗):每出口一行(右对齐),是唯一流内容 → 垂直居中 → 与居中的黄点逐行平齐
if (outs.length > 1) {
- const head = summary(ir, names, node)[0];
- return '' + (isStart ? '▶ ' : '') + '#' + esc(node.id) + ' ' + esc(head) + '
'
- + '' + outs.map(o => '
' + esc(o.label) + '
').join("") + '
';
+ return label + '' + outs.map(o => '
' + esc(o.label) + '
').join("") + '
';
}
- // 线性 / 单出口节点
+ // 线性 / 单出口:角色(可选)+ 文本
const sm = summary(ir, names, node);
- return '' + startTag + '#' + esc(node.id) + '
'
- + '' + esc(sm[0]) + '
' + esc(sm[1] || "") + '
';
+ const actor = node.speaker || node.actor;
+ let body = actor ? ('' + esc(nameOf(ir, names, actor)) + '
') : "";
+ body += '' + esc(sm[1] || "") + '
';
+ return label + '' + body + '
';
}
// ---------- 自动布局:最长路径分层 + 每层顺序铺开 ----------
@@ -203,7 +202,7 @@
const outN = end ? 0 : getOutlets(node).length;
const pos = layout[node.id] || { x: 40, y: 30 };
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;
});
all.forEach(({ node, end }) => {
@@ -221,22 +220,27 @@
_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, irId === startId);
+ if (box) box.innerHTML = nodeInner(ir, node);
},
- // 把画布平移到开头节点(ir.nodes[0])附近
+ // 把画布平移到开头节点(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) {}
+ const apply = () => {
+ const p = (ir._layout || {})[startId]; if (!p) return;
+ const z = editor.zoom || 1;
+ 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 {
+ 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) {}
+ };
+ apply();
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(apply);
},
select(irId) {
document.querySelectorAll("#drawflow .drawflow-node.selected").forEach(n => n.classList.remove("selected"));
diff --git a/web/static/index.html b/web/static/index.html
index ae3e044..132eb64 100644
--- a/web/static/index.html
+++ b/web/static/index.html
@@ -55,9 +55,12 @@
-
-
- 拖出口圆点→目标节点 = 连跳转 · 拖动摆位 · 选中按 Del 删除
+
+
+
+
+
+ 拖出口圆点→目标=连跳转 · 拖动摆位 · Del 删除 · R 整理 · Enter 加后继 · Ctrl+Z/Y 撤销重做
从左侧选择一个事件
diff --git a/web/static/style.css b/web/static/style.css
index cbbf08a..a9661ac 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -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;
background:#19150f; border-bottom:1px solid #3a322a; }
#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;
background-image:radial-gradient(#2a2419 1.1px, transparent 1.1px);
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.selected { border-color:#e6c878; box-shadow:0 0 0 2px rgba(230,200,120,.45); }
#drawflow .drawflow_content_node { width:100%; }
-.drawflow-node .nid { font-size:10px; color:#6a6256; }
-.drawflow-node .k { font-size:11px; color:#b89a5a; font-weight:bold; }
-.drawflow-node .t { font-size:12.5px; margin-top:2px; line-height:1.35; color:#ddd3c2; word-break:break-all; }
-.drawflow-node .outs { margin-top:5px; display:flex; flex-direction:column; gap:2px; }
-.drawflow-node .outs span { font-size:10.5px; color:#9ec0f0; }
+/* 顶边框标牌(legend:边框在文字处断开) */
+#drawflow .drawflow_content_node { position:relative; }
+.drawflow-node .nlabel { position:absolute; top:-18px; left:50%; transform:translateX(-50%);
+ background:#161310; padding:0 7px; font-size:11.5px; font-weight:bold; white-space:nowrap; color:#b89a5a; }
+.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;
border-top:1px dashed #6a5630; padding-top:4px; }
#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 .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 .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 .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 {
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-node .startflag { color:#7ad88a; font-weight:bold; }
-/* 多出口节点:每出口一行,与右侧端口(间距25px、垂直居中)平齐 */
-#drawflow .kind-choice .drawflow_content_node,
-#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 */
+/* 多出口节点:每出口一行(唯一流内容→垂直居中→与居中黄点逐行平齐);top:2px 对齐端口偏移 */
+.ch-opts { position:relative; top:2px; }
.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; }
@@ -159,6 +153,11 @@ button.mini { padding:2px 8px; font-size:12px; }
padding:3px 8px; border-radius:11px; cursor:pointer; }
.tag-pick input { margin-right:4px; }
.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 ---- */
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:100;