Files
story-edit-web/web/static/graph.js
bia 2fa4c34bb1 节点视觉重构 + 右栏选项折叠 + 撤销按钮/快捷键
- kind 名做成顶边框标牌(legend,边框在文字处断开)
- 去掉「开头」字(仅绿框)、去掉选择标题的项数
- 多出口节点每出口一行严格对齐右侧黄点
- 开头节点改为视口垂直居中(左侧)
- 选择节点右栏选项改为可折叠,点开编辑单个
- 撤销/重做按钮(不可用时灰)+ R自动整理 + Enter加后继
2026-06-08 19:17:38 +08:00

253 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 ----------
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 kind = end ? "ending" : node.kind;
const label = '<div class="nlabel">' + esc(KIND_CN[kind] || kind) + '</div>';
if (end) {
const g = (node.grants && node.grants.length) ? node.grants.map(gr => grantStr(ir, names, gr)).join("") : "无奖励";
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) {
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);
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>';
}
// ---------- 自动布局:最长路径分层 + 每层顺序铺开 ----------
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));
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);
},
// 把画布平移到开头节点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 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"));
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); },
};
})();