@xiaobuyu/nodesign 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server/api/assets/helpers.js +20 -0
- package/server/api/assets/notes.js +50 -0
- package/server/api/assets.js +6 -11
- package/server/api/board.js +78 -1
- package/server/engine/agent/events.js +1 -0
- package/server/engine/agent/hooks/user-prompt-submit.js +43 -0
- package/server/engine/agent/prompts/nodesign-prelude.md +53 -1
- package/server/engine/agent/session-loop.js +11 -0
- package/server/engine/mcp/index.js +12 -0
- package/server/engine/mcp/tools/arrange-on-board.js +4 -4
- package/server/engine/mcp/tools/create-on-board.js +9 -12
- package/server/engine/mcp/tools/edit-sketch.js +167 -0
- package/server/engine/mcp/tools/look-at-board.js +124 -0
- package/server/engine/mcp/tools/read-board.js +121 -34
- package/server/engine/mcp/tools/read-user-view.js +59 -0
- package/server/engine/mcp/tools/relate-on-board.js +5 -3
- package/server/engine/mcp/tools/sketch-on-board.js +337 -0
- package/server/engine/mcp/tools/write-on-board.js +130 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +7 -0
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +7 -0
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +7 -0
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +7 -0
- package/server/engine/runs/board-tasklist.js +130 -0
- package/server/index.js +3 -0
- package/server/lib/binding-types.js +12 -0
- package/server/lib/board-graph-export.js +191 -0
- package/server/lib/board-groups.js +167 -0
- package/server/lib/board-hero.js +76 -0
- package/server/lib/board-kind-sizes.js +23 -0
- package/server/lib/board-relations.js +1 -1
- package/server/lib/chalk.js +121 -0
- package/server/lib/sketch-layout.js +277 -0
- package/server/projects/board-limits.js +12 -0
- package/server/projects/board-sanitize.js +252 -0
- package/server/projects/board-store.js +72 -205
- package/server/projects/ui-config.js +6 -0
- package/server/projects/viewpoint-store.js +78 -0
- package/server/ws/broker.js +3 -0
- package/web/dist/assets/{BrowserWindow-BYCgizHw.js → BrowserWindow-P_vIdh-G.js} +2 -2
- package/web/dist/assets/{DeckWindow-pNydEM4L.js → DeckWindow-BpLv5082.js} +3 -3
- package/web/dist/assets/{DocxWindow-PPgzh3Gq.js → DocxWindow-DSAU1-tD.js} +4 -4
- package/web/dist/assets/MermaidBlock-DR1GikCv.js +313 -0
- package/web/dist/assets/SiteWindow-92uBuRRR.js +18 -0
- package/web/dist/assets/abnfDiagram-VCTEODGH-uA8Esix4.js +1 -0
- package/web/dist/assets/arc-D_khH_xN.js +1 -0
- package/web/dist/assets/architectureDiagram-5GKGNRK7-DydJHqOt.js +36 -0
- package/web/dist/assets/blockDiagram-NRAW4CY4-B7wBbKiW.js +129 -0
- package/web/dist/assets/c4Diagram-UCG6FXSJ-6mrH0x7Y.js +38 -0
- package/web/dist/assets/channel-DGkccMO1.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-Cuikho5Q.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-3-KZ5AUz.js +15 -0
- package/web/dist/assets/chunk-F27PBJKO-Bkwq2gGU.js +1 -0
- package/web/dist/assets/chunk-G27WJ6UU-BLyQzdGE.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-B7HxxQds.js +1 -0
- package/web/dist/assets/chunk-LCL6LL3I-CtXrnUwF.js +206 -0
- package/web/dist/assets/chunk-POPQ4Y6H-dxddZlXt.js +1 -0
- package/web/dist/assets/chunk-SVP7TREG-DpkmrgHn.js +88 -0
- package/web/dist/assets/chunk-XXDRQBXY-BwZ7RbpQ.js +1 -0
- package/web/dist/assets/classDiagram-DTDB5LWJ-BGscw3OD.js +1 -0
- package/web/dist/assets/classDiagram-v2-JRS7N3AN-BGscw3OD.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-DGFOsMUt.js +1 -0
- package/web/dist/assets/cynefin-OW5HDTMX-DUwn3ehS.js +178 -0
- package/web/dist/assets/cynefinDiagram-5FMLGOSQ-DlLAOZ9x.js +62 -0
- package/web/dist/assets/cytoscape.esm-BjAbmLdb.js +331 -0
- package/web/dist/assets/dagre-3AP2YEHR-CyourLfz.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-S7CK7UJ4-BTMbx2np.js +30 -0
- package/web/dist/assets/diagram-UQ7AKVKN-UK94_1-P.js +41 -0
- package/web/dist/assets/diagram-VSXAHHWV-C7zS-v9v.js +3 -0
- package/web/dist/assets/diagram-VX7I27RA-DKJRa6CK.js +24 -0
- package/web/dist/assets/diagram-Z3DM3KII-kUbAfGAp.js +24 -0
- package/web/dist/assets/ebnfDiagram-PWID7BFC-CVW16_vb.js +1 -0
- package/web/dist/assets/erDiagram-SSCWMZ5O-BRQMFQ5y.js +99 -0
- package/web/dist/assets/flowDiagram-A5DVABFB-S34LpLtK.js +168 -0
- package/web/dist/assets/ganttDiagram-EL5Y4UJY-BFiqk1-G.js +292 -0
- package/web/dist/assets/gitGraphDiagram-WWUBYQGX-LZsrdyyv.js +106 -0
- package/web/dist/assets/index-GAamh1p4.js +2152 -0
- package/web/dist/assets/infoDiagram-RXCK75RN-0DvN6KIp.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-5VMMS53U-CaY3A-Bx.js +70 -0
- package/web/dist/assets/journeyDiagram-EYS64GPL-BBeiaCrj.js +139 -0
- package/web/dist/assets/kanban-definition-3QL26DDD-ux42DRXQ.js +89 -0
- package/web/dist/assets/layout-CnXiF_NB.js +1 -0
- package/web/dist/assets/linear-BMW0u6G6.js +1 -0
- package/web/dist/assets/mindmap-definition-FBJOCRG2-hUxSpdKn.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-XKGWAZYB-aCaQPH0w.js +1 -0
- package/web/dist/assets/{pending-edit-apply-C0I13CbD.js → pending-edit-apply-AcdOQ9Ij.js} +6 -6
- package/web/dist/assets/pieDiagram-E7YTZNPT-CmOEZ8zg.js +39 -0
- package/web/dist/assets/quadrantDiagram-AXDQQJYC-VW6fAhsV.js +7 -0
- package/web/dist/assets/railroadDiagram-O6MQD6OU-CKJ7Ba5K.js +1 -0
- package/web/dist/assets/requirementDiagram-EFPCY7ZU-pNGkq9Vw.js +84 -0
- package/web/dist/assets/{rotate-cw-B1UQ8Hld.js → rotate-cw-KAfChbGV.js} +1 -1
- package/web/dist/assets/sankeyDiagram-P5KCCOFB-BIMmDTfZ.js +40 -0
- package/web/dist/assets/sequenceDiagram-WJ2MYXX4-BBW7wzKY.js +162 -0
- package/web/dist/assets/sizeCapture-X5ZJPWSS-DdkXmLYR.js +1 -0
- package/web/dist/assets/{square-dashed-mouse-pointer-8lrgqmfv.js → square-dashed-mouse-pointer-CDUlhUP7.js} +1 -1
- package/web/dist/assets/stateDiagram-HBIQ2CUA-W313Uz16.js +1 -0
- package/web/dist/assets/stateDiagram-v2-4QOOHH4V-DDhJBZzf.js +1 -0
- package/web/dist/assets/swimlanes-XN3QIQJK-C29TL26K.js +1 -0
- package/web/dist/assets/swimlanesDiagram-VK2B7HYN-BfN-Xdmu.js +8 -0
- package/web/dist/assets/timeline-definition-24CTP7MA-CF0JMObX.js +120 -0
- package/web/dist/assets/vennDiagram-4TSXK5OY-DrDFsaH3.js +34 -0
- package/web/dist/assets/wardleyDiagram-VM6X3IG4-CVrnRIS_.js +78 -0
- package/web/dist/assets/xychartDiagram-S5SC5T6Z-BvkN1zsT.js +7 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/SiteWindow-DM0LQPCO.js +0 -18
- package/web/dist/assets/index-BHTBJlpY.js +0 -2125
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/lib/sketch-layout.js —— 黑板草图的排布与笔迹(2026-08-23)
|
|
3
|
+
*
|
|
4
|
+
* sketch_on_board 一次落一整张图:agent 说的是**结构**(节点、形状、线、模板、
|
|
5
|
+
* 局部网格坐标),这里把它翻译成世界像素和 SVG 路径。两层拆开是这件事的核心:
|
|
6
|
+
* - 宏观落位(图落在无限画布哪里)归服务端:锚点旁 / 内容底下
|
|
7
|
+
* - 微观排布(图里面怎么摆)归 agent:模板 + 离散网格(1 格 = UNIT px)
|
|
8
|
+
* 模型对连续坐标的空间推理弱、对小网格和"谁在谁右边"强,所以给的是网格不是像素。
|
|
9
|
+
*
|
|
10
|
+
* 形状(rect/ellipse/line/arrow/underline/circle/path)落成普通 scribble 对象:
|
|
11
|
+
* 路径在这里生成,带确定性的手绘抖动(种子 = 形状 id),前端一个字不用改。
|
|
12
|
+
* 路径只用 M/L/Q(board-sanitize 的字符白名单),数字保留一位小数。
|
|
13
|
+
*
|
|
14
|
+
* 全是纯函数;尺寸估算与 create-on-board 同一套公式。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const UNIT = 24; // 1 网格 = 24 世界像素
|
|
18
|
+
/**
|
|
19
|
+
* 可读性规范(2026-08-23,用户定):黑板上的字要在 80%~100% 缩放下清晰可读。
|
|
20
|
+
* 手写/md 正文 16px 世界像素在 0.8 倍下是 12.8 屏幕像素 —— 这是底线,所以节点
|
|
21
|
+
* 字号不低于 md;一张图的尺寸要能在 0.8 倍下整张进一个普通视口(1400×900 屏
|
|
22
|
+
* ≈ 1750×1125 世界像素)。超过 SKETCH_MAX 直接拒:一张图说一件事,大了就拆。
|
|
23
|
+
*/
|
|
24
|
+
export const SKETCH_FIT = { w: 1700, h: 1100 }; // 推荐上限(0.8 倍一屏)
|
|
25
|
+
export const SKETCH_MAX = { w: 2600, h: 1700 }; // 硬上限(再大就拆成两张)
|
|
26
|
+
export const GAP = 16; // 模板排布的节点间距
|
|
27
|
+
const SIZE_PX = { sm: 13, md: 16, lg: 22, xl: 30 };
|
|
28
|
+
|
|
29
|
+
/** 手写字身位估算(抄 create-on-board / 前端 handleCreateText 同款) */
|
|
30
|
+
export function textBox(t, sizeKey, { md = false, wUnits = null } = {}) {
|
|
31
|
+
const px = SIZE_PX[sizeKey] || 16;
|
|
32
|
+
if (md) {
|
|
33
|
+
const raw = String(t);
|
|
34
|
+
const lines = raw.split('\n');
|
|
35
|
+
// 字宽按 em 估:CJK/全角 1em,其余 0.62em("12 个汉字 200px 装不下"就是这么来的)
|
|
36
|
+
const em = (l) => [...l].reduce((n, c) => n + (/[\u3000-\u9fff\uff00-\uffef]/.test(c) ? 1 : 0.62), 0);
|
|
37
|
+
const longest = Math.max(8, ...lines.map(em));
|
|
38
|
+
const w = wUnits ? wUnits * UNIT : Math.min(440, Math.max(200, Math.round(longest * px) + 24));
|
|
39
|
+
const colsEm = Math.max(8, (w - 12) / px);
|
|
40
|
+
// 渲染侧(MdInk)单换行 = 真换行,所以按源码行数估;空行 = 段落间距(GAP.sm≈8);
|
|
41
|
+
// 行高 1.6、上下 padding 4+4。前端还会把真实高度回写(useMeasuredSize),这里只求落位时别差太多
|
|
42
|
+
let n = 0; let paras = 0;
|
|
43
|
+
for (const l of lines) {
|
|
44
|
+
if (!l.trim()) { paras += 1; continue; }
|
|
45
|
+
n += Math.max(1, Math.ceil(em(l.replace(/[*_`#>]/g, '')) / colsEm));
|
|
46
|
+
}
|
|
47
|
+
// mermaid 盒子:横向(LR/RL)矮、纵向按语句数长;都只是估,真高度由渲染定
|
|
48
|
+
const mm = raw.match(/```mermaid\n([\s\S]*?)```/);
|
|
49
|
+
if (mm) {
|
|
50
|
+
const body = mm[1].split('\n').filter(l => l.trim()).length;
|
|
51
|
+
n += /\b(LR|RL)\b/.test(mm[1]) ? 3 : Math.max(4, body * 1.6);
|
|
52
|
+
}
|
|
53
|
+
return { w, h: Math.round(n * px * 1.6) + 8 + paras * 8 };
|
|
54
|
+
}
|
|
55
|
+
const cols = wUnits ? Math.max(4, Math.floor((wUnits * UNIT) / (px * 1.05))) : Math.min(26, Math.max(6, t.length));
|
|
56
|
+
const lines = Math.ceil(t.length / cols) + (t.match(/\n/g)?.length || 0);
|
|
57
|
+
return { w: wUnits ? wUnits * UNIT : Math.round(cols * px * 1.05) + 12, h: Math.round(lines * px * 1.6) + 10 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* ── 确定性随机(与前端 board-bindings 同款 FNV + mulberry32)── */
|
|
61
|
+
function hashSeed(s) {
|
|
62
|
+
let h = 0x811c9dc5;
|
|
63
|
+
for (let i = 0; i < s.length; i += 1) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
64
|
+
return h >>> 0;
|
|
65
|
+
}
|
|
66
|
+
function rng(seed) {
|
|
67
|
+
let a = seed >>> 0;
|
|
68
|
+
return () => {
|
|
69
|
+
a = (a + 0x6D2B79F5) >>> 0;
|
|
70
|
+
let t = a;
|
|
71
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
72
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
73
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const f1 = (n) => (Math.round(n * 10) / 10).toString();
|
|
77
|
+
|
|
78
|
+
/** 手绘一段:两点之间微抖的折线(带一点弧),返回 "L x y ..." 片段(不含起点 M) */
|
|
79
|
+
function wobblySeg(rand, a, b, amp = 1.2) {
|
|
80
|
+
const dx = b.x - a.x; const dy = b.y - a.y;
|
|
81
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
82
|
+
const nx = -dy / len; const ny = dx / len;
|
|
83
|
+
const segs = Math.max(2, Math.min(12, Math.round(len / 26)));
|
|
84
|
+
const bow = (rand() * 2 - 1) * Math.min(len * 0.03, 3);
|
|
85
|
+
let out = '';
|
|
86
|
+
for (let i = 1; i <= segs; i += 1) {
|
|
87
|
+
const t = i / segs;
|
|
88
|
+
const w = i === segs ? 0 : amp * (rand() * 2 - 1) + Math.sin(Math.PI * t) * bow;
|
|
89
|
+
out += ` L ${f1(a.x + dx * t + nx * w)} ${f1(a.y + dy * t + ny * w)}`;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 形状 → { d, w, h }(路径相对形状包围盒左上角,pad 留 6px 给抖动和线宽)。
|
|
96
|
+
* 输入尺寸是像素(调用方已把网格换成像素)。
|
|
97
|
+
*/
|
|
98
|
+
export function shapePath(kind, { w = 0, h = 0, to = null, d = null } = {}, seed = 'shape') {
|
|
99
|
+
const rand = rng(hashSeed(seed));
|
|
100
|
+
const P = 6;
|
|
101
|
+
if (kind === 'path') {
|
|
102
|
+
// 自由路径:agent 给的局部像素坐标,只做字符白名单与平移(调用方算 bbox)
|
|
103
|
+
return { d: String(d || ''), w, h };
|
|
104
|
+
}
|
|
105
|
+
if (kind === 'line' || kind === 'arrow' || kind === 'underline') {
|
|
106
|
+
// 从 (0,0) 到 (to.x,to.y) 的一笔;underline 是水平的 line 别名
|
|
107
|
+
const tx = to?.x ?? w; const ty = kind === 'underline' ? 0 : (to?.y ?? 0);
|
|
108
|
+
const minX = Math.min(0, tx); const minY = Math.min(0, ty);
|
|
109
|
+
const a = { x: P - minX, y: P - minY }; const b = { x: tx + P - minX, y: ty + P - minY };
|
|
110
|
+
let path = `M ${f1(a.x)} ${f1(a.y)}${wobblySeg(rand, a, b)}`;
|
|
111
|
+
if (kind === 'arrow') {
|
|
112
|
+
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1;
|
|
113
|
+
const ux = (b.x - a.x) / len; const uy = (b.y - a.y) / len;
|
|
114
|
+
const L = Math.min(14, Math.max(8, len * 0.12));
|
|
115
|
+
const wing = (s) => ({ x: b.x - ux * L + (-uy) * s * L * 0.55, y: b.y - uy * L + ux * s * L * 0.55 });
|
|
116
|
+
const w1 = wing(1); const w2 = wing(-1);
|
|
117
|
+
path += ` M ${f1(w1.x)} ${f1(w1.y)}${wobblySeg(rand, w1, b, 0.6)} M ${f1(w2.x)} ${f1(w2.y)}${wobblySeg(rand, w2, b, 0.6)}`;
|
|
118
|
+
}
|
|
119
|
+
return { d: path, w: Math.abs(tx) + P * 2, h: Math.abs(ty) + P * 2 };
|
|
120
|
+
}
|
|
121
|
+
if (kind === 'rect') {
|
|
122
|
+
const x0 = P; const y0 = P; const x1 = P + w; const y1 = P + h;
|
|
123
|
+
const c = [{ x: x0, y: y0 }, { x: x1, y: y0 }, { x: x1, y: y1 }, { x: x0, y: y1 }];
|
|
124
|
+
// 角上微微出头(手画的框角会交叉),起笔点随机落在四角之一
|
|
125
|
+
let path = `M ${f1(c[0].x - 1)} ${f1(c[0].y)}`;
|
|
126
|
+
for (let i = 0; i < 4; i += 1) path += wobblySeg(rand, c[i], c[(i + 1) % 4]);
|
|
127
|
+
path += ` L ${f1(c[0].x + 3)} ${f1(c[0].y + 0.5)}`;
|
|
128
|
+
return { d: path, w: w + P * 2, h: h + P * 2 };
|
|
129
|
+
}
|
|
130
|
+
if (kind === 'ellipse' || kind === 'circle') {
|
|
131
|
+
const rx = w / 2; const ry = (kind === 'circle' ? w : h) / 2;
|
|
132
|
+
const cx = P + rx; const cy = P + ry;
|
|
133
|
+
const n = Math.max(16, Math.min(48, Math.round((rx + ry) / 6)));
|
|
134
|
+
const amp = Math.min(1.6, 0.5 + (rx + ry) / 300);
|
|
135
|
+
let path = '';
|
|
136
|
+
// 多画 1/8 圈让起止重叠,像手画的圈
|
|
137
|
+
for (let i = 0; i <= n + n / 8; i += 1) {
|
|
138
|
+
const t = (i / n) * Math.PI * 2 - Math.PI / 3;
|
|
139
|
+
const jitter = 1 + (rand() * 2 - 1) * amp / Math.max(rx, ry);
|
|
140
|
+
const x = cx + Math.cos(t) * rx * jitter; const y = cy + Math.sin(t) * ry * jitter;
|
|
141
|
+
path += (i === 0 ? 'M ' : ' L ') + `${f1(x)} ${f1(y)}`;
|
|
142
|
+
}
|
|
143
|
+
return { d: path, w: rx * 2 + P * 2, h: ry * 2 + P * 2 };
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 模板排布。nodes: [{ key, w, h, at?: {x,y}(网格) }] → Map key → {x,y}(局部像素)
|
|
150
|
+
* - free:有 at 的按网格落,没 at 的排在 free 区域下面一列
|
|
151
|
+
* - column / row / grid(cols) / mindmap(第一个是中心,其余环绕)
|
|
152
|
+
*/
|
|
153
|
+
export function layoutNodes(nodes, { template = 'auto', cols = null } = {}) {
|
|
154
|
+
const pos = new Map();
|
|
155
|
+
if (!nodes.length) return pos;
|
|
156
|
+
let tpl = template;
|
|
157
|
+
if (tpl === 'auto') tpl = nodes.some(n => n.at) ? 'free' : (nodes.length <= 4 ? 'column' : 'grid');
|
|
158
|
+
|
|
159
|
+
if (tpl === 'free') {
|
|
160
|
+
let bottom = 0;
|
|
161
|
+
const rest = [];
|
|
162
|
+
for (const n of nodes) {
|
|
163
|
+
if (n.at && Number.isFinite(n.at.x) && Number.isFinite(n.at.y)) {
|
|
164
|
+
const p = { x: Math.round(n.at.x * UNIT), y: Math.round(n.at.y * UNIT) };
|
|
165
|
+
pos.set(n.key, p); bottom = Math.max(bottom, p.y + n.h);
|
|
166
|
+
} else rest.push(n);
|
|
167
|
+
}
|
|
168
|
+
let y = bottom ? bottom + GAP : 0;
|
|
169
|
+
for (const n of rest) { pos.set(n.key, { x: 0, y }); y += n.h + GAP; }
|
|
170
|
+
return pos;
|
|
171
|
+
}
|
|
172
|
+
if (tpl === 'column') {
|
|
173
|
+
let y = 0;
|
|
174
|
+
for (const n of nodes) { pos.set(n.key, { x: 0, y }); y += n.h + GAP; }
|
|
175
|
+
return pos;
|
|
176
|
+
}
|
|
177
|
+
if (tpl === 'row') {
|
|
178
|
+
let x = 0;
|
|
179
|
+
for (const n of nodes) { pos.set(n.key, { x, y: 0 }); x += n.w + GAP + 4; }
|
|
180
|
+
return pos;
|
|
181
|
+
}
|
|
182
|
+
if (tpl === 'mindmap') {
|
|
183
|
+
const [center, ...rest] = nodes;
|
|
184
|
+
const cx = 0; const cy = 0;
|
|
185
|
+
pos.set(center.key, { x: cx - center.w / 2, y: cy - center.h / 2 });
|
|
186
|
+
if (!rest.length) return pos;
|
|
187
|
+
// 半径:中心节点(含可能套在外面的圈)之外留 70,且环的周长要摆得下所有节点
|
|
188
|
+
const need = rest.reduce((sum, n) => sum + Math.max(n.w, 120) + GAP * 2, 0);
|
|
189
|
+
const r = Math.max(center.w / 2 + 70, center.h / 2 + 70, need / (Math.PI * 2) + 40);
|
|
190
|
+
rest.forEach((n, i) => {
|
|
191
|
+
const t = (i / rest.length) * Math.PI * 2 - Math.PI / 2;
|
|
192
|
+
const c = Math.cos(t); const sn = Math.sin(t);
|
|
193
|
+
// 节点**靠内的那条边**落在环上(不是节点中心):宽节点才不会压进中心
|
|
194
|
+
pos.set(n.key, {
|
|
195
|
+
x: Math.round(cx + c * r - n.w * (1 - c) / 2),
|
|
196
|
+
y: Math.round(cy + sn * r * 0.72 - n.h * (1 - sn) / 2),
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
return pos;
|
|
200
|
+
}
|
|
201
|
+
// grid
|
|
202
|
+
const c = Math.max(1, cols || Math.ceil(Math.sqrt(nodes.length)));
|
|
203
|
+
const colW = Array(c).fill(0);
|
|
204
|
+
nodes.forEach((n, i) => { colW[i % c] = Math.max(colW[i % c], n.w); });
|
|
205
|
+
let y = 0; let rowH = 0;
|
|
206
|
+
nodes.forEach((n, i) => {
|
|
207
|
+
const col = i % c;
|
|
208
|
+
if (col === 0 && i > 0) { y += rowH + GAP; rowH = 0; }
|
|
209
|
+
let x = 0; for (let k = 0; k < col; k += 1) x += colW[k] + GAP + 4;
|
|
210
|
+
pos.set(n.key, { x, y }); rowH = Math.max(rowH, n.h);
|
|
211
|
+
});
|
|
212
|
+
return pos;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 一组矩形的包围盒 */
|
|
216
|
+
export function bboxOf(rects) {
|
|
217
|
+
let x0 = Infinity; let y0 = Infinity; let x1 = -Infinity; let y1 = -Infinity;
|
|
218
|
+
for (const r of rects) {
|
|
219
|
+
x0 = Math.min(x0, r.x); y0 = Math.min(y0, r.y); x1 = Math.max(x1, r.x + r.w); y1 = Math.max(y1, r.y + r.h);
|
|
220
|
+
}
|
|
221
|
+
return Number.isFinite(x0) ? { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } : { x: 0, y: 0, w: 0, h: 0 };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 宏观落位:在 obstacles(同层已摆的矩形)中给一块 w×h 找地方。
|
|
226
|
+
* - near:锚右侧(撞了往下让),让不开就锚下方
|
|
227
|
+
* - 否则:内容最低边下面(与入座"新东西排底下"同一条起排线精神)
|
|
228
|
+
*/
|
|
229
|
+
export function findSpot({ w, h, near = null, obstacles = [], contentBottom = 0, viewport = null }) {
|
|
230
|
+
const hits = (x, y) => obstacles.some(o => !(x + w <= o.x || o.x + o.w <= x || y + h <= o.y || o.y + o.h <= y));
|
|
231
|
+
// 用户视口里有空地就落在视口里(黑板是主窗口时,画在他眼前而不是让他去找)。
|
|
232
|
+
// 阅读顺序纪律(08-23 真踩:第二张图落到第一张的**左边**):先挑不在已有内容左侧/
|
|
233
|
+
// 上方的位置(顺着先左后右、先上后下长),实在没有才退而求其次。
|
|
234
|
+
if (!near && viewport && viewport.w >= w + 24 && viewport.h >= h + 24) {
|
|
235
|
+
const content = obstacles.length ? {
|
|
236
|
+
x: Math.min(...obstacles.map(o => o.x)), y: Math.min(...obstacles.map(o => o.y)),
|
|
237
|
+
} : null;
|
|
238
|
+
const sx = Math.max(24, Math.round(viewport.w / 8)); const sy = Math.max(24, Math.round(viewport.h / 8));
|
|
239
|
+
for (const strict of [true, false]) {
|
|
240
|
+
for (let y = viewport.y + 12; y + h <= viewport.y + viewport.h - 12; y += sy) {
|
|
241
|
+
for (let x = viewport.x + 12; x + w <= viewport.x + viewport.w - 12; x += sx) {
|
|
242
|
+
if (strict && content && (x < content.x - 24 || y < content.y - 24)) continue;
|
|
243
|
+
if (!hits(x, y)) return { x: Math.round(x), y: Math.round(y), side: 'viewport' };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (near) {
|
|
249
|
+
let x = near.x + near.w + 32; let y = near.y;
|
|
250
|
+
for (let g = 0; g < 30; g += 1) {
|
|
251
|
+
if (!hits(x, y)) return { x: Math.round(x), y: Math.round(y), side: 'right' };
|
|
252
|
+
const blocker = obstacles.find(o => !(x + w <= o.x || o.x + o.w <= x || y + h <= o.y || o.y + o.h <= y));
|
|
253
|
+
y = blocker.y + blocker.h + GAP;
|
|
254
|
+
}
|
|
255
|
+
x = near.x; y = near.y + near.h + 32;
|
|
256
|
+
for (let g = 0; g < 30; g += 1) {
|
|
257
|
+
if (!hits(x, y)) return { x: Math.round(x), y: Math.round(y), side: 'below' };
|
|
258
|
+
const blocker = obstacles.find(o => !(x + w <= o.x || o.x + o.w <= x || y + h <= o.y || o.y + o.h <= y));
|
|
259
|
+
y = blocker.y + blocker.h + GAP;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// 排在内容底下:左边缘对齐已有内容(不是写死的 10),读起来像续在同一栏
|
|
263
|
+
const left = obstacles.length ? Math.min(...obstacles.map(o => o.x)) : 10;
|
|
264
|
+
return { x: Math.round(left), y: Math.round(contentBottom) + 40, side: 'bottom' };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 按用户屏幕算「一屏」:视点里有相机矩形和缩放 → 屏幕像素 = 世界 × 缩放;
|
|
269
|
+
* 0.8 倍可读 → 一屏世界像素 = 屏幕 / 0.8。没视点就用 SKETCH_FIT 的缺省。
|
|
270
|
+
*/
|
|
271
|
+
export function fitFor(vp) {
|
|
272
|
+
if (vp?.camera?.w && vp?.zoom) {
|
|
273
|
+
const sw = vp.camera.w * vp.zoom; const sh = vp.camera.h * vp.zoom;
|
|
274
|
+
return { w: Math.round(sw / 0.8), h: Math.round(sh / 0.8), screen: { w: Math.round(sw), h: Math.round(sh) } };
|
|
275
|
+
}
|
|
276
|
+
return { ...SKETCH_FIT, screen: null };
|
|
277
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/projects/board-limits.js — board.json 的容量常数(2026-08-23 拆出)
|
|
3
|
+
* board-store(写盘前查字节数)和 board-sanitize(逐类计数)都要,单独一份免循环依赖。
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULT_BOARD_SIZE = { w: 4000, h: 2600 };
|
|
6
|
+
export const MAX_BOARD_BYTES = 512 * 1024;
|
|
7
|
+
export const MAX_OBJECTS = 2000;
|
|
8
|
+
export const MAX_ZONES = 200;
|
|
9
|
+
// 关系线上限。取值理由:一块板上人能看懂的线远少于这个数,1000 是防脱缰
|
|
10
|
+
// (agent 循环里连画)的闸门,不是设计目标。超了直接不收,不做淘汰 ——
|
|
11
|
+
// 静默丢最旧的会让"我明明画了"变成玄学。
|
|
12
|
+
export const MAX_BINDINGS = 1000;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/projects/board-sanitize.js — board.json 的校验/清洗(2026-08-23 从 board-store 拆出)
|
|
3
|
+
*
|
|
4
|
+
* 纯函数,不碰磁盘不碰锁。board-store 是唯一读写方,读进来先过这里、写出去前
|
|
5
|
+
* 也过这里;字段的白名单与上限全在这一份。拆出来只是因为 board-store 胖过了
|
|
6
|
+
* 行数棘轮,语义一个字没改。
|
|
7
|
+
*
|
|
8
|
+
* schema 见 board-store 头注释。黑板字段(2026-08-23):
|
|
9
|
+
* object.tag / object.staging / text.data.format
|
|
10
|
+
* binding.material / binding.tag / binding.staging
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { isBindingType, isBindingMaterial } from '../lib/binding-types.js';
|
|
14
|
+
import { DEFAULT_BOARD_SIZE, MAX_OBJECTS, MAX_ZONES, MAX_BINDINGS } from './board-limits.js';
|
|
15
|
+
|
|
16
|
+
export function clampNum(v, min, max, fallback) {
|
|
17
|
+
const n = Number(v);
|
|
18
|
+
if (!Number.isFinite(n)) return fallback;
|
|
19
|
+
return Math.min(max, Math.max(min, Math.round(n)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function sanitizeSize(raw) {
|
|
23
|
+
return {
|
|
24
|
+
w: clampNum(raw?.w, 1000, 20000, DEFAULT_BOARD_SIZE.w),
|
|
25
|
+
h: clampNum(raw?.h, 800, 20000, DEFAULT_BOARD_SIZE.h),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* **画布原生**物件的形态白名单。
|
|
31
|
+
*
|
|
32
|
+
* 绝大多数画布物件是磁盘产物的影子:board.json 只存它摆在哪,本体是文件
|
|
33
|
+
* (所以 agent 读得到、能进上下文、删文件即消失)。涂鸦不一样 —— 它没有
|
|
34
|
+
* 有意义的文件形态,board.json 就是它的**本体**。这类物件必须显式登记,
|
|
35
|
+
* 否则任何人往 objects 里塞一个 kind 就能造出一个不受形态表管的东西。
|
|
36
|
+
*
|
|
37
|
+
* 2026-08-08 加进 `text`。在那之前画布上打的字一律落成 `.md` 便签,理由是
|
|
38
|
+
* "agent 读得到"。但用户要的是**白板**:在工程文件旁边随手写一句、画一笔,
|
|
39
|
+
* 那是给自己的记号,不是给 agent 的输入。想让 agent 看见的写便利贴 ——
|
|
40
|
+
* 那条路还在,挪到了右键菜单里。
|
|
41
|
+
*/
|
|
42
|
+
const CANVAS_NATIVE_KINDS = new Set(['scribble', 'text']);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 坐标夹持上限。画布 2026-08-13 起全向无限,坐标不再由 board.size 夹住 ——
|
|
46
|
+
* 这个数只挡非有限值和纯属事故的数字(±100 万世界像素之外没有正常操作能到)。
|
|
47
|
+
*/
|
|
48
|
+
const COORD_LIMIT = 1e6;
|
|
49
|
+
|
|
50
|
+
/** 涂鸦路径串上限。一条随手画的线约 300~800 字符,8000 够长且撑不爆 board.json */
|
|
51
|
+
const MAX_SCRIBBLE_PATH = 8000;
|
|
52
|
+
/**
|
|
53
|
+
* 画布文字的字数上限。
|
|
54
|
+
*
|
|
55
|
+
* 它是"写在白板上的一句话",不是文档 —— 长东西该写成 .md(那是便利贴,
|
|
56
|
+
* agent 读得到)。2000 字够写一段说明,也撑不爆 board.json。
|
|
57
|
+
*/
|
|
58
|
+
const MAX_TEXT_LEN = 2000;
|
|
59
|
+
/**
|
|
60
|
+
* md 档文字的上限(2026-08-23 黑板)。画布上的 markdown/KaTeX/mermaid 节点比手写
|
|
61
|
+
* 一句话长,但它仍是"黑板上的一块"不是文档 —— 想写长的走 .md。
|
|
62
|
+
*/
|
|
63
|
+
const MAX_MD_TEXT_LEN = 4000;
|
|
64
|
+
/** 文字的排版格式:plain = 原样手写;md = Markdown(含 KaTeX 与 mermaid 围栏) */
|
|
65
|
+
const TEXT_FORMATS = ['plain', 'md'];
|
|
66
|
+
/**
|
|
67
|
+
* 分组标签(2026-08-23 黑板):物件和线都可以带。它**不是几何容器**,只是一个
|
|
68
|
+
* 字段 —— 读侧按它过滤、渲染侧按它画包络、staging 落定按它成批。字符集收紧
|
|
69
|
+
* 是因为它会进 DOM 属性和 URL 查询串。
|
|
70
|
+
*/
|
|
71
|
+
const TAG_RE = /^[\w\u4e00-\u9fff\u3040-\u30ff-]{1,40}$/;
|
|
72
|
+
/**
|
|
73
|
+
* 画布 id 的路径安全(2026-08-23 fable 审出 P0):id 大多是工作区相对路径,下游有人拿它拼
|
|
74
|
+
* 文件路径(removeByTag 删板书、chalkExcerpts 读板书)。拒掉 `..` 段、绝对路径、反斜杠、NUL;
|
|
75
|
+
* 带形态前缀(deck:/site:/…)的先剥前缀再查。
|
|
76
|
+
*/
|
|
77
|
+
export function isSafeCanvasId(id) {
|
|
78
|
+
if (typeof id !== 'string' || !id || id.length > 300) return false;
|
|
79
|
+
if (id.includes('\0') || id.includes('\\')) return false;
|
|
80
|
+
const c = id.indexOf(':');
|
|
81
|
+
const p = (c > 0 && /^[a-z]+$/.test(id.slice(0, c))) ? id.slice(c + 1) : id;
|
|
82
|
+
if (p.startsWith('/')) return false;
|
|
83
|
+
return !p.split('/').some(seg => seg === '..');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function sanitizeTag(v) {
|
|
87
|
+
return typeof v === 'string' && TAG_RE.test(v) ? v : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 画布文字可选的字体。**白名单而不是自由字符串** —— 这个值会进 CSS */
|
|
91
|
+
export const TEXT_FONTS = ['pen', 'kai', 'sans', 'serif', 'mono'];
|
|
92
|
+
const TEXT_SIZES = ['sm', 'md', 'lg', 'xl'];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 变换字段(2026-08-13,选中态控制器)。缺省不落字段 —— 没转过没缩过的物件
|
|
96
|
+
* 别背两个恒等值,board.json 的 diff 要能一眼看出"谁被动过"。
|
|
97
|
+
*/
|
|
98
|
+
function sanitizeTransform(data) {
|
|
99
|
+
const out = {};
|
|
100
|
+
const rot = Number(data?.rotation);
|
|
101
|
+
if (Number.isFinite(rot) && rot !== 0) out.rotation = clampNum(rot, -360, 360, 0);
|
|
102
|
+
const sc = Number(data?.scale);
|
|
103
|
+
if (Number.isFinite(sc) && sc !== 1) out.scale = clampNum(sc, 0.2, 10, 1);
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sanitizeCanvasData(kind, data) {
|
|
108
|
+
if (kind === 'text') {
|
|
109
|
+
const format = TEXT_FORMATS.includes(data?.format) ? data.format : 'plain';
|
|
110
|
+
const max = format === 'md' ? MAX_MD_TEXT_LEN : MAX_TEXT_LEN;
|
|
111
|
+
const t = typeof data?.t === 'string' ? data.t.slice(0, max) : '';
|
|
112
|
+
if (!t.trim()) return null;
|
|
113
|
+
return {
|
|
114
|
+
t,
|
|
115
|
+
// plain 不落字段:存量条目全是 plain,别让 diff 里凭空多一行
|
|
116
|
+
...(format === 'md' ? { format } : {}),
|
|
117
|
+
font: TEXT_FONTS.includes(data?.font) ? data.font : 'kai',
|
|
118
|
+
size: TEXT_SIZES.includes(data?.size) ? data.size : 'md',
|
|
119
|
+
color: ['ink', 'red', 'pencil', 'brass'].includes(data?.color) ? data.color : 'ink',
|
|
120
|
+
...sanitizeTransform(data),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (kind !== 'scribble') return null;
|
|
124
|
+
const d = typeof data?.d === 'string' ? data.d.slice(0, MAX_SCRIBBLE_PATH) : '';
|
|
125
|
+
// 只收 SVG path 里合法的那几个字符,挡住任何往 DOM 里塞东西的尝试
|
|
126
|
+
if (!d || !/^[\dMLQCZ ,.\-eE]+$/.test(d)) return null;
|
|
127
|
+
return {
|
|
128
|
+
d,
|
|
129
|
+
color: ['ink', 'red', 'pencil', 'brass'].includes(data.color) ? data.color : 'ink',
|
|
130
|
+
width: clampNum(data.width, 1, 24, 2),
|
|
131
|
+
...sanitizeTransform(data),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function sanitizeObject(o, size) {
|
|
136
|
+
if (!o || typeof o !== 'object') return null;
|
|
137
|
+
const kind = typeof o.kind === 'string' && CANVAS_NATIVE_KINDS.has(o.kind) ? o.kind : null;
|
|
138
|
+
const data = kind ? sanitizeCanvasData(kind, o.data) : null;
|
|
139
|
+
// 登记了 kind 却给不出合法内容 → 整条丢弃。留一个空壳会在画布上变成
|
|
140
|
+
// 一个看不见也删不掉的幽灵物件。
|
|
141
|
+
if (kind && !data) return null;
|
|
142
|
+
return {
|
|
143
|
+
// 画布 2026-08-13 起全向无限:坐标不再被桌面尺寸夹持(原来非 native 只许
|
|
144
|
+
// 正区间、native 也出不了 ±size)。这里只挡非有限值和纯属事故的数字 ——
|
|
145
|
+
// 夹得再紧一点都意味着"用户摆在那儿的东西刷新后跳走",那是静默数据损坏。
|
|
146
|
+
x: clampNum(o.x, -COORD_LIMIT, COORD_LIMIT, 0),
|
|
147
|
+
y: clampNum(o.y, -COORD_LIMIT, COORD_LIMIT, 0),
|
|
148
|
+
z: clampNum(o.z, 0, 1e6, 0),
|
|
149
|
+
...(Number.isFinite(Number(o.w)) ? { w: clampNum(o.w, 4, COORD_LIMIT, 200) } : {}),
|
|
150
|
+
...(Number.isFinite(Number(o.h)) ? { h: clampNum(o.h, 4, COORD_LIMIT, 200) } : {}),
|
|
151
|
+
...(o.expanded ? { expanded: true } : {}),
|
|
152
|
+
// 显式归属:'' = 明确无归属(覆盖 sid 派生),非空 = 所属工作区 id
|
|
153
|
+
...(typeof o.zone === 'string' && o.zone.length <= 300 ? { zone: o.zone } : {}),
|
|
154
|
+
// 出处(2026-08-14 agent 摆位/建元素):agent 落的座/写的字标出来,
|
|
155
|
+
// 用户的是默认不标 —— 跟关系线的 by 同一条纪律
|
|
156
|
+
...(o.by === 'agent' ? { by: 'agent' } : {}),
|
|
157
|
+
// 分组标签 + 草稿位(2026-08-23 黑板)。staging = agent 这一轮还在打草稿:
|
|
158
|
+
// 入座不看它、read_board 默认不列它、画面上半透明;落定(commitStaging)清位。
|
|
159
|
+
...(sanitizeTag(o.tag) ? { tag: sanitizeTag(o.tag) } : {}),
|
|
160
|
+
...(o.staging === true ? { staging: true } : {}),
|
|
161
|
+
...(kind ? { kind, data } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 关系线。**不存坐标** —— 端点是 object id 或 zone id,线跟着端点走。
|
|
167
|
+
*
|
|
168
|
+
* 词汇表在 `server/lib/binding-types.js`(前端画线那份视觉映射要跟它对齐,
|
|
169
|
+
* 有 parity 断言看着)。不认识的 type 一律丢弃:宁可少画一条线,也不要在
|
|
170
|
+
* 画布上留一条没人知道什么意思的连线。
|
|
171
|
+
*
|
|
172
|
+
* 自环(from === to)也丢:它画不出来,且多半是 agent 传错了 id。
|
|
173
|
+
*/
|
|
174
|
+
export function sanitizeBinding(b) {
|
|
175
|
+
if (!b || typeof b !== 'object') return null;
|
|
176
|
+
if (!isBindingType(b.type)) return null;
|
|
177
|
+
const from = typeof b.from === 'string' ? b.from.slice(0, 300) : '';
|
|
178
|
+
const to = typeof b.to === 'string' ? b.to.slice(0, 300) : '';
|
|
179
|
+
if (!from || !to || from === to) return null;
|
|
180
|
+
return {
|
|
181
|
+
type: b.type,
|
|
182
|
+
from,
|
|
183
|
+
to,
|
|
184
|
+
// 线上的字。没写就渲染时回落到词汇表的默认词,不在这里补 —— 存了默认词
|
|
185
|
+
// 之后改词汇表就改不动存量了。
|
|
186
|
+
...(typeof b.label === 'string' && b.label.trim()
|
|
187
|
+
? { label: b.label.trim().slice(0, 60) }
|
|
188
|
+
: {}),
|
|
189
|
+
// 谁画的。用户画的线 agent 不该擅自删,反过来也一样;auto = 机器可证的
|
|
190
|
+
// 引用关系(auto-relations.js 对账层专属,只有它增删自家 b:auto:* 条目)。
|
|
191
|
+
...(b.by === 'agent' || b.by === 'user' || b.by === 'auto' ? { by: b.by } : {}),
|
|
192
|
+
// 材质(2026-08-23 黑板):语义之外的第二个轴 —— 墨线/手绘/丝线。不落默认值,
|
|
193
|
+
// 渲染侧按语义给缺省材质;存了默认值以后改缺省就改不动存量。
|
|
194
|
+
...(isBindingMaterial(b.material) && b.material !== 'ink' ? { material: b.material } : {}),
|
|
195
|
+
...(sanitizeTag(b.tag) ? { tag: sanitizeTag(b.tag) } : {}),
|
|
196
|
+
...(b.staging === true ? { staging: true } : {}),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* zones 一行只剩坐标(2026-08-13 瘦身,#14)。
|
|
202
|
+
*
|
|
203
|
+
* 逐字段的下场:
|
|
204
|
+
* - `w`/`h` —— 文件夹变方卡后前端视图**强制** FOLDER_CARD 尺寸,存的数字
|
|
205
|
+
* 没人读,还会成为"画布上 288 宽、存档里 1340"那种自相矛盾的证据。
|
|
206
|
+
* - `title` —— 名字从路径读(id 就是路径),存一份就是第二个真相源,
|
|
207
|
+
* 改名后立刻过期(实测过)。
|
|
208
|
+
* - `collapsed` —— 收起/展开两态 2026-08-13 随"当前目录"模型退役。
|
|
209
|
+
* - `pinned` —— 纵向堆叠 2026-08-08 退役,字段没有了对立面。
|
|
210
|
+
* 存量数据里这些字段读进来直接丢,下次写盘自然消失。
|
|
211
|
+
*/
|
|
212
|
+
export function sanitizeZone(z) {
|
|
213
|
+
if (!z || typeof z !== 'object') return null;
|
|
214
|
+
return {
|
|
215
|
+
// 同 sanitizeObject:无限画布,文件夹卡也能摆在任何地方(含负坐标)
|
|
216
|
+
x: clampNum(z.x, -COORD_LIMIT, COORD_LIMIT, 0),
|
|
217
|
+
y: clampNum(z.y, -COORD_LIMIT, COORD_LIMIT, 0),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function sanitizeBoard(raw) {
|
|
222
|
+
const size = sanitizeSize(raw?.size);
|
|
223
|
+
const objects = {};
|
|
224
|
+
const zones = {};
|
|
225
|
+
const bindings = {};
|
|
226
|
+
let count = 0;
|
|
227
|
+
for (const [id, o] of Object.entries(raw?.objects && typeof raw.objects === 'object' ? raw.objects : {})) {
|
|
228
|
+
if (count >= MAX_OBJECTS) break;
|
|
229
|
+
if (!isSafeCanvasId(id)) continue;
|
|
230
|
+
const s = sanitizeObject(o, size);
|
|
231
|
+
if (s) { objects[id] = s; count += 1; }
|
|
232
|
+
}
|
|
233
|
+
let zCount = 0;
|
|
234
|
+
for (const [id, z] of Object.entries(raw?.zones && typeof raw.zones === 'object' ? raw.zones : {})) {
|
|
235
|
+
if (zCount >= MAX_ZONES) break;
|
|
236
|
+
if (!isSafeCanvasId(id)) continue;
|
|
237
|
+
const s = sanitizeZone(z);
|
|
238
|
+
if (s) { zones[id] = s; zCount += 1; }
|
|
239
|
+
}
|
|
240
|
+
let bCount = 0;
|
|
241
|
+
for (const [id, b] of Object.entries(raw?.bindings && typeof raw.bindings === 'object' ? raw.bindings : {})) {
|
|
242
|
+
if (bCount >= MAX_BINDINGS) break;
|
|
243
|
+
if (typeof id !== 'string' || id.length > 300) continue;
|
|
244
|
+
const s = sanitizeBinding(b);
|
|
245
|
+
if (s) { bindings[id] = s; bCount += 1; }
|
|
246
|
+
}
|
|
247
|
+
// 主角覆盖(2026-08-14 agent 摆位):显式立的主角压过前端 pickHero 的推断。
|
|
248
|
+
// 存 id 不存理由 —— 理由在会话里,画布只要知道谁站 C 位
|
|
249
|
+
const hero = typeof raw?.hero === 'string' && raw.hero.length <= 300 ? raw.hero : null;
|
|
250
|
+
return { size, zones, objects, bindings, ...(hero ? { hero } : {}) };
|
|
251
|
+
}
|
|
252
|
+
|