Files
story-edit-web/web/static/graph.js
bia 4a681dfe91 节点编辑器改用 Drawflow 拖拽连线版
- 中间画布换成 Drawflow:拖动节点摆位、从出口圆点拉线到目标=建跳转
- 出口端口动态映射 IR:线性next/choice选项/random分支/fight胜败
- 连线/拖动实时写回 IR;节点坐标持久化到 ir._layout(编译忽略)
- 右栏表单保留并双向联动;改跳转目标触发画布重渲
- 工具栏:自动整理、加后继;防误删(右栏输入时 Del 不删节点)
- 移除旧 tree.js
2026-06-08 17:27:45 +08:00

209 lines
10 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) {
const names = roleNames(ir), end = isEnding(node);
const sm = summary(ir, names, end ? Object.assign({ kind: "ending" }, node) : node);
let h = '<div class="nid">#' + 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);
const edges = [];
(ir.nodes || []).forEach(n => rawTargets(n).forEach(t => { if (t && nodes[t]) edges.push([n.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 perLayer = {}, pos = {};
ids.forEach(id => {
const l = layer[id]; perLayer[l] = perLayer[l] || 0;
// 横向分层(左→右),契合 Drawflow 端口左右朝向
pos[id] = { x: 40 + l * COL, y: 30 + perLayer[l] * ROW };
perLayer[l]++;
});
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/Backspace 不应删画布选中节点
document.addEventListener("keydown", e => {
if (e.key === "Delete" || e.key === "Backspace") {
const a = document.activeElement;
if (a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.isContentEditable)) e.stopPropagation();
}
}, true);
},
render(ir, selectedIrId) {
_ir = ir; building = true;
editor.clear();
dfId2ir = {}; ir2dfId = {};
const layout = ensureLayout(ir);
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 dfId = editor.addNode(node.id, 1, outN, pos.x, pos.y,
"irnode kind-" + (end ? "ending" : node.kind), { irId: node.id }, nodeInner(ir, node));
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 box = document.querySelector("#node-" + dfId + " .drawflow_content_node");
if (box) box.innerHTML = nodeInner(ir, node);
},
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); },
};
})();