Files
story-edit-web/web/static/graph.js
bia 188bfbbf7c 节点编辑三项增强
- 删除中间节点自动缝合:线性节点删除后把前驱接到其后继
- 撤销/重做:Ctrl+Z / Ctrl+Y(含连线、删除、移动、改字段,防抖快照)
- 开头节点(nodes[0]):绿色边框+「▶开头」标识,选中事件时自动定位到它
2026-06-08 18:39:04 +08:00

243 lines
12 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, 6) : ""), target: o.goto || "" }));
if (k === "random")
return (node.branches || []).map((b) => ({ label: "权" + (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 sm = summary(ir, names, end ? Object.assign({ kind: "ending" }, node) : node);
let h = '<div class="nid">' + (isStart ? '<span class="startflag">▶ 开头</span> ' : '') + '#' + esc(node.id) + '</div>'
+ '<div class="k">' + esc(sm[0]) + '</div>'
+ '<div class="t">' + esc(sm[1] || "") + '</div>';
if (end) {
const g = (node.grants && node.grants.length) ? node.grants.map(gr => grantStr(ir, names, gr)).join("") : "无奖励";
h += '<div class="rw">' + esc(g) + '</div>';
} else {
const outs = getOutlets(node);
if (outs.length > 1)
h += '<div class="outs">' + outs.map((o, i) => '<span>' + (i + 1) + "·" + esc(o.label) + '</span>').join("") + '</div>';
}
return h;
}
// ---------- 自动布局:最长路径分层 + 每层顺序铺开 ----------
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); },
};
})();