Files
story-edit-web/web/static/graph.js
bia 2b66374e90 多出口节点重设计 + 全局错误提示
- 选择/随机/战斗节点改为每出口一行、右对齐、行高25px对齐右侧端口
- 去掉重复的选项合并预览,顶部改为紧凑角标
- 未捕获错误/操作失败统一 toast 提示,便于同事发现反馈
2026-06-08 18:53:48 +08:00

249 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// graph.js — Drawflow 版分支图编辑器。
// 单一数据源 = IR拖线连接=改出口跳转,拖动节点=存坐标(ir._layout),结构变=整体重渲。
// 暴露 window.GraphUI = { init, render, updateLabel, select, autoLayout }。
(function () {
const ROW = 135, COL = 290; // 自动布局间距COL=层间(横向) ROW=同层(纵向)
let editor = null, cb = {}, _ir = null;
let dfId2ir = {}, ir2dfId = {}, building = false; // building: 重渲期间抑制事件
// ---------- 显示辅助(移植自旧 tree.js ----------
function roleNames(ir) {
const m = {};
(ir.roles || []).forEach(r => m[r.slot] = r.name + (r.archetype ? ("" + r.archetype + "") : ""));
return m;
}
function nameOf(ir, names, s) { return (s === "P1" ? "玩家" : "") || names[s] || s; }
function grantStr(ir, names, gr) {
if (gr.kind === "银两") return "银两 " + (gr.value > 0 ? "+" : "") + gr.value;
if (gr.kind === "道具") return "道具 " + gr.item + " ×" + gr.value;
if (gr.kind === "友好度") return nameOf(ir, names, gr.target) + " 友好度+" + gr.value;
if (gr.kind === "入门") return nameOf(ir, names, gr.target) + " 加入门派";
return JSON.stringify(gr);
}
function summary(ir, names, n) {
if (n.kind === "narration") return ["旁白", (n.text || "").slice(0, 22)];
if (n.kind === "dialogue") return ["对话 · " + nameOf(ir, names, n.speaker), (n.text || "").slice(0, 20)];
if (n.kind === "choice") return ["选择 (" + (n.options || []).length + "项)", (n.options || []).map(o => o.text).join(" / ").slice(0, 24)];
if (n.kind === "choice_once") return ["一次性选择", (n.options || []).map(o => o.text).join(" / ").slice(0, 24)];
if (n.kind === "random") return ["随机分支", (n.branches || []).length + " 路"];
if (n.kind === "fight") return ["战斗", "vs " + (n.camp2 || []).map(s => nameOf(ir, names, s)).join("、")];
if (n.kind === "move") return ["走位 · " + nameOf(ir, names, n.actor), "→ " + (n.to || "")];
if (n.kind === "anim") return ["动画 · " + nameOf(ir, names, n.actor), n.ani || ""];
if (n.kind === "reward") return ["奖励结算", ""];
if (n.kind === "out_ref") return ["引用子序列", "→ " + (n.ref || "")];
if (n.kind === "ending") return ["★ 结局", n.summary || ""];
return [n.kind, ""];
}
function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
// ---------- 出口模型output_(i+1) ←→ IR 出口 ----------
function isEnding(node) { return (_ir.endings || []).some(e => e.id === node.id); }
function getOutlets(node) {
const k = node.kind;
if (k === "choice" || k === "choice_once")
return (node.options || []).map((o, i) => ({ label: (i + 1) + ". " + (o.text ? o.text.slice(0, 14) : "(空选项)"), target: o.goto || "" }));
if (k === "random")
return (node.branches || []).map((b, i) => ({ label: (i + 1) + ". 权重 " + (b.weight != null ? b.weight : "?"), target: b.goto || "" }));
if (k === "fight")
return [{ label: "① 胜", target: node.win || "" }, { label: "② 败", target: node.lose || "" }];
if (isEnding(node)) return [];
return [{ label: "next", target: node.next || "" }]; // 线性
}
function setOutlet(node, idx, target) {
const k = node.kind;
if (k === "choice" || k === "choice_once") { if (node.options && node.options[idx]) node.options[idx].goto = target; }
else if (k === "random") { if (node.branches && node.branches[idx]) node.branches[idx].goto = target; }
else if (k === "fight") { if (idx === 0) node.win = target; else node.lose = target; }
else { node.next = target; }
}
function findNode(irId) {
return (_ir.nodes || []).find(n => n.id === irId) || (_ir.endings || []).find(e => e.id === irId);
}
// ---------- 节点 HTML ----------
function nodeInner(ir, node, isStart) {
const names = roleNames(ir), end = isEnding(node);
const startTag = isStart ? '<span class="startflag">▶ 开头</span> ' : '';
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>';
}
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>';
}
// 线性 / 单出口节点
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>';
}
// ---------- 自动布局:最长路径分层 + 每层顺序铺开 ----------
function rawTargets(n) {
const k = n.kind;
if (k === "choice" || k === "choice_once") return (n.options || []).map(o => o.goto);
if (k === "random") return (n.branches || []).map(b => b.goto);
if (k === "fight") return [n.win, n.lose];
return [n.next];
}
function autoCoords(ir) {
const nodes = {};
(ir.nodes || []).forEach(n => nodes[n.id] = n);
(ir.endings || []).forEach(e => nodes[e.id] = e);
const ids = Object.keys(nodes);
// 层级:最长路径(决定横向 x左→右
const edges = [];
ids.forEach(id => rawTargets(nodes[id]).forEach(t => { if (t && nodes[t]) edges.push([id, t]); }));
const layer = {}; ids.forEach(id => layer[id] = 0);
let changed = true, guard = 0;
while (changed && guard++ < 9999) {
changed = false;
edges.forEach(([u, v]) => { if (layer[v] < layer[u] + 1) { layer[v] = layer[u] + 1; changed = true; } });
}
// 有序子表(按出口顺序,去重)+ 入度求根
const childMap = {}, indeg = {}; ids.forEach(id => { childMap[id] = []; indeg[id] = 0; });
const seen = new Set();
ids.forEach(id => rawTargets(nodes[id]).forEach(t => {
if (t && nodes[t]) { const k = id + ">" + t; if (!seen.has(k)) { seen.add(k); childMap[id].push(t); indeg[t]++; } }
}));
let roots = ids.filter(id => indeg[id] === 0);
if (!roots.length && ids.length) roots = [ids[0]];
// 子树居中:按出口顺序递归分配纵向 y父节点居子范围中点 → 选项1上/2中/3下且对齐
const ypos = {}; let nextY = 0; const vis = new Set();
function assignY(id) {
if (!id || vis.has(id)) return; vis.add(id);
if (!childMap[id].length) { ypos[id] = nextY; nextY += ROW; return; }
childMap[id].forEach(assignY);
const placed = childMap[id].map(c => ypos[c]).filter(v => v !== undefined);
ypos[id] = placed.length ? (Math.min(...placed) + Math.max(...placed)) / 2 : (nextY += ROW, nextY - ROW);
}
roots.forEach(assignY);
ids.forEach(id => { if (ypos[id] === undefined) { ypos[id] = nextY; nextY += ROW; } });
const pos = {};
ids.forEach(id => { pos[id] = { x: 40 + layer[id] * COL, y: 30 + ypos[id] }; });
return pos;
}
function ensureLayout(ir) {
const layout = ir._layout || (ir._layout = {});
const all = (ir.nodes || []).map(n => n.id).concat((ir.endings || []).map(e => e.id));
if (all.some(id => !layout[id])) {
const auto = autoCoords(ir);
all.forEach(id => { if (!layout[id]) layout[id] = auto[id] || { x: 40, y: 30 }; });
}
return layout;
}
// ---------- 连线事件 ----------
function handleConnect(info) {
const srcIr = dfId2ir[info.output_id], dstIr = dfId2ir[info.input_id];
if (!srcIr || !dstIr) return;
const idx = parseInt(String(info.output_class).split("_")[1], 10) - 1;
const node = findNode(srcIr); if (!node) return;
const cur = getOutlets(node)[idx];
const old = cur && cur.target;
if (old && old !== dstIr && ir2dfId[old]) { // 单出口单目标:先拆旧线
building = true;
try { editor.removeSingleConnection(info.output_id, ir2dfId[old], info.output_class, "input_1"); } catch (e) {}
building = false;
}
setOutlet(node, idx, dstIr);
if (cb.onConnect) cb.onConnect();
}
function handleDisconnect(info) {
const srcIr = dfId2ir[info.output_id], dstIr = dfId2ir[info.input_id];
if (!srcIr) return;
const idx = parseInt(String(info.output_class).split("_")[1], 10) - 1;
const node = findNode(srcIr); if (!node) return;
const cur = getOutlets(node)[idx];
if (cur && cur.target === dstIr) { setOutlet(node, idx, ""); if (cb.onDisconnect) cb.onDisconnect(); }
}
// ---------- 公开接口 ----------
window.GraphUI = {
init(containerId, callbacks) {
cb = callbacks || {};
editor = new Drawflow(document.getElementById(containerId));
editor.start();
editor.on("nodeSelected", id => { if (building) return; const ir = dfId2ir[id]; if (ir && cb.onSelect) cb.onSelect(ir); });
editor.on("nodeMoved", id => {
if (building) return;
const n = editor.getNodeFromId(id), ir = dfId2ir[id];
if (ir && cb.onMove) cb.onMove(ir, Math.round(n.pos_x), Math.round(n.pos_y));
});
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删画布选中节点 → 交给 app 做「缝合删除」;焦点在输入框时放行文本编辑;选中连线时交给 Drawflow
document.addEventListener("keydown", e => {
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);
},
render(ir, selectedIrId) {
_ir = ir; building = true;
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 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 }) => {
if (end) return;
getOutlets(node).forEach((o, i) => {
if (o.target && ir2dfId[o.target]) {
try { editor.addConnection(ir2dfId[node.id], ir2dfId[o.target], "output_" + (i + 1), "input_1"); } catch (e) {}
}
});
});
building = false;
if (selectedIrId) this.select(selectedIrId);
},
updateLabel(ir, irId) {
_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);
},
// 把画布平移到开头节点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"));
const dfId = ir2dfId[irId];
if (dfId) { const el = document.getElementById("node-" + dfId); if (el) el.classList.add("selected"); }
},
autoLayout(ir) { ir._layout = autoCoords(ir); this.render(ir, null); },
};
})();