@neta-art/cohub 3.1.0 → 4.0.0

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 (94) hide show
  1. package/README.md +33 -2
  2. package/dist/{board.d.ts → board/animation.d.ts} +4 -2
  3. package/dist/{chunks/board.js → board/animation.js} +5 -44
  4. package/dist/board/codec.d.ts +25 -0
  5. package/dist/board/codec.js +243 -0
  6. package/dist/board/core/bindings.d.ts +45 -0
  7. package/dist/board/core/bindings.js +162 -0
  8. package/dist/board/core/draw-geometry.d.ts +30 -0
  9. package/dist/board/core/draw-geometry.js +175 -0
  10. package/dist/board/core/export-assets.d.ts +14 -0
  11. package/dist/board/core/export-assets.js +22 -0
  12. package/dist/board/core/export-plan.d.ts +84 -0
  13. package/dist/board/core/export-plan.js +128 -0
  14. package/dist/board/core/file-preview.d.ts +178 -0
  15. package/dist/board/core/file-preview.js +282 -0
  16. package/dist/board/core/palette.d.ts +42 -0
  17. package/dist/board/core/palette.js +166 -0
  18. package/dist/board/core/shape-definition.d.ts +35 -0
  19. package/dist/board/core/shape-definition.js +64 -0
  20. package/dist/board/core/shape-types.d.ts +140 -0
  21. package/dist/board/core/shape-types.js +27 -0
  22. package/dist/board/core/text-metrics.d.ts +18 -0
  23. package/dist/board/core/text-metrics.js +43 -0
  24. package/dist/board/core/tool-styles.d.ts +52 -0
  25. package/dist/board/core/tool-styles.js +51 -0
  26. package/dist/board/export/index.d.ts +56 -0
  27. package/dist/board/export/index.js +86 -0
  28. package/dist/board/export/scene.d.ts +35 -0
  29. package/dist/board/export/scene.js +69 -0
  30. package/dist/board/geometry.d.ts +139 -0
  31. package/dist/board/geometry.js +492 -0
  32. package/dist/board/headless/index.d.ts +69 -0
  33. package/dist/board/headless/index.js +150 -0
  34. package/dist/board/image-key.d.ts +19 -0
  35. package/dist/board/image-key.js +34 -0
  36. package/dist/board/index.d.ts +17 -0
  37. package/dist/board/index.js +17 -0
  38. package/dist/board/render/index.d.ts +6 -0
  39. package/dist/board/render/index.js +6 -0
  40. package/dist/board/render/palette.d.ts +5 -0
  41. package/dist/board/render/palette.js +30 -0
  42. package/dist/board/render/renderers/arrow-card-renderer.d.ts +5 -0
  43. package/dist/board/render/renderers/arrow-card-renderer.js +157 -0
  44. package/dist/board/render/renderers/base-card-renderer.d.ts +46 -0
  45. package/dist/board/render/renderers/base-card-renderer.js +153 -0
  46. package/dist/board/render/renderers/board-renderer-registry.d.ts +85 -0
  47. package/dist/board/render/renderers/board-renderer-registry.js +41 -0
  48. package/dist/board/render/renderers/draw-card-renderer.d.ts +5 -0
  49. package/dist/board/render/renderers/draw-card-renderer.js +82 -0
  50. package/dist/board/render/renderers/far-plate.d.ts +39 -0
  51. package/dist/board/render/renderers/far-plate.js +92 -0
  52. package/dist/board/render/renderers/file-card-renderer.d.ts +40 -0
  53. package/dist/board/render/renderers/file-card-renderer.js +350 -0
  54. package/dist/board/render/renderers/frame-card-renderer.d.ts +5 -0
  55. package/dist/board/render/renderers/frame-card-renderer.js +99 -0
  56. package/dist/board/render/renderers/geo-card-renderer.d.ts +5 -0
  57. package/dist/board/render/renderers/geo-card-renderer.js +128 -0
  58. package/dist/board/render/renderers/image-card-renderer.d.ts +8 -0
  59. package/dist/board/render/renderers/image-card-renderer.js +138 -0
  60. package/dist/board/render/renderers/text-card-renderer.d.ts +7 -0
  61. package/dist/board/render/renderers/text-card-renderer.js +94 -0
  62. package/dist/board/render/renderers/unknown-card-renderer.d.ts +5 -0
  63. package/dist/board/render/renderers/unknown-card-renderer.js +102 -0
  64. package/dist/board/render/renderers/video-card-renderer.d.ts +7 -0
  65. package/dist/board/render/renderers/video-card-renderer.js +146 -0
  66. package/dist/board/render/text-measurement.d.ts +30 -0
  67. package/dist/board/render/text-measurement.js +57 -0
  68. package/dist/board/render/text-resolution.d.ts +16 -0
  69. package/dist/board/render/text-resolution.js +43 -0
  70. package/dist/board/render/themes/board-theme-registry.d.ts +22 -0
  71. package/dist/board/render/themes/board-theme-registry.js +13 -0
  72. package/dist/board/render/themes/clean-theme.d.ts +5 -0
  73. package/dist/board/render/themes/clean-theme.js +105 -0
  74. package/dist/chunks/http.d.ts +2 -3
  75. package/dist/chunks/http.js +4 -0
  76. package/dist/chunks/websocket.d.ts +851 -353
  77. package/dist/http.d.ts +1 -2
  78. package/dist/index.d.ts +100 -5
  79. package/dist/index.js +391 -1
  80. package/dist/protocol/dist/board-constants.d.ts +23 -0
  81. package/dist/protocol/dist/board-constants.js +61 -0
  82. package/dist/protocol/dist/board-document.d.ts +1047 -0
  83. package/dist/protocol/dist/board-document.js +321 -0
  84. package/dist/protocol/dist/board.d.ts +186 -0
  85. package/dist/protocol/dist/board.js +207 -0
  86. package/dist/protocol/dist/index.d.ts +5 -0
  87. package/dist/protocol/dist/index.js +5 -0
  88. package/dist/protocol/dist/provenance.js +12 -0
  89. package/dist/protocol/dist/realtime/board-awareness.d.ts +1 -0
  90. package/dist/protocol/dist/realtime/board-awareness.js +119 -0
  91. package/dist/protocol/dist/realtime/types.d.ts +2 -0
  92. package/package.json +40 -4
  93. package/dist/board.js +0 -2
  94. package/dist/chunks/board.d.ts +0 -2589
@@ -0,0 +1,350 @@
1
+ import { BOARD_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
+ import { fileBaseName, filePreviewKind } from "../../core/file-preview.js";
3
+ import { syncTextResolution } from "../text-resolution.js";
4
+ import { drawFarPlate } from "./far-plate.js";
5
+ import { positionShell } from "./base-card-renderer.js";
6
+ import { CanvasTextMetrics, Container, Graphics, Sprite, Text, Texture } from "pixi.js";
7
+ //#region src/board/render/renderers/file-card-renderer.ts
8
+ /**
9
+ * File card renderer.
10
+ *
11
+ * The card is an *entry point* to a workspace file, so it shows only what helps
12
+ * you recognise and pick it: an optional cover, the name, a couple of lines of
13
+ * excerpt. Everything else belongs to the file preview that opens
14
+ * when the card is activated.
15
+ *
16
+ * Cost is managed through three levels of detail keyed on zoom, because a board
17
+ * is expected to hold thousands of these. Pixi `Text` is a rasterised texture,
18
+ * so below roughly one CSS pixel per glyph it can only ever resolve to a smudge;
19
+ * the tiers below simply stop paying for what cannot be read anyway.
20
+ *
21
+ * Absolute floor: content never paints outside the node frame. A rounded clip
22
+ * mask is the hard boundary; line-clamped title/excerpt keep the interior tidy.
23
+ */
24
+ const RADIUS = 4;
25
+ const PADDING = 10;
26
+ const COVER_RATIO = .56;
27
+ /** Minimum body height reserved under a cover so the title stays readable. */
28
+ const MIN_BODY_FOR_COVER = 38;
29
+ const TITLE_SIZE = 13;
30
+ const TITLE_LINE = TITLE_SIZE * 1.35;
31
+ const TITLE_MAX_LINES = 2;
32
+ const EXCERPT_SIZE = 11;
33
+ const EXCERPT_LINE = EXCERPT_SIZE * 1.45;
34
+ const EXCERPT_MAX_LINES = 4;
35
+ const GAP = 4;
36
+ /** Zoom below which the title is dropped (glyphs are sub-pixel). */
37
+ const LOD_TITLE_ZOOM = .35;
38
+ /** Zoom below which the excerpt is dropped. */
39
+ const LOD_BODY_ZOOM = .6;
40
+ /** Upper bound on dashes in the unavailable-state edge, regardless of width. */
41
+ const MAX_DASHES = 40;
42
+ const partsByContainer = /* @__PURE__ */ new WeakMap();
43
+ /** Detail tier for a zoom level. */
44
+ function detailFor(zoom) {
45
+ if (zoom < LOD_TITLE_ZOOM) return "plate";
46
+ if (zoom < LOD_BODY_ZOOM) return "title";
47
+ return "full";
48
+ }
49
+ /** Cover band height for a card, or 0 when it has no cover. */
50
+ function coverHeight(item, height) {
51
+ if (filePreviewKind(item.snapshot) !== "cover") return 0;
52
+ const ideal = Math.round(height * COVER_RATIO);
53
+ return Math.max(0, Math.min(ideal, height - MIN_BODY_FOR_COVER));
54
+ }
55
+ /**
56
+ * Join already-wrapped lines and append an ellipsis to the last one.
57
+ * Pure helper so the clamp can be unit-tested without a Pixi canvas.
58
+ */
59
+ function ellipsizeWrappedLines(lines, maxLines) {
60
+ if (maxLines <= 0 || lines.length === 0) return "";
61
+ if (lines.length <= maxLines) return lines.join("\n");
62
+ const kept = lines.slice(0, maxLines);
63
+ const lastIndex = kept.length - 1;
64
+ const last = (kept[lastIndex] ?? "").replace(/\s+$/u, "");
65
+ kept[lastIndex] = last ? `${last}…` : "…";
66
+ return kept.join("\n");
67
+ }
68
+ /**
69
+ * Shorten `line` so `line + "…"` fits in `wrapWidth`.
70
+ *
71
+ * Binary search over the prefix: O(log n) single-line measures. Callers pass a
72
+ * measure callback so this stays free of Pixi types and easy to unit-test.
73
+ */
74
+ function fitLineWithEllipsis(line, wrapWidth, measureWidth) {
75
+ const trimmed = line.replace(/\s+$/u, "");
76
+ if (!trimmed) return "…";
77
+ const full = `${trimmed}…`;
78
+ if (measureWidth(full) <= wrapWidth) return full;
79
+ let lo = 0;
80
+ let hi = trimmed.length;
81
+ let best = "…";
82
+ while (lo <= hi) {
83
+ const mid = lo + hi >> 1;
84
+ const candidate = mid > 0 ? `${trimmed.slice(0, mid)}…` : "…";
85
+ if (measureWidth(candidate) <= wrapWidth) {
86
+ best = candidate;
87
+ lo = mid + 1;
88
+ } else hi = mid - 1;
89
+ }
90
+ return best;
91
+ }
92
+ /**
93
+ * Fit a Pixi text node into a fixed line budget.
94
+ *
95
+ * Pixi Text has no built-in max-lines / ellipsis, so we measure with the same
96
+ * style the node will render, keep the leading wrapped lines, and mark a cut
97
+ * with an ellipsis. Returning early when everything fits avoids an extra
98
+ * texture rebuild on the common path.
99
+ *
100
+ * Cost is one wrap of a length-capped sample, plus at most a binary search on
101
+ * the last kept line. No character-by-character multi-line remeasure.
102
+ */
103
+ function fitTextToLines(text, value, maxLines, wrapWidth) {
104
+ const width = Math.max(1, wrapWidth);
105
+ text.style.wordWrapWidth = width;
106
+ if (!value || maxLines <= 0) {
107
+ if (text.text !== "") text.text = "";
108
+ return;
109
+ }
110
+ const charCap = Math.max(maxLines * Math.ceil(width), maxLines * 4);
111
+ const sample = value.length > charCap ? value.slice(0, charCap) : value;
112
+ const metrics = CanvasTextMetrics.measureText(sample, text.style);
113
+ if (!(metrics.lines.length > maxLines || sample !== value)) {
114
+ if (text.text !== value) text.text = value;
115
+ return;
116
+ }
117
+ const keptCount = Math.min(maxLines, metrics.lines.length);
118
+ const kept = metrics.lines.slice(0, keptCount);
119
+ const lastIndex = kept.length - 1;
120
+ const probe = text.style.clone();
121
+ probe.wordWrap = false;
122
+ kept[lastIndex] = fitLineWithEllipsis(kept[lastIndex] ?? "", width, (candidate) => CanvasTextMetrics.measureText(candidate, probe).width);
123
+ const fitted = kept.join("\n");
124
+ if (text.text !== fitted) text.text = fitted;
125
+ }
126
+ /** How many full lines of `lineHeight` fit in `room`, capped at `max`. */
127
+ function linesInRoom(room, lineHeight, max) {
128
+ if (room < lineHeight * .85) return 0;
129
+ return Math.max(0, Math.min(max, Math.floor((room + .5) / lineHeight)));
130
+ }
131
+ /** Scale an image into a fixed cover band without cropping or distortion. */
132
+ function containCoverRect(width, height, imageWidth, imageHeight) {
133
+ if (width <= 0 || height <= 0 || imageWidth <= 0 || imageHeight <= 0) return {
134
+ x: 0,
135
+ y: 0,
136
+ width: 0,
137
+ height: 0
138
+ };
139
+ const scale = Math.min(width / imageWidth, height / imageHeight);
140
+ const renderedWidth = imageWidth * scale;
141
+ const renderedHeight = imageHeight * scale;
142
+ return {
143
+ x: (width - renderedWidth) / 2,
144
+ y: (height - renderedHeight) / 2,
145
+ width: renderedWidth,
146
+ height: renderedHeight
147
+ };
148
+ }
149
+ function layoutCover(sprite, width, height, texture) {
150
+ const rect = containCoverRect(width, height, texture.width, texture.height);
151
+ sprite.position.set(rect.x, rect.y);
152
+ sprite.width = rect.width;
153
+ sprite.height = rect.height;
154
+ }
155
+ function syncClip(parts, width, height) {
156
+ parts.clip.clear().roundRect(0, 0, Math.max(1, width), Math.max(1, height), RADIUS).fill({ color: 16777215 });
157
+ }
158
+ function sync(container, item, context) {
159
+ const parts = partsByContainer.get(container);
160
+ if (!parts) return;
161
+ positionShell(parts.root, item);
162
+ const { width, height } = item.frame;
163
+ const selected = context.selectedIds.has(item.id);
164
+ const hovered = context.hoveredId === item.id;
165
+ const detail = detailFor(context.zoom);
166
+ const key = context.assetKey(item);
167
+ const texture = key ? context.getTexture(key) : null;
168
+ const coverFailed = Boolean(key && !texture && context.hasError(key));
169
+ const fileState = context.fileState(item.ref.path);
170
+ const band = coverHeight(item, height);
171
+ syncTextResolution(parts.title, parts.titleRes, context.zoom);
172
+ syncTextResolution(parts.excerpt, parts.excerptRes, context.zoom);
173
+ const visualSig = [
174
+ key ?? "",
175
+ width,
176
+ height,
177
+ selected,
178
+ hovered,
179
+ detail,
180
+ texture ? `${texture.width}x${texture.height}` : "none",
181
+ coverFailed,
182
+ fileState,
183
+ context.colorScheme,
184
+ context.palette.surface,
185
+ context.palette.hover
186
+ ].join("|");
187
+ if (visualSig !== parts.visualSig) {
188
+ parts.visualSig = visualSig;
189
+ syncClip(parts, width, height);
190
+ parts.plate.clear();
191
+ parts.plate.roundRect(0, 0, width, height, RADIUS).fill({
192
+ color: context.palette.surface,
193
+ alpha: .98
194
+ }).roundRect(0, 0, width, height, RADIUS).stroke({
195
+ color: selected ? context.palette.brand : hovered ? context.palette.muted : context.palette.border,
196
+ width: selected ? 2 : 1,
197
+ alpha: selected ? .95 : .85
198
+ });
199
+ if (fileState !== "ok") {
200
+ const dash = Math.max(6, width / MAX_DASHES / 2);
201
+ for (let x = 0; x < width; x += dash * 2) parts.plate.moveTo(x, .5).lineTo(Math.min(x + dash, width), .5);
202
+ parts.plate.stroke({
203
+ color: context.palette.muted,
204
+ width: 2,
205
+ alpha: fileState === "missing" ? .9 : .5
206
+ });
207
+ }
208
+ const showCover = band > 0 && Boolean(texture);
209
+ if (band > 0) parts.plate.rect(1, 1, width - 2, band - 1).fill({
210
+ color: context.palette.hover,
211
+ alpha: coverFailed ? .35 : .6
212
+ });
213
+ if (showCover && texture) {
214
+ if (parts.cover.texture !== texture) parts.cover.texture = texture;
215
+ layoutCover(parts.cover, width, band, texture);
216
+ parts.coverMask.clear().roundRect(0, 0, width, band, RADIUS).fill({ color: 16777215 });
217
+ parts.coverMask.rect(0, band - RADIUS, width, RADIUS).fill({ color: 16777215 });
218
+ }
219
+ parts.cover.visible = showCover;
220
+ parts.coverMask.visible = showCover;
221
+ if (!band) parts.plate.rect(1, 1, 2, height - 2).fill({
222
+ color: context.palette.muted,
223
+ alpha: .35
224
+ });
225
+ parts.title.visible = detail !== "plate";
226
+ parts.excerpt.visible = detail === "full";
227
+ }
228
+ if (detail === "plate") return;
229
+ const title = item.snapshot?.title || fileBaseName(item.ref.path);
230
+ const excerpt = item.snapshot?.excerpt ?? "";
231
+ const innerWidth = Math.max(1, width - PADDING * 2);
232
+ const textSig = [
233
+ title,
234
+ excerpt,
235
+ detail,
236
+ innerWidth,
237
+ band,
238
+ height,
239
+ context.palette.text,
240
+ context.palette.muted
241
+ ].join("|");
242
+ if (textSig === parts.textSig) return;
243
+ parts.textSig = textSig;
244
+ const top = band > 0 ? band + PADDING * .8 : PADDING;
245
+ const contentBottom = height - PADDING;
246
+ const titleLines = linesInRoom(Math.max(0, contentBottom - top), TITLE_LINE, TITLE_MAX_LINES);
247
+ parts.title.style.fill = context.palette.text;
248
+ fitTextToLines(parts.title, title, titleLines, innerWidth);
249
+ parts.title.position.set(PADDING, top);
250
+ parts.title.visible = titleLines > 0;
251
+ if (detail !== "full") {
252
+ parts.excerpt.visible = false;
253
+ return;
254
+ }
255
+ const excerptTop = top + (titleLines > 0 ? parts.title.height + GAP : 0);
256
+ const excerptLines = linesInRoom(contentBottom - excerptTop, EXCERPT_LINE, EXCERPT_MAX_LINES);
257
+ const showExcerpt = Boolean(excerpt) && excerptLines > 0;
258
+ if (showExcerpt) {
259
+ parts.excerpt.style.fill = context.palette.muted;
260
+ fitTextToLines(parts.excerpt, excerpt, excerptLines, innerWidth);
261
+ parts.excerpt.position.set(PADDING, excerptTop);
262
+ }
263
+ parts.excerpt.visible = showExcerpt;
264
+ }
265
+ const fileCardRenderer = {
266
+ id: "file-card",
267
+ canRender: (item) => item.type === "file",
268
+ create: (item, context) => {
269
+ const root = new Container();
270
+ const plate = new Graphics();
271
+ const body = new Container();
272
+ const clip = new Graphics();
273
+ const cover = new Sprite(Texture.EMPTY);
274
+ const coverMask = new Graphics();
275
+ cover.mask = coverMask;
276
+ const resolution = 1;
277
+ const title = new Text({
278
+ text: "",
279
+ style: {
280
+ fill: context.palette.text,
281
+ fontFamily: BOARD_FONT_STACK,
282
+ fontSize: TITLE_SIZE,
283
+ fontWeight: "600",
284
+ wordWrap: true,
285
+ breakWords: true,
286
+ lineHeight: TITLE_LINE
287
+ },
288
+ resolution,
289
+ roundPixels: true
290
+ });
291
+ const excerpt = new Text({
292
+ text: "",
293
+ style: {
294
+ fill: context.palette.muted,
295
+ fontFamily: BOARD_FONT_STACK,
296
+ fontSize: EXCERPT_SIZE,
297
+ fontWeight: "400",
298
+ wordWrap: true,
299
+ breakWords: true,
300
+ lineHeight: EXCERPT_LINE
301
+ },
302
+ resolution,
303
+ roundPixels: true
304
+ });
305
+ body.mask = clip;
306
+ body.addChild(cover, coverMask, title, excerpt);
307
+ root.addChild(plate, body, clip);
308
+ partsByContainer.set(root, {
309
+ root,
310
+ plate,
311
+ body,
312
+ clip,
313
+ cover,
314
+ coverMask,
315
+ title,
316
+ excerpt,
317
+ visualSig: "",
318
+ textSig: "",
319
+ titleRes: { resolution },
320
+ excerptRes: { resolution }
321
+ });
322
+ if (item.type === "file") sync(root, item, context);
323
+ return root;
324
+ },
325
+ update: (container, item, context) => {
326
+ if (item.type === "file") sync(container, item, context);
327
+ },
328
+ /**
329
+ * Far LOD: a plate with a muted accent band. Sampling the real cover here
330
+ * would mean one draw call per distinct image and defeat the batch.
331
+ */
332
+ renderFar: (graphics, item, context) => {
333
+ drawFarPlate(graphics, item.frame, {
334
+ fill: context.palette.surface,
335
+ fillAlpha: .96,
336
+ accent: context.palette.muted,
337
+ accentAlpha: .4
338
+ });
339
+ },
340
+ destroy: (container) => {
341
+ partsByContainer.get(container)?.root.destroy({ children: true });
342
+ partsByContainer.delete(container);
343
+ }
344
+ };
345
+ /** Helper kept for type narrowing in tests. */
346
+ function isFileItem(item) {
347
+ return item.type === "file";
348
+ }
349
+ //#endregion
350
+ export { containCoverRect, ellipsizeWrappedLines, fileCardRenderer, fitLineWithEllipsis, fitTextToLines, isFileItem };
@@ -0,0 +1,5 @@
1
+ import { BoardCardRenderer } from "./board-renderer-registry.js";
2
+ //#region src/board/render/renderers/frame-card-renderer.d.ts
3
+ declare const frameCardRenderer: BoardCardRenderer;
4
+ //#endregion
5
+ export { frameCardRenderer };
@@ -0,0 +1,99 @@
1
+ import { BOARD_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
+ import { pickBoardColor } from "../../core/palette.js";
3
+ import { syncTextResolution } from "../text-resolution.js";
4
+ import { drawFarPlate } from "./far-plate.js";
5
+ import { createLabel, positionShell } from "./base-card-renderer.js";
6
+ import { Container, Graphics } from "pixi.js";
7
+ //#region src/board/render/renderers/frame-card-renderer.ts
8
+ const partsByContainer = /* @__PURE__ */ new WeakMap();
9
+ function sync(container, item, context) {
10
+ const parts = partsByContainer.get(container);
11
+ if (!parts || item.type !== "frame") return;
12
+ const { frame, label, color, locked } = item;
13
+ positionShell(parts.root, item);
14
+ syncTextResolution(parts.label, parts, context.zoom);
15
+ const selected = context.selectedIds.has(item.id);
16
+ const hovered = context.hoveredId === item.id;
17
+ const palette = pickBoardColor(context.colors, color, context.colorScheme);
18
+ const stroke = selected ? context.palette.brand : palette.stroke;
19
+ const alpha = selected ? 1 : hovered ? .85 : .55;
20
+ const visualSig = [
21
+ frame.width,
22
+ frame.height,
23
+ selected,
24
+ hovered,
25
+ palette.fill,
26
+ stroke
27
+ ].join("|");
28
+ if (visualSig !== parts.visualSig) {
29
+ parts.visualSig = visualSig;
30
+ parts.box.clear();
31
+ parts.box.roundRect(0, 0, frame.width, frame.height, 4).fill({
32
+ color: palette.fill,
33
+ alpha: .04
34
+ });
35
+ parts.box.roundRect(0, 0, frame.width, frame.height, 4).stroke({
36
+ color: stroke,
37
+ width: selected ? 2 : 1.5,
38
+ alpha
39
+ });
40
+ }
41
+ const title = locked ? `🔒 ${label || "Frame"}` : label || "Frame";
42
+ const textSig = [title, palette.stroke].join("|");
43
+ if (textSig !== parts.textSig) {
44
+ parts.textSig = textSig;
45
+ parts.label.text = title;
46
+ parts.label.style.fill = palette.stroke;
47
+ }
48
+ parts.label.position.set(4, -18);
49
+ }
50
+ const frameCardRenderer = {
51
+ id: "frame-card",
52
+ canRender: (item) => item.type === "frame",
53
+ create: (item, context) => {
54
+ const root = new Container();
55
+ const box = new Graphics();
56
+ const label = createLabel("", {
57
+ fill: context.palette.muted,
58
+ fontFamily: BOARD_FONT_STACK,
59
+ fontSize: 12,
60
+ fontWeight: "600"
61
+ });
62
+ root.addChild(box, label);
63
+ partsByContainer.set(root, {
64
+ root,
65
+ box,
66
+ label,
67
+ visualSig: "",
68
+ textSig: "",
69
+ resolution: label.resolution
70
+ });
71
+ sync(root, item, context);
72
+ return root;
73
+ },
74
+ update: (container, item, context) => {
75
+ sync(container, item, context);
76
+ },
77
+ /**
78
+ * Far LOD: a frame is a background region, so it draws as a faint wash with no
79
+ * label.
80
+ *
81
+ * Batching it is what keeps it *behind* its contents. As a live container it
82
+ * would be drawn above the whole far layer no matter where the document places
83
+ * it, so a frame would hide every card inside it.
84
+ */
85
+ renderFar: (graphics, item, context) => {
86
+ if (item.type !== "frame") return;
87
+ const palette = pickBoardColor(context.colors, item.color, context.colorScheme);
88
+ drawFarPlate(graphics, item.frame, {
89
+ fill: palette.fill,
90
+ fillAlpha: .07
91
+ });
92
+ },
93
+ destroy: (container) => {
94
+ partsByContainer.delete(container);
95
+ container.destroy({ children: true });
96
+ }
97
+ };
98
+ //#endregion
99
+ export { frameCardRenderer };
@@ -0,0 +1,5 @@
1
+ import { BoardCardRenderer } from "./board-renderer-registry.js";
2
+ //#region src/board/render/renderers/geo-card-renderer.d.ts
3
+ declare const geoCardRenderer: BoardCardRenderer;
4
+ //#endregion
5
+ export { geoCardRenderer };
@@ -0,0 +1,128 @@
1
+ import { BOARD_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
+ import { pickBoardColor } from "../../core/palette.js";
3
+ import { syncTextResolution, syncTextWrapWidth, textResolutionForZoom } from "../text-resolution.js";
4
+ import { drawFarPlate } from "./far-plate.js";
5
+ import { positionShell } from "./base-card-renderer.js";
6
+ import { Container, Graphics, Text } from "pixi.js";
7
+ //#region src/board/render/renderers/geo-card-renderer.ts
8
+ const RADIUS = 8;
9
+ const LABEL_PADDING = 8;
10
+ const partsByContainer = /* @__PURE__ */ new WeakMap();
11
+ function traceOutline(graphics, geo, width, height) {
12
+ switch (geo) {
13
+ case "ellipse":
14
+ graphics.ellipse(width / 2, height / 2, width / 2, height / 2);
15
+ return;
16
+ case "diamond":
17
+ graphics.moveTo(width / 2, 0).lineTo(width, height / 2).lineTo(width / 2, height).lineTo(0, height / 2).closePath();
18
+ return;
19
+ case "triangle":
20
+ graphics.moveTo(width / 2, 0).lineTo(width, height).lineTo(0, height).closePath();
21
+ return;
22
+ case "rounded":
23
+ graphics.roundRect(0, 0, width, height, RADIUS);
24
+ return;
25
+ default: graphics.rect(0, 0, width, height);
26
+ }
27
+ }
28
+ function sync(container, item, context) {
29
+ const parts = partsByContainer.get(container);
30
+ if (!parts) return;
31
+ positionShell(parts.root, item);
32
+ const { width, height } = item.frame;
33
+ const selected = context.selectedIds.has(item.id);
34
+ const hovered = context.hoveredId === item.id;
35
+ const color = pickBoardColor(context.colors, item.color, context.colorScheme);
36
+ syncTextResolution(parts.label, parts, context.zoom);
37
+ const resizing = context.resizingIds.has(item.id);
38
+ const visualSig = [
39
+ width,
40
+ height,
41
+ selected,
42
+ hovered,
43
+ item.geo,
44
+ item.fillOpacity,
45
+ color.fill,
46
+ color.stroke,
47
+ context.palette.muted
48
+ ].join("|");
49
+ if (visualSig !== parts.visualSig) {
50
+ parts.visualSig = visualSig;
51
+ parts.shape.clear();
52
+ traceOutline(parts.shape, item.geo, width, height);
53
+ if (item.fillOpacity > 0) parts.shape.fill({
54
+ color: color.fill,
55
+ alpha: item.fillOpacity
56
+ });
57
+ traceOutline(parts.shape, item.geo, width, height);
58
+ parts.shape.stroke({
59
+ color: selected ? color.stroke : hovered ? context.palette.muted : color.stroke,
60
+ width: selected ? 2.5 : 1.75,
61
+ alpha: selected ? 1 : hovered ? .95 : .85
62
+ });
63
+ }
64
+ const textSig = [item.text, color.label].join("|");
65
+ if (textSig !== parts.textSig) {
66
+ parts.textSig = textSig;
67
+ parts.label.text = item.text;
68
+ parts.label.visible = item.text.length > 0;
69
+ parts.label.style.fill = color.label;
70
+ }
71
+ syncTextWrapWidth(parts.label, parts, Math.max(1, width - LABEL_PADDING * 2), resizing);
72
+ parts.label.position.set(width / 2, height / 2);
73
+ }
74
+ const geoCardRenderer = {
75
+ id: "geo-card",
76
+ canRender: (item) => item.type === "geo",
77
+ create: (item, context) => {
78
+ const root = new Container();
79
+ const shape = new Graphics();
80
+ const resolution = textResolutionForZoom(context.zoom);
81
+ const label = new Text({
82
+ text: "",
83
+ style: {
84
+ fill: 16777215,
85
+ fontFamily: BOARD_FONT_STACK,
86
+ fontSize: 14,
87
+ fontWeight: "500",
88
+ align: "center",
89
+ wordWrap: true,
90
+ lineHeight: 20
91
+ },
92
+ resolution,
93
+ roundPixels: true
94
+ });
95
+ label.anchor.set(.5);
96
+ root.addChild(shape, label);
97
+ partsByContainer.set(root, {
98
+ root,
99
+ shape,
100
+ label,
101
+ visualSig: "",
102
+ textSig: "",
103
+ wrapWidth: 0,
104
+ resolution
105
+ });
106
+ if (item.type === "geo") sync(root, item, context);
107
+ return root;
108
+ },
109
+ update: (container, item, context) => {
110
+ if (item.type === "geo") sync(container, item, context);
111
+ },
112
+ renderFar: (graphics, item, context) => {
113
+ if (item.type !== "geo") return;
114
+ const color = pickBoardColor(context.colors, item.color, context.colorScheme);
115
+ drawFarPlate(graphics, item.frame, {
116
+ fill: color.fill,
117
+ fillAlpha: Math.max(.18, item.fillOpacity),
118
+ accent: color.stroke,
119
+ accentAlpha: .9
120
+ });
121
+ },
122
+ destroy: (container) => {
123
+ partsByContainer.get(container)?.root.destroy({ children: true });
124
+ partsByContainer.delete(container);
125
+ }
126
+ };
127
+ //#endregion
128
+ export { geoCardRenderer };
@@ -0,0 +1,8 @@
1
+ import { BoardImageItem, BoardItem } from "../../../protocol/dist/board-document.js";
2
+ import { BoardCardRenderer } from "./board-renderer-registry.js";
3
+ //#region src/board/render/renderers/image-card-renderer.d.ts
4
+ declare const imageCardRenderer: BoardCardRenderer;
5
+ /** Helper kept for type narrowing in tests. */
6
+ declare function isImageItem(item: BoardItem): item is BoardImageItem;
7
+ //#endregion
8
+ export { imageCardRenderer, isImageItem };