@neta-art/cohub 5.3.3 → 5.4.1

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.
Files changed (43) hide show
  1. package/dist/board/codec.d.ts +2 -0
  2. package/dist/board/codec.js +18 -13
  3. package/dist/board/core/arrow-geometry.d.ts +23 -0
  4. package/dist/board/core/arrow-geometry.js +109 -0
  5. package/dist/board/core/connections.d.ts +91 -0
  6. package/dist/board/core/connections.js +414 -0
  7. package/dist/board/core/export-plan.d.ts +15 -4
  8. package/dist/board/core/export-plan.js +49 -16
  9. package/dist/board/core/shape-definition.js +1 -1
  10. package/dist/board/core/shape-types.d.ts +8 -2
  11. package/dist/board/core/shape-types.js +1 -1
  12. package/dist/board/core/tool-styles.d.ts +9 -3
  13. package/dist/board/core/tool-styles.js +10 -6
  14. package/dist/board/export/index.js +1 -0
  15. package/dist/board/export/scene.d.ts +3 -0
  16. package/dist/board/export/scene.js +18 -1
  17. package/dist/board/index.d.ts +8 -5
  18. package/dist/board/index.js +8 -5
  19. package/dist/board/render/connection-layer.d.ts +48 -0
  20. package/dist/board/render/connection-layer.js +223 -0
  21. package/dist/board/render/index.d.ts +2 -1
  22. package/dist/board/render/index.js +3 -2
  23. package/dist/board/render/renderers/arrow-card-renderer.js +35 -48
  24. package/dist/chunks/http.d.ts +53 -1
  25. package/dist/chunks/http.js +287 -1
  26. package/dist/chunks/websocket.d.ts +296 -57
  27. package/dist/http.d.ts +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.js +17 -56
  30. package/dist/protocol/dist/board-connection.d.ts +310 -0
  31. package/dist/protocol/dist/board-connection.js +215 -0
  32. package/dist/protocol/dist/board-constants.d.ts +11 -1
  33. package/dist/protocol/dist/board-constants.js +15 -1
  34. package/dist/protocol/dist/board-document.d.ts +106 -60
  35. package/dist/protocol/dist/board-document.js +57 -17
  36. package/dist/protocol/dist/board.d.ts +1 -0
  37. package/dist/protocol/dist/board.js +3 -0
  38. package/dist/protocol/dist/index.d.ts +2 -1
  39. package/dist/protocol/dist/index.js +2 -1
  40. package/dist/protocol/dist/realtime/board-awareness.js +15 -15
  41. package/package.json +1 -1
  42. package/dist/board/core/bindings.d.ts +0 -45
  43. package/dist/board/core/bindings.js +0 -162
@@ -0,0 +1,414 @@
1
+ import { degToRad, frameRect, rectCenter, rotatePointAround, worldPoint } from "../geometry.js";
2
+ //#region src/board/core/connections.ts
3
+ /**
4
+ * Connection geometry — pure resolution of a relation into a drawable path.
5
+ *
6
+ * A `BoardConnection` stores no coordinates: it names two nodes, how it attaches
7
+ * to each, and how the line should travel. Everything spatial is derived here from
8
+ * the live node frames, which is what makes a connection incapable of going stale.
9
+ * No PixiJS, no DOM, no editor state — so the editor, the far-LOD batch, the
10
+ * headless exporter and the tests all resolve a connection the same way.
11
+ *
12
+ * The `auto` anchor is the interesting part: rather than storing a side, it picks
13
+ * one per resolve from the relative position of the two frames, so a connection
14
+ * keeps its shortest sensible route through any layout change. A stored side is
15
+ * honoured exactly, because a user who pinned one is stating intent that geometry
16
+ * must not override.
17
+ */
18
+ /**
19
+ * Gap between a node's edge and the line's endpoint, in world units.
20
+ *
21
+ * A connection that touches the border reads as part of the node; a small gap
22
+ * makes the relation legible as its own object and keeps an arrowhead from being
23
+ * swallowed by the card it points at.
24
+ */
25
+ const CONNECTION_ENDPOINT_GAP = 4;
26
+ /** Perpendicular fraction of the span used for an `orthogonal` elbow. */
27
+ const ORTHOGONAL_SNAP = .5;
28
+ const SIDE_NORMALS = {
29
+ top: {
30
+ x: 0,
31
+ y: -1
32
+ },
33
+ right: {
34
+ x: 1,
35
+ y: 0
36
+ },
37
+ bottom: {
38
+ x: 0,
39
+ y: 1
40
+ },
41
+ left: {
42
+ x: -1,
43
+ y: 0
44
+ }
45
+ };
46
+ /** Normalized position of a point on a frame side, as (nx, ny). */
47
+ function sideAnchorPoint(side, offset) {
48
+ switch (side) {
49
+ case "top": return {
50
+ nx: offset,
51
+ ny: 0
52
+ };
53
+ case "bottom": return {
54
+ nx: offset,
55
+ ny: 1
56
+ };
57
+ case "left": return {
58
+ nx: 0,
59
+ ny: offset
60
+ };
61
+ default: return {
62
+ nx: 1,
63
+ ny: offset
64
+ };
65
+ }
66
+ }
67
+ /** Denormalize a (nx, ny) anchor to a world point on a possibly rotated frame. */
68
+ function anchorToWorld(frame, nx, ny) {
69
+ const unrotated = worldPoint(frame.x + nx * frame.width, frame.y + ny * frame.height);
70
+ if (!frame.rotation) return unrotated;
71
+ return rotatePointAround(unrotated, rectCenter(frameRect(frame)), degToRad(frame.rotation));
72
+ }
73
+ /** Normalize a world point into a (nx, ny) anchor on a frame, clamped to it. */
74
+ function worldToAnchor(frame, point) {
75
+ const local = frame.rotation ? rotatePointAround(point, rectCenter(frameRect(frame)), -degToRad(frame.rotation)) : point;
76
+ return {
77
+ nx: clamp01((local.x - frame.x) / Math.max(frame.width, 1e-4)),
78
+ ny: clamp01((local.y - frame.y) / Math.max(frame.height, 1e-4))
79
+ };
80
+ }
81
+ function clamp01(value) {
82
+ return Math.min(1, Math.max(0, value));
83
+ }
84
+ /**
85
+ * Choose the side an `auto` anchor should use.
86
+ *
87
+ * The dominant axis of the center-to-center vector wins, weighted by the frame's
88
+ * own proportions so a wide card prefers its long edges. Comparing raw dx/dy
89
+ * instead would make a wide node attach to its short side for most angles, which
90
+ * is the classic "line leaves from the corner" artifact.
91
+ */
92
+ function autoConnectionSide(from, to) {
93
+ const a = rectCenter(frameRect(from));
94
+ const b = rectCenter(frameRect(to));
95
+ const dx = b.x - a.x;
96
+ const dy = b.y - a.y;
97
+ if (Math.abs(dx) / Math.max(from.width / 2, 1e-4) >= Math.abs(dy) / Math.max(from.height / 2, 1e-4)) return dx >= 0 ? "right" : "left";
98
+ return dy >= 0 ? "bottom" : "top";
99
+ }
100
+ /**
101
+ * World point of an anchor on a frame, ignoring the gap and the facing node.
102
+ *
103
+ * For a live drag the other end is the pointer, not a node, so `auto` has no
104
+ * frame to face: it resolves to the node's centre, which is the only honest
105
+ * answer while the relation has nowhere to point yet.
106
+ */
107
+ function anchorPointOnFrame(anchor, frame) {
108
+ if (anchor.kind === "fixed") return anchorToWorld(frame, anchor.nx, anchor.ny);
109
+ if (anchor.kind === "side") {
110
+ const point = sideAnchorPoint(anchor.side, anchor.offset);
111
+ return anchorToWorld(frame, point.nx, point.ny);
112
+ }
113
+ return anchorToWorld(frame, .5, .5);
114
+ }
115
+ /** Resolve one endpoint of a connection against its node frame. */
116
+ function resolveEndpoint(anchor, frame, otherFrame, gap) {
117
+ let side;
118
+ let nx;
119
+ let ny;
120
+ if (anchor.kind === "fixed") {
121
+ nx = anchor.nx;
122
+ ny = anchor.ny;
123
+ side = nearestSide(nx, ny);
124
+ } else if (anchor.kind === "side") {
125
+ side = anchor.side;
126
+ const point = sideAnchorPoint(side, anchor.offset);
127
+ nx = point.nx;
128
+ ny = point.ny;
129
+ } else {
130
+ side = autoConnectionSide(frame, otherFrame);
131
+ const point = sideAnchorPoint(side, .5);
132
+ nx = point.nx;
133
+ ny = point.ny;
134
+ }
135
+ const base = anchorToWorld(frame, nx, ny);
136
+ const local = SIDE_NORMALS[side];
137
+ const normal = frame.rotation ? rotateVector(local, degToRad(frame.rotation)) : worldPoint(local.x, local.y);
138
+ return {
139
+ point: worldPoint(base.x + normal.x * gap, base.y + normal.y * gap),
140
+ normal,
141
+ side
142
+ };
143
+ }
144
+ function rotateVector(vector, angleRad) {
145
+ const cos = Math.cos(angleRad);
146
+ const sin = Math.sin(angleRad);
147
+ return worldPoint(vector.x * cos - vector.y * sin, vector.x * sin + vector.y * cos);
148
+ }
149
+ function nearestSide(nx, ny) {
150
+ const distances = [
151
+ ["left", nx],
152
+ ["right", 1 - nx],
153
+ ["top", ny],
154
+ ["bottom", 1 - ny]
155
+ ];
156
+ distances.sort((a, b) => a[1] - b[1]);
157
+ return distances[0]?.[0] ?? "right";
158
+ }
159
+ /**
160
+ * A self-loop's path: a lobe leaving and re-entering the same node.
161
+ *
162
+ * Handled separately because every straight/curve formula degenerates when both
163
+ * endpoints coincide, and a self-relation is meaningful enough (a node that
164
+ * depends on itself, a retry edge) that dropping it would lose real data.
165
+ */
166
+ function selfLoopPath(frame, gap) {
167
+ const rect = frameRect(frame);
168
+ const size = Math.max(Math.min(rect.width, rect.height) * .45, 24);
169
+ const right = rect.x + rect.width + gap;
170
+ const top = rect.y - gap;
171
+ const midY = rect.y + rect.height * .3;
172
+ const midX = rect.x + rect.width * .7;
173
+ return [
174
+ worldPoint(midX, top),
175
+ worldPoint(midX, top - size),
176
+ worldPoint(right + size, top - size),
177
+ worldPoint(right + size, midY),
178
+ worldPoint(right, midY)
179
+ ];
180
+ }
181
+ function quadraticPoint(start, control, end, t) {
182
+ const mt = 1 - t;
183
+ return worldPoint(mt * mt * start.x + 2 * mt * t * control.x + t * t * end.x, mt * mt * start.y + 2 * mt * t * control.y + t * t * end.y);
184
+ }
185
+ /** Sample a quadratic curve into a polyline. */
186
+ function sampleQuadratic(start, control, end, segments) {
187
+ const out = [];
188
+ for (let index = 0; index <= segments; index += 1) out.push(quadraticPoint(start, control, end, index / segments));
189
+ return out;
190
+ }
191
+ /**
192
+ * Build an orthogonal elbow between two endpoints, leaving each along its normal.
193
+ *
194
+ * The turn happens on the axis each endpoint exits by, so the line never starts
195
+ * parallel to the edge it is attached to (which would read as touching the node
196
+ * rather than leaving it).
197
+ */
198
+ function orthogonalPath(source, target) {
199
+ const horizontalStart = Math.abs(source.normal.x) > Math.abs(source.normal.y);
200
+ const horizontalEnd = Math.abs(target.normal.x) > Math.abs(target.normal.y);
201
+ const a = source.point;
202
+ const b = target.point;
203
+ if (horizontalStart && horizontalEnd) {
204
+ const midX = a.x + (b.x - a.x) * ORTHOGONAL_SNAP;
205
+ return [
206
+ a,
207
+ worldPoint(midX, a.y),
208
+ worldPoint(midX, b.y),
209
+ b
210
+ ];
211
+ }
212
+ if (!horizontalStart && !horizontalEnd) {
213
+ const midY = a.y + (b.y - a.y) * ORTHOGONAL_SNAP;
214
+ return [
215
+ a,
216
+ worldPoint(a.x, midY),
217
+ worldPoint(b.x, midY),
218
+ b
219
+ ];
220
+ }
221
+ return horizontalStart ? [
222
+ a,
223
+ worldPoint(b.x, a.y),
224
+ b
225
+ ] : [
226
+ a,
227
+ worldPoint(a.x, b.y),
228
+ b
229
+ ];
230
+ }
231
+ /** Curve samples per connection. Enough to read as smooth at typical zoom. */
232
+ const CURVE_SEGMENTS = 20;
233
+ /**
234
+ * Resolve a connection into world-space geometry, or null if either node is gone.
235
+ *
236
+ * A null result means "not drawable right now", never "delete this": the caller
237
+ * may be looking at a viewport-culled read where an endpoint simply was not
238
+ * fetched.
239
+ */
240
+ function resolveConnection(connection, getFrame, options = {}) {
241
+ const sourceFrame = getFrame(connection.source.nodeId);
242
+ const targetFrame = getFrame(connection.target.nodeId);
243
+ if (!sourceFrame || !targetFrame) return null;
244
+ const gap = options.gap ?? 4;
245
+ if (connection.source.nodeId === connection.target.nodeId) {
246
+ const path = selfLoopPath(sourceFrame, gap);
247
+ const first = path[0];
248
+ const last = path[path.length - 1];
249
+ return {
250
+ source: {
251
+ point: first,
252
+ normal: worldPoint(0, -1),
253
+ side: "top"
254
+ },
255
+ target: {
256
+ point: last,
257
+ normal: worldPoint(1, 0),
258
+ side: "right"
259
+ },
260
+ path,
261
+ mid: path[Math.floor(path.length / 2)] ?? first
262
+ };
263
+ }
264
+ const source = resolveEndpoint(connection.source.anchor, sourceFrame, targetFrame, gap);
265
+ const target = resolveEndpoint(connection.target.anchor, targetFrame, sourceFrame, gap);
266
+ const waypoints = connection.routing.waypoints;
267
+ let path;
268
+ if (waypoints.length > 0) path = [
269
+ source.point,
270
+ ...waypoints.map((point) => worldPoint(point.x, point.y)),
271
+ target.point
272
+ ];
273
+ else if (connection.routing.kind === "orthogonal") path = orthogonalPath(source, target);
274
+ else if (connection.routing.kind === "straight" && !connection.routing.bend) path = [source.point, target.point];
275
+ else path = sampleQuadratic(source.point, curveControl(source, target, connection.routing.bend), target.point, CURVE_SEGMENTS);
276
+ return {
277
+ source,
278
+ target,
279
+ path,
280
+ mid: pathMidpoint(path)
281
+ };
282
+ }
283
+ /**
284
+ * Control point for a curved connection.
285
+ *
286
+ * With no explicit bend the bow is derived from the endpoint normals, so the line
287
+ * leaves each node perpendicular to its edge and reads as attached rather than
288
+ * merely adjacent. An explicit bend is a user-set bow and takes over entirely.
289
+ */
290
+ function curveControl(source, target, bend) {
291
+ const mid = worldPoint((source.point.x + target.point.x) / 2, (source.point.y + target.point.y) / 2);
292
+ const dx = target.point.x - source.point.x;
293
+ const dy = target.point.y - source.point.y;
294
+ const length = Math.hypot(dx, dy) || 1;
295
+ if (bend) {
296
+ const offset = bend * length;
297
+ return worldPoint(mid.x + -dy / length * offset, mid.y + dx / length * offset);
298
+ }
299
+ const nx = (source.normal.x + target.normal.x) / 2;
300
+ const ny = (source.normal.y + target.normal.y) / 2;
301
+ const magnitude = Math.hypot(nx, ny);
302
+ if (magnitude < 1e-4) return mid;
303
+ const strength = Math.min(length * .18, 96);
304
+ return worldPoint(mid.x + nx / magnitude * strength, mid.y + ny / magnitude * strength);
305
+ }
306
+ /** Midpoint by arc length, so a label sits visually centered on the line. */
307
+ function pathMidpoint(path) {
308
+ if (path.length === 0) return worldPoint(0, 0);
309
+ if (path.length === 1) return path[0];
310
+ let total = 0;
311
+ for (let index = 0; index < path.length - 1; index += 1) {
312
+ const from = path[index];
313
+ const to = path[index + 1];
314
+ total += Math.hypot(to.x - from.x, to.y - from.y);
315
+ }
316
+ let travelled = 0;
317
+ const half = total / 2;
318
+ for (let index = 0; index < path.length - 1; index += 1) {
319
+ const from = path[index];
320
+ const to = path[index + 1];
321
+ const segment = Math.hypot(to.x - from.x, to.y - from.y);
322
+ if (travelled + segment >= half) {
323
+ const t = segment > 0 ? (half - travelled) / segment : 0;
324
+ return worldPoint(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t);
325
+ }
326
+ travelled += segment;
327
+ }
328
+ return path[path.length - 1];
329
+ }
330
+ /** World bounds of a resolved connection, padded for its stroke. */
331
+ function connectionBounds(resolved, strokeSize) {
332
+ let minX = Number.POSITIVE_INFINITY;
333
+ let minY = Number.POSITIVE_INFINITY;
334
+ let maxX = Number.NEGATIVE_INFINITY;
335
+ let maxY = Number.NEGATIVE_INFINITY;
336
+ for (const point of resolved.path) {
337
+ minX = Math.min(minX, point.x);
338
+ minY = Math.min(minY, point.y);
339
+ maxX = Math.max(maxX, point.x);
340
+ maxY = Math.max(maxY, point.y);
341
+ }
342
+ const pad = Math.max(8, strokeSize * 2);
343
+ return {
344
+ x: minX - pad,
345
+ y: minY - pad,
346
+ width: Math.max(1, maxX - minX + pad * 2),
347
+ height: Math.max(1, maxY - minY + pad * 2)
348
+ };
349
+ }
350
+ /** Distance from a world point to a resolved connection's path. */
351
+ function distanceToConnection(resolved, point) {
352
+ let min = Number.POSITIVE_INFINITY;
353
+ for (let index = 0; index < resolved.path.length - 1; index += 1) {
354
+ const from = resolved.path[index];
355
+ const to = resolved.path[index + 1];
356
+ if (!from || !to) continue;
357
+ const distance = segmentDistance(point, from, to);
358
+ if (distance < min) min = distance;
359
+ }
360
+ return min;
361
+ }
362
+ function segmentDistance(p, a, b) {
363
+ const dx = b.x - a.x;
364
+ const dy = b.y - a.y;
365
+ const lengthSq = dx * dx + dy * dy;
366
+ if (lengthSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
367
+ const t = Math.min(1, Math.max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSq));
368
+ return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
369
+ }
370
+ /** Hit test a connection with a zoom-independent screen-space tolerance. */
371
+ function connectionHitTest(resolved, point, strokeSize) {
372
+ return distanceToConnection(resolved, point) <= Math.max(8, strokeSize * 2.5);
373
+ }
374
+ /** Whether the connection draws an arrowhead at its source / target. */
375
+ function connectionArrowheads(connection) {
376
+ switch (connection.direction) {
377
+ case "forward": return {
378
+ atSource: false,
379
+ atTarget: true
380
+ };
381
+ case "backward": return {
382
+ atSource: true,
383
+ atTarget: false
384
+ };
385
+ case "both": return {
386
+ atSource: true,
387
+ atTarget: true
388
+ };
389
+ default: return {
390
+ atSource: false,
391
+ atTarget: false
392
+ };
393
+ }
394
+ }
395
+ const NO_CONNECTIONS = [];
396
+ function createConnectionIndex(connections) {
397
+ const byNode = /* @__PURE__ */ new Map();
398
+ const byId = /* @__PURE__ */ new Map();
399
+ for (const connection of connections) {
400
+ byId.set(connection.id, connection);
401
+ for (const nodeId of /* @__PURE__ */ new Set([connection.source.nodeId, connection.target.nodeId])) {
402
+ const list = byNode.get(nodeId);
403
+ if (list) list.push(connection.id);
404
+ else byNode.set(nodeId, [connection.id]);
405
+ }
406
+ }
407
+ return {
408
+ byNode: (nodeId) => byNode.get(nodeId) ?? NO_CONNECTIONS,
409
+ get: (connectionId) => byId.get(connectionId),
410
+ all: connections
411
+ };
412
+ }
413
+ //#endregion
414
+ export { CONNECTION_ENDPOINT_GAP, anchorPointOnFrame, anchorToWorld, autoConnectionSide, connectionArrowheads, connectionBounds, connectionHitTest, createConnectionIndex, distanceToConnection, pathMidpoint, resolveConnection, worldToAnchor };
@@ -1,6 +1,7 @@
1
+ import { BoardConnection } from "../../protocol/dist/board-connection.js";
1
2
  import { BoardDocument, BoardItem } from "../../protocol/dist/board-document.js";
2
3
  import { Rect } from "../geometry.js";
3
- import { FrameLookup } from "./bindings.js";
4
+ import { FrameLookup } from "./connections.js";
4
5
  //#region src/board/core/export-plan.d.ts
5
6
  /** What part of the board to capture. */
6
7
  type BoardExportRegion =
@@ -45,6 +46,14 @@ type BoardExportPlan = {
45
46
  height: number;
46
47
  /** Items to draw, in document order. */
47
48
  items: BoardItem[];
49
+ /**
50
+ * Connections to draw.
51
+ *
52
+ * A relation is only included when both of its nodes are in `items`: half a
53
+ * connection would render as a line into empty space, which reads as a defect
54
+ * rather than as a clipped edge.
55
+ */
56
+ connections: BoardConnection[];
48
57
  /** True when `scale` had to be reduced to fit the size budget. */
49
58
  clamped: boolean;
50
59
  };
@@ -73,12 +82,14 @@ declare const BOARD_EXPORT_DEFAULT_PADDING = 32;
73
82
  /** Above this, an export is still produced but the caller is warned. */
74
83
  declare const BOARD_EXPORT_ITEM_WARN_THRESHOLD = 2000;
75
84
  declare function boardFrameLookup(document: BoardDocument): FrameLookup;
76
- /** Bounds of a single item, resolving arrow endpoints through their bindings. */
77
- declare function exportItemBounds(item: BoardItem, getFrame: FrameLookup): Rect;
85
+ /** Bounds of a single item. Arrows resolve through their own curve geometry. */
86
+ declare function exportItemBounds(item: BoardItem, _getFrame?: FrameLookup): Rect;
87
+ /** Bounds of a connection, resolved against the document's node frames. */
88
+ declare function exportConnectionBounds(connection: BoardConnection, getFrame: FrameLookup): Rect | null;
78
89
  /**
79
90
  * Resolve a region into a concrete capture plan, or null when there is nothing
80
91
  * to draw. Callers treat null as "empty selection", not as an error.
81
92
  */
82
93
  declare function planBoardExport(input: BoardExportPlanInput): BoardExportPlan | null;
83
94
  //#endregion
84
- export { BOARD_EXPORT_DEFAULT_PADDING, BOARD_EXPORT_DEFAULT_SCALE, BOARD_EXPORT_ITEM_WARN_THRESHOLD, BOARD_EXPORT_MAX_EDGE, BOARD_EXPORT_MAX_PIXELS, BoardExportPlan, BoardExportPlanInput, BoardExportRegion, boardFrameLookup, exportItemBounds, normalizeBoardDocument, planBoardExport };
95
+ export { BOARD_EXPORT_DEFAULT_PADDING, BOARD_EXPORT_DEFAULT_SCALE, BOARD_EXPORT_ITEM_WARN_THRESHOLD, BOARD_EXPORT_MAX_EDGE, BOARD_EXPORT_MAX_PIXELS, BoardExportPlan, BoardExportPlanInput, BoardExportRegion, boardFrameLookup, exportConnectionBounds, exportItemBounds, normalizeBoardDocument, planBoardExport };
@@ -1,6 +1,7 @@
1
- import { BoardDocumentSchema } from "../../protocol/dist/board-document.js";
1
+ import { parseBoardDocument } from "../../protocol/dist/board-document.js";
2
2
  import { itemBounds, rectsIntersect, unionRects } from "../geometry.js";
3
- import { arrowBounds } from "./bindings.js";
3
+ import { arrowBounds } from "./arrow-geometry.js";
4
+ import { connectionBounds, resolveConnection } from "./connections.js";
4
5
  //#region src/board/core/export-plan.ts
5
6
  /**
6
7
  * Export planning — pure geometry, no renderer.
@@ -20,7 +21,7 @@ import { arrowBounds } from "./bindings.js";
20
21
  * error instead of a render-time crash.
21
22
  */
22
23
  function normalizeBoardDocument(document) {
23
- return BoardDocumentSchema.parse(document);
24
+ return parseBoardDocument(document);
24
25
  }
25
26
  /**
26
27
  * Hard ceilings.
@@ -40,24 +41,41 @@ function boardFrameLookup(document) {
40
41
  const frames = new Map(document.items.map((item) => [item.id, item.frame]));
41
42
  return (id) => frames.get(id);
42
43
  }
43
- /** Bounds of a single item, resolving arrow endpoints through their bindings. */
44
- function exportItemBounds(item, getFrame) {
45
- if (item.type === "arrow") return arrowBounds(item, getFrame) ?? itemBounds(item.frame);
44
+ /** Bounds of a single item. Arrows resolve through their own curve geometry. */
45
+ function exportItemBounds(item, _getFrame) {
46
+ if (item.type === "arrow") return arrowBounds(item);
46
47
  return itemBounds(item.frame);
47
48
  }
49
+ /** Bounds of a connection, resolved against the document's node frames. */
50
+ function exportConnectionBounds(connection, getFrame) {
51
+ const resolved = resolveConnection(connection, getFrame);
52
+ return resolved ? connectionBounds(resolved, connection.style.size) : null;
53
+ }
54
+ /** Connections whose endpoints are both inside the given item set. */
55
+ function connectionsWithin(document, items) {
56
+ if (document.connections.length === 0) return [];
57
+ const present = new Set(items.map((item) => item.id));
58
+ return document.connections.filter((connection) => present.has(connection.source.nodeId) && present.has(connection.target.nodeId));
59
+ }
48
60
  function resolveRegion(document, region, getFrame) {
49
61
  switch (region.kind) {
50
- case "all": return {
51
- items: document.items,
52
- rect: unionRects(document.items.map((item) => exportItemBounds(item, getFrame))),
53
- padding: null
54
- };
62
+ case "all": {
63
+ const connections = document.connections;
64
+ return {
65
+ items: document.items,
66
+ connections,
67
+ rect: unionRects([...document.items.map((item) => exportItemBounds(item)), ...connectionBoundsList(connections, getFrame)]),
68
+ padding: null
69
+ };
70
+ }
55
71
  case "items": {
56
72
  const wanted = new Set(region.ids);
57
73
  const items = document.items.filter((item) => wanted.has(item.id));
74
+ const connections = connectionsWithin(document, items);
58
75
  return {
59
76
  items,
60
- rect: unionRects(items.map((item) => exportItemBounds(item, getFrame))),
77
+ connections,
78
+ rect: unionRects([...items.map((item) => exportItemBounds(item)), ...connectionBoundsList(connections, getFrame)]),
61
79
  padding: null
62
80
  };
63
81
  }
@@ -65,26 +83,39 @@ function resolveRegion(document, region, getFrame) {
65
83
  const frame = document.items.find((item) => item.id === region.id);
66
84
  if (!frame) return {
67
85
  items: [],
86
+ connections: [],
68
87
  rect: null,
69
88
  padding: null
70
89
  };
71
90
  const rect = itemBounds(frame.frame);
91
+ const items = document.items.filter((item) => item.id !== region.id && rectsIntersect(exportItemBounds(item), rect));
72
92
  return {
73
- items: document.items.filter((item) => item.id !== region.id && rectsIntersect(exportItemBounds(item, getFrame), rect)),
93
+ items,
94
+ connections: connectionsWithin(document, items),
74
95
  rect,
75
96
  padding: 0
76
97
  };
77
98
  }
78
99
  case "rect": {
79
100
  const rect = region.rect;
101
+ const items = document.items.filter((item) => rectsIntersect(exportItemBounds(item), rect));
80
102
  return {
81
- items: document.items.filter((item) => rectsIntersect(exportItemBounds(item, getFrame), rect)),
103
+ items,
104
+ connections: connectionsWithin(document, items),
82
105
  rect,
83
106
  padding: 0
84
107
  };
85
108
  }
86
109
  }
87
110
  }
111
+ function connectionBoundsList(connections, getFrame) {
112
+ const rects = [];
113
+ for (const connection of connections) {
114
+ const bounds = exportConnectionBounds(connection, getFrame);
115
+ if (bounds) rects.push(bounds);
116
+ }
117
+ return rects;
118
+ }
88
119
  /**
89
120
  * Largest scale that keeps the output inside both the edge and pixel budgets.
90
121
  *
@@ -102,7 +133,8 @@ function clampScale(requested, world, maxEdge, maxPixels) {
102
133
  * to draw. Callers treat null as "empty selection", not as an error.
103
134
  */
104
135
  function planBoardExport(input) {
105
- const { document, region, scale: requestedScale = 2, maxEdge = BOARD_EXPORT_MAX_EDGE, maxPixels = BOARD_EXPORT_MAX_PIXELS } = input;
136
+ const { region, scale: requestedScale = 2, maxEdge = BOARD_EXPORT_MAX_EDGE, maxPixels = BOARD_EXPORT_MAX_PIXELS } = input;
137
+ const document = normalizeBoardDocument(input.document);
106
138
  const resolved = resolveRegion(document, region, boardFrameLookup(document));
107
139
  if (!resolved.rect) return null;
108
140
  const padding = input.padding ?? resolved.padding ?? 32;
@@ -121,8 +153,9 @@ function planBoardExport(input) {
121
153
  width: Math.max(1, Math.floor(world.width * scale)),
122
154
  height: Math.max(1, Math.floor(world.height * scale)),
123
155
  items: resolved.items,
156
+ connections: resolved.connections,
124
157
  clamped: scale < safeRequest - 1e-6
125
158
  };
126
159
  }
127
160
  //#endregion
128
- export { BOARD_EXPORT_DEFAULT_PADDING, BOARD_EXPORT_DEFAULT_SCALE, BOARD_EXPORT_ITEM_WARN_THRESHOLD, BOARD_EXPORT_MAX_EDGE, BOARD_EXPORT_MAX_PIXELS, boardFrameLookup, exportItemBounds, normalizeBoardDocument, planBoardExport };
161
+ export { BOARD_EXPORT_DEFAULT_PADDING, BOARD_EXPORT_DEFAULT_SCALE, BOARD_EXPORT_ITEM_WARN_THRESHOLD, BOARD_EXPORT_MAX_EDGE, BOARD_EXPORT_MAX_PIXELS, boardFrameLookup, exportConnectionBounds, exportItemBounds, normalizeBoardDocument, planBoardExport };
@@ -34,7 +34,7 @@ const unknownShapeDefinition = {
34
34
  aspectLocked: false,
35
35
  canRotate: true,
36
36
  canEdit: false,
37
- canBind: true,
37
+ canConnect: true,
38
38
  canSnap: true,
39
39
  canLock: true
40
40
  }
@@ -49,8 +49,14 @@ type ShapeCapabilities = {
49
49
  canRotate: boolean;
50
50
  /** Supports double-click inline editing. */
51
51
  canEdit: boolean;
52
- /** Can be a binding target for arrows. */
53
- canBind: boolean;
52
+ /**
53
+ * Can be an endpoint of a connection.
54
+ *
55
+ * False for shapes that are themselves annotation strokes (draw, arrow): a
56
+ * relation between two scribbles has no meaning the model could express, and
57
+ * allowing it would produce edges no reader could interpret.
58
+ */
59
+ canConnect: boolean;
54
60
  /** Participates in snapping as a target. */
55
61
  canSnap: boolean;
56
62
  /** Can be locked against accidental edits. */
@@ -9,7 +9,7 @@ const FULL_CAPABILITIES = {
9
9
  aspectLocked: false,
10
10
  canRotate: true,
11
11
  canEdit: false,
12
- canBind: true,
12
+ canConnect: true,
13
13
  canSnap: true,
14
14
  canLock: true
15
15
  };
@@ -1,8 +1,7 @@
1
+ import { BOARD_STROKE_MAX_SIZE, BOARD_STROKE_MIN_SIZE, clampBoardStrokeSize } from "../../protocol/dist/board-constants.js";
1
2
  import { BoardColorId } from "./palette.js";
2
3
  import { GeoKind } from "./shape-types.js";
3
4
  //#region src/board/core/tool-styles.d.ts
4
- declare const BOARD_STROKE_MIN_SIZE = 1;
5
- declare const BOARD_STROKE_MAX_SIZE = 64;
6
5
  type BoardToolStyleMap = {
7
6
  text: {
8
7
  color: BoardColorId;
@@ -19,6 +18,10 @@ type BoardToolStyleMap = {
19
18
  color: BoardColorId;
20
19
  size: number;
21
20
  };
21
+ connection: {
22
+ color: BoardColorId;
23
+ size: number;
24
+ };
22
25
  frame: {
23
26
  color: BoardColorId;
24
27
  };
@@ -41,11 +44,14 @@ declare const DEFAULT_BOARD_TOOL_STYLES: {
41
44
  readonly color: "brand";
42
45
  readonly size: 2.5;
43
46
  };
47
+ readonly connection: {
48
+ readonly color: "neutral";
49
+ readonly size: 2.5;
50
+ };
44
51
  readonly frame: {
45
52
  readonly color: "neutral";
46
53
  };
47
54
  };
48
- declare function clampBoardStrokeSize(size: number): number;
49
55
  /** Return a mutable, validated style map for an editor or another Board client. */
50
56
  declare function createBoardToolStyles(patch?: BoardToolStylePatch): BoardToolStyleMap;
51
57
  //#endregion