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;