@neta-art/cohub 3.0.0 → 3.2.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 (96) 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 +252 -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 +31 -0
  9. package/dist/board/core/draw-geometry.js +183 -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 +176 -0
  15. package/dist/board/core/file-preview.js +271 -0
  16. package/dist/board/core/palette.d.ts +42 -0
  17. package/dist/board/core/palette.js +138 -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 +144 -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/export/index.d.ts +56 -0
  25. package/dist/board/export/index.js +86 -0
  26. package/dist/board/export/scene.d.ts +35 -0
  27. package/dist/board/export/scene.js +69 -0
  28. package/dist/board/geometry.d.ts +131 -0
  29. package/dist/board/geometry.js +468 -0
  30. package/dist/board/headless/index.d.ts +69 -0
  31. package/dist/board/headless/index.js +150 -0
  32. package/dist/board/image-key.d.ts +19 -0
  33. package/dist/board/image-key.js +34 -0
  34. package/dist/board/index.d.ts +16 -0
  35. package/dist/board/index.js +16 -0
  36. package/dist/board/render/index.d.ts +6 -0
  37. package/dist/board/render/index.js +6 -0
  38. package/dist/board/render/palette.d.ts +5 -0
  39. package/dist/board/render/palette.js +30 -0
  40. package/dist/board/render/renderers/arrow-card-renderer.d.ts +5 -0
  41. package/dist/board/render/renderers/arrow-card-renderer.js +157 -0
  42. package/dist/board/render/renderers/base-card-renderer.d.ts +46 -0
  43. package/dist/board/render/renderers/base-card-renderer.js +153 -0
  44. package/dist/board/render/renderers/board-renderer-registry.d.ts +85 -0
  45. package/dist/board/render/renderers/board-renderer-registry.js +43 -0
  46. package/dist/board/render/renderers/draw-card-renderer.d.ts +5 -0
  47. package/dist/board/render/renderers/draw-card-renderer.js +82 -0
  48. package/dist/board/render/renderers/far-plate.d.ts +39 -0
  49. package/dist/board/render/renderers/far-plate.js +92 -0
  50. package/dist/board/render/renderers/file-card-renderer.d.ts +33 -0
  51. package/dist/board/render/renderers/file-card-renderer.js +371 -0
  52. package/dist/board/render/renderers/frame-card-renderer.d.ts +5 -0
  53. package/dist/board/render/renderers/frame-card-renderer.js +99 -0
  54. package/dist/board/render/renderers/geo-card-renderer.d.ts +5 -0
  55. package/dist/board/render/renderers/geo-card-renderer.js +128 -0
  56. package/dist/board/render/renderers/image-card-renderer.d.ts +8 -0
  57. package/dist/board/render/renderers/image-card-renderer.js +138 -0
  58. package/dist/board/render/renderers/note-card-renderer.d.ts +5 -0
  59. package/dist/board/render/renderers/note-card-renderer.js +112 -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 +20 -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 +102 -0
  74. package/dist/chunks/http.d.ts +10 -40
  75. package/dist/chunks/http.js +26 -4
  76. package/dist/chunks/transport.js +4 -2
  77. package/dist/chunks/websocket.d.ts +832 -21
  78. package/dist/chunks/websocket.js +19 -2
  79. package/dist/http.d.ts +2 -3
  80. package/dist/index.d.ts +100 -5
  81. package/dist/index.js +391 -1
  82. package/dist/protocol/dist/board-constants.d.ts +23 -0
  83. package/dist/protocol/dist/board-constants.js +60 -0
  84. package/dist/protocol/dist/board-document.d.ts +1124 -0
  85. package/dist/protocol/dist/board-document.js +331 -0
  86. package/dist/protocol/dist/board.d.ts +186 -0
  87. package/dist/protocol/dist/board.js +207 -0
  88. package/dist/protocol/dist/index.d.ts +5 -0
  89. package/dist/protocol/dist/index.js +5 -0
  90. package/dist/protocol/dist/provenance.js +12 -0
  91. package/dist/protocol/dist/realtime/board-awareness.d.ts +1 -0
  92. package/dist/protocol/dist/realtime/board-awareness.js +117 -0
  93. package/dist/protocol/dist/realtime/types.d.ts +2 -0
  94. package/package.json +39 -4
  95. package/dist/board.js +0 -2
  96. package/dist/chunks/board.d.ts +0 -2589
@@ -0,0 +1,82 @@
1
+ import { buildStrokeOutline, computeDrawBounds } from "../../core/draw-geometry.js";
2
+ import { pickBoardColor } from "../../core/palette.js";
3
+ import { drawFarStroke } from "./far-plate.js";
4
+ import { positionShell } from "./base-card-renderer.js";
5
+ import { Container, Graphics } from "pixi.js";
6
+ //#region src/board/render/renderers/draw-card-renderer.ts
7
+ const partsByContainer = /* @__PURE__ */ new WeakMap();
8
+ function sync(container, item, context) {
9
+ const parts = partsByContainer.get(container);
10
+ if (!parts) return;
11
+ positionShell(parts.root, item);
12
+ const selected = context.selectedIds.has(item.id);
13
+ const hovered = context.hoveredId === item.id;
14
+ const color = pickBoardColor(context.colors, item.color, context.colorScheme);
15
+ const sig = [
16
+ item.points.length,
17
+ item.size,
18
+ item.color,
19
+ selected,
20
+ hovered,
21
+ context.colorScheme,
22
+ color.stroke
23
+ ].join("|");
24
+ if (sig !== parts.sig || item.points !== parts.points) {
25
+ parts.sig = sig;
26
+ parts.points = item.points;
27
+ parts.baseWidth = computeDrawBounds(item.points, item.size).width;
28
+ parts.stroke.clear();
29
+ const outline = buildStrokeOutline(item.points, item.size);
30
+ const origin = outline[0];
31
+ if (origin && outline.length >= 3) {
32
+ parts.stroke.moveTo(origin.x, origin.y);
33
+ for (let i = 1; i < outline.length; i += 1) {
34
+ const point = outline[i];
35
+ if (point) parts.stroke.lineTo(point.x, point.y);
36
+ }
37
+ parts.stroke.closePath();
38
+ parts.stroke.fill({
39
+ color: color.stroke,
40
+ alpha: selected || hovered ? 1 : .92
41
+ });
42
+ }
43
+ }
44
+ const previewScale = item.frame.width / Math.max(1e-4, parts.baseWidth);
45
+ parts.stroke.scale.set(Number.isFinite(previewScale) ? previewScale : 1);
46
+ }
47
+ const drawCardRenderer = {
48
+ id: "draw-card",
49
+ canRender: (item) => item.type === "draw",
50
+ create: (item, context) => {
51
+ const root = new Container();
52
+ const stroke = new Graphics();
53
+ root.addChild(stroke);
54
+ partsByContainer.set(root, {
55
+ root,
56
+ stroke,
57
+ sig: "",
58
+ points: null,
59
+ baseWidth: 0
60
+ });
61
+ if (item.type === "draw") sync(root, item, context);
62
+ return root;
63
+ },
64
+ update: (container, item, context) => {
65
+ if (item.type === "draw") sync(container, item, context);
66
+ },
67
+ renderFar: (graphics, item, context) => {
68
+ if (item.type !== "draw") return;
69
+ const color = pickBoardColor(context.colors, item.color, context.colorScheme);
70
+ drawFarStroke(graphics, item.points, {
71
+ color: color.stroke,
72
+ width: item.size,
73
+ alpha: .92
74
+ });
75
+ },
76
+ destroy: (container) => {
77
+ partsByContainer.get(container)?.root.destroy({ children: true });
78
+ partsByContainer.delete(container);
79
+ }
80
+ };
81
+ //#endregion
82
+ export { drawCardRenderer };
@@ -0,0 +1,39 @@
1
+ import { BoardFrame } from "../../../protocol/dist/board-document.js";
2
+ import { Graphics } from "pixi.js";
3
+ //#region src/board/render/renderers/far-plate.d.ts
4
+ type FarPlateStyle = {
5
+ /** Plate body fill. */
6
+ fill: number;
7
+ fillAlpha?: number;
8
+ /** Optional accent band along the top edge, identifying the content kind. */
9
+ accent?: number;
10
+ accentAlpha?: number;
11
+ };
12
+ /**
13
+ * Draw one card as a far-LOD plate: a filled body plus an optional top accent
14
+ * band. Selection and hover are intentionally not drawn here — the interaction
15
+ * overlay already renders selection in world space, above this layer.
16
+ */
17
+ declare function drawFarPlate(graphics: Graphics, frame: BoardFrame, style: FarPlateStyle): void;
18
+ /**
19
+ * Draw one card as a far-LOD polyline.
20
+ *
21
+ * The counterpart to `drawFarPlate` for shapes whose meaning is a path rather than
22
+ * a region — strokes and arrows. It stays in the shared `Graphics`, so these keep
23
+ * their place in the batch and therefore in document order: an unbatched shape
24
+ * would be a live container, drawn above every plate no matter where the document
25
+ * puts it.
26
+ *
27
+ * Points are decimated to a bound, because a freehand stroke can hold thousands of
28
+ * samples that are sub-pixel apart at this zoom.
29
+ */
30
+ declare function drawFarStroke(graphics: Graphics, points: ReadonlyArray<{
31
+ x: number;
32
+ y: number;
33
+ }>, style: {
34
+ color: number;
35
+ width: number;
36
+ alpha?: number;
37
+ }): void;
38
+ //#endregion
39
+ export { FarPlateStyle, drawFarPlate, drawFarStroke };
@@ -0,0 +1,92 @@
1
+ import { degToRad } from "../../geometry.js";
2
+ //#region src/board/render/renderers/far-plate.ts
3
+ /** Upper bound on points kept when a stroke is drawn at far LOD. */
4
+ const FAR_STROKE_MAX_POINTS = 24;
5
+ /** Accent band height as a fraction of the plate, clamped to sane world units. */
6
+ const ACCENT_RATIO = .14;
7
+ const ACCENT_MIN = 2;
8
+ const ACCENT_MAX = 10;
9
+ /**
10
+ * Trace a frame's rectangle, honouring rotation. Unrotated frames take the
11
+ * fast `rect` path; rotated ones emit four explicit corners (still one path,
12
+ * no arcs) so the far layer never needs a per-card transform.
13
+ */
14
+ function traceFrame(graphics, frame, inset = 0, heightOverride) {
15
+ const x = frame.x + inset;
16
+ const y = frame.y + inset;
17
+ const width = Math.max(0, frame.width - inset * 2);
18
+ const height = Math.max(0, (heightOverride ?? frame.height) - inset * 2);
19
+ if (!frame.rotation) {
20
+ graphics.rect(x, y, width, height);
21
+ return;
22
+ }
23
+ const cx = frame.x + frame.width / 2;
24
+ const cy = frame.y + frame.height / 2;
25
+ const angle = degToRad(frame.rotation);
26
+ const cos = Math.cos(angle);
27
+ const sin = Math.sin(angle);
28
+ const corner = (px, py) => {
29
+ const dx = px - cx;
30
+ const dy = py - cy;
31
+ return {
32
+ x: cx + dx * cos - dy * sin,
33
+ y: cy + dx * sin + dy * cos
34
+ };
35
+ };
36
+ const a = corner(x, y);
37
+ const b = corner(x + width, y);
38
+ const c = corner(x + width, y + height);
39
+ const d = corner(x, y + height);
40
+ graphics.moveTo(a.x, a.y).lineTo(b.x, b.y).lineTo(c.x, c.y).lineTo(d.x, d.y).closePath();
41
+ }
42
+ /**
43
+ * Draw one card as a far-LOD plate: a filled body plus an optional top accent
44
+ * band. Selection and hover are intentionally not drawn here — the interaction
45
+ * overlay already renders selection in world space, above this layer.
46
+ */
47
+ function drawFarPlate(graphics, frame, style) {
48
+ if (frame.width <= 0 || frame.height <= 0) return;
49
+ traceFrame(graphics, frame);
50
+ graphics.fill({
51
+ color: style.fill,
52
+ alpha: style.fillAlpha ?? 1
53
+ });
54
+ if (style.accent === void 0) return;
55
+ traceFrame(graphics, frame, 0, Math.min(ACCENT_MAX, Math.max(ACCENT_MIN, frame.height * ACCENT_RATIO)));
56
+ graphics.fill({
57
+ color: style.accent,
58
+ alpha: style.accentAlpha ?? .9
59
+ });
60
+ }
61
+ /**
62
+ * Draw one card as a far-LOD polyline.
63
+ *
64
+ * The counterpart to `drawFarPlate` for shapes whose meaning is a path rather than
65
+ * a region — strokes and arrows. It stays in the shared `Graphics`, so these keep
66
+ * their place in the batch and therefore in document order: an unbatched shape
67
+ * would be a live container, drawn above every plate no matter where the document
68
+ * puts it.
69
+ *
70
+ * Points are decimated to a bound, because a freehand stroke can hold thousands of
71
+ * samples that are sub-pixel apart at this zoom.
72
+ */
73
+ function drawFarStroke(graphics, points, style) {
74
+ if (points.length < 2) return;
75
+ const step = Math.max(1, Math.ceil(points.length / FAR_STROKE_MAX_POINTS));
76
+ const first = points[0];
77
+ if (!first) return;
78
+ graphics.moveTo(first.x, first.y);
79
+ for (let i = step; i < points.length; i += step) {
80
+ const point = points[i];
81
+ if (point) graphics.lineTo(point.x, point.y);
82
+ }
83
+ const last = points[points.length - 1];
84
+ if (last) graphics.lineTo(last.x, last.y);
85
+ graphics.stroke({
86
+ color: style.color,
87
+ width: style.width,
88
+ alpha: style.alpha ?? .9
89
+ });
90
+ }
91
+ //#endregion
92
+ export { drawFarPlate, drawFarStroke };
@@ -0,0 +1,33 @@
1
+ import { BoardFileItem, BoardItem } from "../../../protocol/dist/board-document.js";
2
+ import { BoardCardRenderer } from "./board-renderer-registry.js";
3
+ import { Text } from "pixi.js";
4
+ //#region src/board/render/renderers/file-card-renderer.d.ts
5
+ /**
6
+ * Join already-wrapped lines and append an ellipsis to the last one.
7
+ * Pure helper so the clamp can be unit-tested without a Pixi canvas.
8
+ */
9
+ declare function ellipsizeWrappedLines(lines: string[], maxLines: number): string;
10
+ /**
11
+ * Shorten `line` so `line + "…"` fits in `wrapWidth`.
12
+ *
13
+ * Binary search over the prefix: O(log n) single-line measures. Callers pass a
14
+ * measure callback so this stays free of Pixi types and easy to unit-test.
15
+ */
16
+ declare function fitLineWithEllipsis(line: string, wrapWidth: number, measureWidth: (value: string) => number): string;
17
+ /**
18
+ * Fit a Pixi text node into a fixed line budget.
19
+ *
20
+ * Pixi Text has no built-in max-lines / ellipsis, so we measure with the same
21
+ * style the node will render, keep the leading wrapped lines, and mark a cut
22
+ * with an ellipsis. Returning early when everything fits avoids an extra
23
+ * texture rebuild on the common path.
24
+ *
25
+ * Cost is one wrap of a length-capped sample, plus at most a binary search on
26
+ * the last kept line. No character-by-character multi-line remeasure.
27
+ */
28
+ declare function fitTextToLines(text: Text, value: string, maxLines: number, wrapWidth: number): void;
29
+ declare const fileCardRenderer: BoardCardRenderer;
30
+ /** Helper kept for type narrowing in tests. */
31
+ declare function isFileItem(item: BoardItem): item is BoardFileItem;
32
+ //#endregion
33
+ export { ellipsizeWrappedLines, fileCardRenderer, fitLineWithEllipsis, fitTextToLines, isFileItem };
@@ -0,0 +1,371 @@
1
+ import { BOARD_FONT_STACK, BOARD_MONO_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
+ import { fileBaseName, filePreviewKind, fileTypeLabel, formatFileSize } 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, and the type. 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 META_SIZE = 9;
36
+ const META_LINE = META_SIZE * 1.3;
37
+ const GAP = 4;
38
+ /** Zoom below which the title is dropped (glyphs are sub-pixel). */
39
+ const LOD_TITLE_ZOOM = .35;
40
+ /** Zoom below which the excerpt and meta line are dropped. */
41
+ const LOD_BODY_ZOOM = .6;
42
+ /** Upper bound on dashes in the unavailable-state edge, regardless of width. */
43
+ const MAX_DASHES = 40;
44
+ const partsByContainer = /* @__PURE__ */ new WeakMap();
45
+ /** Detail tier for a zoom level. */
46
+ function detailFor(zoom) {
47
+ if (zoom < LOD_TITLE_ZOOM) return "plate";
48
+ if (zoom < LOD_BODY_ZOOM) return "title";
49
+ return "full";
50
+ }
51
+ /** Cover band height for a card, or 0 when it has no cover. */
52
+ function coverHeight(item, height) {
53
+ if (filePreviewKind(item.snapshot) !== "cover") return 0;
54
+ const ideal = Math.round(height * COVER_RATIO);
55
+ return Math.max(0, Math.min(ideal, height - MIN_BODY_FOR_COVER));
56
+ }
57
+ function metaLine(item, state) {
58
+ const type = fileTypeLabel(item.ref.path);
59
+ if (state === "missing") return `${type} · missing`;
60
+ if (state === "unavailable") return `${type} · unavailable`;
61
+ const size = formatFileSize(item.snapshot?.size);
62
+ return size ? `${type} · ${size}` : type;
63
+ }
64
+ /**
65
+ * Join already-wrapped lines and append an ellipsis to the last one.
66
+ * Pure helper so the clamp can be unit-tested without a Pixi canvas.
67
+ */
68
+ function ellipsizeWrappedLines(lines, maxLines) {
69
+ if (maxLines <= 0 || lines.length === 0) return "";
70
+ if (lines.length <= maxLines) return lines.join("\n");
71
+ const kept = lines.slice(0, maxLines);
72
+ const lastIndex = kept.length - 1;
73
+ const last = (kept[lastIndex] ?? "").replace(/\s+$/u, "");
74
+ kept[lastIndex] = last ? `${last}…` : "…";
75
+ return kept.join("\n");
76
+ }
77
+ /**
78
+ * Shorten `line` so `line + "…"` fits in `wrapWidth`.
79
+ *
80
+ * Binary search over the prefix: O(log n) single-line measures. Callers pass a
81
+ * measure callback so this stays free of Pixi types and easy to unit-test.
82
+ */
83
+ function fitLineWithEllipsis(line, wrapWidth, measureWidth) {
84
+ const trimmed = line.replace(/\s+$/u, "");
85
+ if (!trimmed) return "…";
86
+ const full = `${trimmed}…`;
87
+ if (measureWidth(full) <= wrapWidth) return full;
88
+ let lo = 0;
89
+ let hi = trimmed.length;
90
+ let best = "…";
91
+ while (lo <= hi) {
92
+ const mid = lo + hi >> 1;
93
+ const candidate = mid > 0 ? `${trimmed.slice(0, mid)}…` : "…";
94
+ if (measureWidth(candidate) <= wrapWidth) {
95
+ best = candidate;
96
+ lo = mid + 1;
97
+ } else hi = mid - 1;
98
+ }
99
+ return best;
100
+ }
101
+ /**
102
+ * Fit a Pixi text node into a fixed line budget.
103
+ *
104
+ * Pixi Text has no built-in max-lines / ellipsis, so we measure with the same
105
+ * style the node will render, keep the leading wrapped lines, and mark a cut
106
+ * with an ellipsis. Returning early when everything fits avoids an extra
107
+ * texture rebuild on the common path.
108
+ *
109
+ * Cost is one wrap of a length-capped sample, plus at most a binary search on
110
+ * the last kept line. No character-by-character multi-line remeasure.
111
+ */
112
+ function fitTextToLines(text, value, maxLines, wrapWidth) {
113
+ const width = Math.max(1, wrapWidth);
114
+ text.style.wordWrapWidth = width;
115
+ if (!value || maxLines <= 0) {
116
+ if (text.text !== "") text.text = "";
117
+ return;
118
+ }
119
+ const charCap = Math.max(maxLines * Math.ceil(width), maxLines * 4);
120
+ const sample = value.length > charCap ? value.slice(0, charCap) : value;
121
+ const metrics = CanvasTextMetrics.measureText(sample, text.style);
122
+ if (!(metrics.lines.length > maxLines || sample !== value)) {
123
+ if (text.text !== value) text.text = value;
124
+ return;
125
+ }
126
+ const keptCount = Math.min(maxLines, metrics.lines.length);
127
+ const kept = metrics.lines.slice(0, keptCount);
128
+ const lastIndex = kept.length - 1;
129
+ const probe = text.style.clone();
130
+ probe.wordWrap = false;
131
+ kept[lastIndex] = fitLineWithEllipsis(kept[lastIndex] ?? "", width, (candidate) => CanvasTextMetrics.measureText(candidate, probe).width);
132
+ const fitted = kept.join("\n");
133
+ if (text.text !== fitted) text.text = fitted;
134
+ }
135
+ /** How many full lines of `lineHeight` fit in `room`, capped at `max`. */
136
+ function linesInRoom(room, lineHeight, max) {
137
+ if (room < lineHeight * .85) return 0;
138
+ return Math.max(0, Math.min(max, Math.floor((room + .5) / lineHeight)));
139
+ }
140
+ /** Fill the cover band, cropping overflow (the band's aspect rarely matches). */
141
+ function layoutCover(sprite, width, height, texture) {
142
+ const tw = texture.width;
143
+ const th = texture.height;
144
+ if (!tw || !th || !height) return;
145
+ const scale = Math.max(width / tw, height / th);
146
+ sprite.width = tw * scale;
147
+ sprite.height = th * scale;
148
+ sprite.x = (width - sprite.width) / 2;
149
+ sprite.y = 0;
150
+ }
151
+ function syncClip(parts, width, height) {
152
+ parts.clip.clear().roundRect(0, 0, Math.max(1, width), Math.max(1, height), RADIUS).fill({ color: 16777215 });
153
+ }
154
+ function sync(container, item, context) {
155
+ const parts = partsByContainer.get(container);
156
+ if (!parts) return;
157
+ positionShell(parts.root, item);
158
+ const { width, height } = item.frame;
159
+ const selected = context.selectedIds.has(item.id);
160
+ const hovered = context.hoveredId === item.id;
161
+ const detail = detailFor(context.zoom);
162
+ const key = context.assetKey(item);
163
+ const texture = key ? context.getTexture(key) : null;
164
+ const coverFailed = Boolean(key && !texture && context.hasError(key));
165
+ const fileState = context.fileState(item.ref.path);
166
+ const band = coverHeight(item, height);
167
+ syncTextResolution(parts.title, parts.titleRes, context.zoom);
168
+ syncTextResolution(parts.excerpt, parts.excerptRes, context.zoom);
169
+ syncTextResolution(parts.meta, parts.metaRes, context.zoom);
170
+ const visualSig = [
171
+ key ?? "",
172
+ width,
173
+ height,
174
+ selected,
175
+ hovered,
176
+ detail,
177
+ texture ? `${texture.width}x${texture.height}` : "none",
178
+ coverFailed,
179
+ fileState,
180
+ context.colorScheme,
181
+ context.palette.surface
182
+ ].join("|");
183
+ if (visualSig !== parts.visualSig) {
184
+ parts.visualSig = visualSig;
185
+ syncClip(parts, width, height);
186
+ parts.plate.clear();
187
+ parts.plate.roundRect(0, 0, width, height, RADIUS).fill({
188
+ color: context.palette.surface,
189
+ alpha: .98
190
+ }).roundRect(0, 0, width, height, RADIUS).stroke({
191
+ color: selected ? context.palette.brand : hovered ? context.palette.muted : context.palette.border,
192
+ width: selected ? 2 : 1,
193
+ alpha: selected ? .95 : .85
194
+ });
195
+ if (fileState !== "ok") {
196
+ const dash = Math.max(6, width / MAX_DASHES / 2);
197
+ for (let x = 0; x < width; x += dash * 2) parts.plate.moveTo(x, .5).lineTo(Math.min(x + dash, width), .5);
198
+ parts.plate.stroke({
199
+ color: context.palette.muted,
200
+ width: 2,
201
+ alpha: fileState === "missing" ? .9 : .5
202
+ });
203
+ }
204
+ const showCover = band > 0 && Boolean(texture);
205
+ if (showCover && texture) {
206
+ layoutCover(parts.cover, width, band, texture);
207
+ if (parts.cover.texture !== texture) parts.cover.texture = texture;
208
+ parts.coverMask.clear().roundRect(0, 0, width, band, RADIUS).fill({ color: 16777215 });
209
+ parts.coverMask.rect(0, band - RADIUS, width, RADIUS).fill({ color: 16777215 });
210
+ }
211
+ parts.cover.visible = showCover;
212
+ parts.coverMask.visible = showCover;
213
+ if (band > 0 && !texture) parts.plate.rect(1, 1, width - 2, band - 1).fill({
214
+ color: context.palette.hover,
215
+ alpha: coverFailed ? .35 : .6
216
+ });
217
+ if (!band) parts.plate.rect(1, 1, 2, height - 2).fill({
218
+ color: context.palette.muted,
219
+ alpha: .35
220
+ });
221
+ parts.title.visible = detail !== "plate";
222
+ parts.excerpt.visible = detail === "full";
223
+ parts.meta.visible = detail === "full";
224
+ }
225
+ if (detail === "plate") return;
226
+ const title = item.snapshot?.title || fileBaseName(item.ref.path);
227
+ const excerpt = item.snapshot?.excerpt ?? "";
228
+ const meta = metaLine(item, fileState);
229
+ const innerWidth = Math.max(1, width - PADDING * 2);
230
+ const textSig = [
231
+ title,
232
+ excerpt,
233
+ meta,
234
+ detail,
235
+ innerWidth,
236
+ band,
237
+ height,
238
+ context.palette.text,
239
+ context.palette.muted
240
+ ].join("|");
241
+ if (textSig === parts.textSig) return;
242
+ parts.textSig = textSig;
243
+ const top = band > 0 ? band + PADDING * .8 : PADDING;
244
+ const bottom = height - PADDING;
245
+ const metaFits = detail === "full" && bottom - top >= 33.25;
246
+ const contentBottom = bottom - (metaFits ? META_LINE : 0) - (metaFits ? GAP : 0);
247
+ const titleLines = linesInRoom(Math.max(0, contentBottom - top), TITLE_LINE, TITLE_MAX_LINES);
248
+ parts.title.style.fill = context.palette.text;
249
+ fitTextToLines(parts.title, title, titleLines, innerWidth);
250
+ parts.title.position.set(PADDING, top);
251
+ parts.title.visible = titleLines > 0;
252
+ if (detail !== "full") {
253
+ parts.excerpt.visible = false;
254
+ parts.meta.visible = false;
255
+ return;
256
+ }
257
+ if (metaFits) {
258
+ if (parts.meta.text !== meta) parts.meta.text = meta;
259
+ parts.meta.style.fill = context.palette.muted;
260
+ parts.meta.position.set(PADDING, height - PADDING - Math.min(parts.meta.height, META_LINE));
261
+ parts.meta.visible = true;
262
+ } else parts.meta.visible = false;
263
+ const excerptTop = top + (titleLines > 0 ? parts.title.height + GAP : 0);
264
+ const excerptLines = linesInRoom(contentBottom - excerptTop, EXCERPT_LINE, EXCERPT_MAX_LINES);
265
+ const showExcerpt = Boolean(excerpt) && excerptLines > 0;
266
+ if (showExcerpt) {
267
+ parts.excerpt.style.fill = context.palette.muted;
268
+ fitTextToLines(parts.excerpt, excerpt, excerptLines, innerWidth);
269
+ parts.excerpt.position.set(PADDING, excerptTop);
270
+ }
271
+ parts.excerpt.visible = showExcerpt;
272
+ }
273
+ const fileCardRenderer = {
274
+ id: "file-card",
275
+ canRender: (item) => item.type === "file",
276
+ create: (item, context) => {
277
+ const root = new Container();
278
+ const plate = new Graphics();
279
+ const body = new Container();
280
+ const clip = new Graphics();
281
+ const cover = new Sprite(Texture.EMPTY);
282
+ const coverMask = new Graphics();
283
+ cover.mask = coverMask;
284
+ const resolution = 1;
285
+ const title = new Text({
286
+ text: "",
287
+ style: {
288
+ fill: context.palette.text,
289
+ fontFamily: BOARD_FONT_STACK,
290
+ fontSize: TITLE_SIZE,
291
+ fontWeight: "600",
292
+ wordWrap: true,
293
+ breakWords: true,
294
+ lineHeight: TITLE_LINE
295
+ },
296
+ resolution,
297
+ roundPixels: true
298
+ });
299
+ const excerpt = new Text({
300
+ text: "",
301
+ style: {
302
+ fill: context.palette.muted,
303
+ fontFamily: BOARD_FONT_STACK,
304
+ fontSize: EXCERPT_SIZE,
305
+ fontWeight: "400",
306
+ wordWrap: true,
307
+ breakWords: true,
308
+ lineHeight: EXCERPT_LINE
309
+ },
310
+ resolution,
311
+ roundPixels: true
312
+ });
313
+ const meta = new Text({
314
+ text: "",
315
+ style: {
316
+ fill: context.palette.muted,
317
+ fontFamily: BOARD_MONO_FONT_STACK,
318
+ fontSize: META_SIZE,
319
+ fontWeight: "500"
320
+ },
321
+ resolution,
322
+ roundPixels: true
323
+ });
324
+ body.mask = clip;
325
+ body.addChild(cover, coverMask, title, excerpt, meta);
326
+ root.addChild(plate, body, clip);
327
+ partsByContainer.set(root, {
328
+ root,
329
+ plate,
330
+ body,
331
+ clip,
332
+ cover,
333
+ coverMask,
334
+ title,
335
+ excerpt,
336
+ meta,
337
+ visualSig: "",
338
+ textSig: "",
339
+ titleRes: { resolution },
340
+ excerptRes: { resolution },
341
+ metaRes: { resolution }
342
+ });
343
+ if (item.type === "file") sync(root, item, context);
344
+ return root;
345
+ },
346
+ update: (container, item, context) => {
347
+ if (item.type === "file") sync(container, item, context);
348
+ },
349
+ /**
350
+ * Far LOD: a plate with a muted accent band. Sampling the real cover here
351
+ * would mean one draw call per distinct image and defeat the batch.
352
+ */
353
+ renderFar: (graphics, item, context) => {
354
+ drawFarPlate(graphics, item.frame, {
355
+ fill: context.palette.surface,
356
+ fillAlpha: .96,
357
+ accent: context.palette.muted,
358
+ accentAlpha: .4
359
+ });
360
+ },
361
+ destroy: (container) => {
362
+ partsByContainer.get(container)?.root.destroy({ children: true });
363
+ partsByContainer.delete(container);
364
+ }
365
+ };
366
+ /** Helper kept for type narrowing in tests. */
367
+ function isFileItem(item) {
368
+ return item.type === "file";
369
+ }
370
+ //#endregion
371
+ export { 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 };