节点视觉重构 + 右栏选项折叠 + 撤销按钮/快捷键
- kind 名做成顶边框标牌(legend,边框在文字处断开) - 去掉「开头」字(仅绿框)、去掉选择标题的项数 - 多出口节点每出口一行严格对齐右侧黄点 - 开头节点改为视口垂直居中(左侧) - 选择节点右栏选项改为可折叠,点开编辑单个 - 撤销/重做按钮(不可用时灰)+ R自动整理 + Enter加后继
This commit is contained in:
@ -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 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);
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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 ? '<span class="startflag">▶ 开头</span> ' : '';
|
||||
const kind = end ? "ending" : node.kind;
|
||||
const label = '<div class="nlabel">' + esc(KIND_CN[kind] || kind) + '</div>';
|
||||
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 '<div class="nid">' + startTag + '#' + esc(node.id) + '</div>'
|
||||
+ '<div class="k">' + esc(sm[0]) + '</div><div class="t">' + esc(sm[1] || "") + '</div>'
|
||||
+ '<div class="rw">' + esc(g) + '</div>';
|
||||
return label + '<div class="nbody"><div class="t">' + esc(node.summary || "") + '</div><div class="rw">' + esc(g) + '</div></div>';
|
||||
}
|
||||
const outs = getOutlets(node);
|
||||
// 多出口节点(选择/随机/战斗):顶部角标 + 每出口一行(右对齐,行高匹配端口间距 → 与黄点平齐)
|
||||
// 多出口(选择/随机/战斗):每出口一行(右对齐),是唯一流内容 → 垂直居中 → 与居中的黄点逐行平齐
|
||||
if (outs.length > 1) {
|
||||
const head = summary(ir, names, node)[0];
|
||||
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>';
|
||||
return label + '<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);
|
||||
return '<div class="nid">' + startTag + '#' + esc(node.id) + '</div>'
|
||||
+ '<div class="k">' + esc(sm[0]) + '</div><div class="t">' + esc(sm[1] || "") + '</div>';
|
||||
const actor = node.speaker || node.actor;
|
||||
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 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 apply = () => {
|
||||
const p = (ir._layout || {})[startId]; if (!p) return;
|
||||
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 {
|
||||
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"));
|
||||
|
||||
@ -55,9 +55,12 @@
|
||||
<!-- 中:分支图(Drawflow 可拖拽连线) -->
|
||||
<main id="graph-pane">
|
||||
<div id="graph-tools">
|
||||
<button id="btn-autolayout" class="mini" disabled>自动整理</button>
|
||||
<button id="btn-addsucc" class="mini" disabled>加后继</button>
|
||||
<span class="tip">拖出口圆点→目标节点 = 连跳转 · 拖动摆位 · 选中按 Del 删除</span>
|
||||
<button id="btn-undo" class="mini" disabled title="撤销 (Ctrl+Z)">↶ 撤销</button>
|
||||
<button id="btn-redo" class="mini" disabled title="重做 (Ctrl+Y)">↷ 重做</button>
|
||||
<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 id="drawflow"></div>
|
||||
<div id="graph-empty" class="empty-center">从左侧选择一个事件</div>
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user