@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,271 @@
1
+ //#region src/board/core/file-preview.ts
2
+ /**
3
+ * File-card preview derivation — pure, renderer-agnostic, dependency-free.
4
+ *
5
+ * A board file card is a *thumbnail entry point* to a workspace file, never a
6
+ * second copy of it. So everything here derives display facts from file content
7
+ * that the caller already has, and the resulting snapshot is treated strictly as
8
+ * a cache keyed by the file's mtime: the file on disk stays the single source of
9
+ * truth, and nothing in this module ever writes back to it.
10
+ *
11
+ * Three presentation tiers fall out of which facts are available, so an
12
+ * unrecognised file degrades instead of being rejected:
13
+ *
14
+ * - `cover` — a cover image was declared in markdown frontmatter;
15
+ * - `text` — text-like content yielded a readable excerpt;
16
+ * - `blank` — anything else (binary, empty, oversized): icon, name and type.
17
+ */
18
+ /** Hard cap on a stored excerpt. Board cards show a few lines at most, and the
19
+ * snapshot rides along in every board transaction — so this stays small. */
20
+ const FILE_EXCERPT_MAX_CHARS = 480;
21
+ /** Files above this size are shown as `blank`; we never pull them for a preview. */
22
+ const FILE_EXCERPT_MAX_BYTES = 256 * 1024;
23
+ /** Frontmatter keys checked for a cover image, in precedence order. */
24
+ const COVER_KEYS = [
25
+ "cover",
26
+ "coverImage",
27
+ "cover_image",
28
+ "image",
29
+ "banner",
30
+ "thumbnail",
31
+ "ogImage",
32
+ "og:image"
33
+ ];
34
+ /**
35
+ * Which tier a card renders at. Derived rather than stored: presentation follows
36
+ * from the facts present, so there is no second piece of state to fall out of
37
+ * sync with them.
38
+ */
39
+ function filePreviewKind(snapshot) {
40
+ if (!snapshot) return "blank";
41
+ if (snapshot.coverPath || snapshot.coverUrl) return "cover";
42
+ if (snapshot.excerpt) return "text";
43
+ return "blank";
44
+ }
45
+ /** Short uppercase type label for the card's meta line (`MD`, `JSON`, `FILE`). */
46
+ function fileTypeLabel(path) {
47
+ const name = path.split("/").filter(Boolean).pop() ?? path;
48
+ const dot = name.lastIndexOf(".");
49
+ if (dot <= 0) return (name.replace(/^\./, "") || "file").toUpperCase();
50
+ return name.slice(dot + 1).toUpperCase();
51
+ }
52
+ /** Human-readable byte size for the meta line. */
53
+ function formatFileSize(bytes) {
54
+ if (bytes === void 0 || !Number.isFinite(bytes) || bytes < 0) return "";
55
+ if (bytes < 1024) return `${bytes} B`;
56
+ const units = [
57
+ "KB",
58
+ "MB",
59
+ "GB",
60
+ "TB"
61
+ ];
62
+ let value = bytes / 1024;
63
+ let unit = 0;
64
+ while (value >= 1024 && unit < units.length - 1) {
65
+ value /= 1024;
66
+ unit += 1;
67
+ }
68
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
69
+ }
70
+ /** Normalise a space-relative path: resolve `.`/`..` against the file's dir. */
71
+ function resolveSpacePath(fromFilePath, ref) {
72
+ const base = ref.startsWith("/") ? [] : fromFilePath.split("/").slice(0, -1).filter(Boolean);
73
+ const segments = ref.replace(/^\//, "").split("/");
74
+ const out = [...base];
75
+ for (const segment of segments) {
76
+ if (!segment || segment === ".") continue;
77
+ if (segment === "..") {
78
+ out.pop();
79
+ continue;
80
+ }
81
+ out.push(segment);
82
+ }
83
+ return out.join("/");
84
+ }
85
+ /**
86
+ * Classify a raw cover reference from frontmatter.
87
+ *
88
+ * Remote covers are allowed on purpose — a lot of real markdown points at a CDN.
89
+ * The trade-off is that opening such a board issues a request to that third
90
+ * party; the renderer degrades silently if it fails, and nothing is retried in a
91
+ * loop.
92
+ */
93
+ function resolveCoverRef(fromFilePath, raw) {
94
+ const value = (raw ?? "").trim();
95
+ if (!value) return null;
96
+ if (value.startsWith("//")) return {
97
+ kind: "url",
98
+ url: `https:${value}`
99
+ };
100
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
101
+ if (scheme) {
102
+ if (scheme === "https") return {
103
+ kind: "url",
104
+ url: value
105
+ };
106
+ return null;
107
+ }
108
+ const path = resolveSpacePath(fromFilePath, value);
109
+ return path ? {
110
+ kind: "path",
111
+ path
112
+ } : null;
113
+ }
114
+ /**
115
+ * Split leading YAML frontmatter from a markdown source. Deliberately minimal:
116
+ * this only needs the raw block so a handful of scalar keys can be read, not a
117
+ * YAML parser.
118
+ */
119
+ function splitFrontmatter(source) {
120
+ if (!/^---[ \t]*\r?\n/.test(source)) return {
121
+ frontmatter: null,
122
+ body: source
123
+ };
124
+ const lines = source.split(/\r?\n/);
125
+ for (let index = 1; index < lines.length; index += 1) {
126
+ const line = lines[index] ?? "";
127
+ if (line === "---" || line === "...") return {
128
+ frontmatter: lines.slice(1, index).join("\n"),
129
+ body: lines.slice(index + 1).join("\n")
130
+ };
131
+ }
132
+ return {
133
+ frontmatter: null,
134
+ body: source
135
+ };
136
+ }
137
+ function unquote(value) {
138
+ const trimmed = value.trim();
139
+ if (trimmed.length < 2) return trimmed;
140
+ const first = trimmed[0];
141
+ const last = trimmed[trimmed.length - 1];
142
+ if (first === "\"" && last === "\"" || first === "'" && last === "'") return trimmed.slice(1, -1);
143
+ return trimmed;
144
+ }
145
+ /**
146
+ * Read the first cover-ish scalar from a frontmatter block.
147
+ *
148
+ * Only top-level keys are considered (a leading-space line is nested and skipped)
149
+ * so `cover:` under some other mapping cannot be mistaken for the file's own.
150
+ */
151
+ function readCoverFromFrontmatter(frontmatter) {
152
+ if (!frontmatter) return null;
153
+ const found = /* @__PURE__ */ new Map();
154
+ for (const line of frontmatter.split(/\r?\n/)) {
155
+ if (!line.trim() || /^\s/.test(line) || line.trimStart().startsWith("#")) continue;
156
+ const match = /^([A-Za-z0-9_:.-]+):(.*)$/.exec(line);
157
+ if (!match) continue;
158
+ const key = match[1];
159
+ const value = unquote(match[2] ?? "");
160
+ if (key && value && !found.has(key)) found.set(key, value);
161
+ }
162
+ for (const key of COVER_KEYS) {
163
+ const value = found.get(key);
164
+ if (value) return value;
165
+ }
166
+ return null;
167
+ }
168
+ /**
169
+ * Reduce source text to a short, readable excerpt.
170
+ *
171
+ * The goal is a card-sized hint of what the file is about, so markdown
172
+ * decoration is flattened rather than rendered: fenced code is dropped whole
173
+ * (it reads as noise at thumbnail size), headings and emphasis lose their
174
+ * markers, links keep their text, and blank runs collapse.
175
+ */
176
+ function buildFileExcerpt(source, limit = 480) {
177
+ if (!source) return "";
178
+ const cleaned = source.replace(/```[\s\S]*?(?:```|$)/g, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<\/?[a-z][^>]*>/gi, " ").replace(/!\[[^\]]*\]\([^)]*\)/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^[ \t]*#{1,6}[ \t]+/gm, "").replace(/^[ \t]*>[ \t]?/gm, "").replace(/^[ \t]*[-*+][ \t]+/gm, "").replace(/^[ \t]*\d+\.[ \t]+/gm, "").replace(/^[ \t]*([-*_])(?:[ \t]*\1){2,}[ \t]*$/gm, " ").replace(/\|/g, " ").replace(/[*_`~]/g, "").replace(/\r\n?/g, "\n").replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").replace(/[ \t]*\n[ \t]*/g, "\n").trim();
179
+ if (cleaned.length <= limit) return cleaned;
180
+ const slice = cleaned.slice(0, limit);
181
+ const lastSpace = slice.lastIndexOf(" ");
182
+ return `${(lastSpace > limit * .6 ? slice.slice(0, lastSpace) : slice).trimEnd()}…`;
183
+ }
184
+ /** Basename of a path, used as the default card title. */
185
+ function fileBaseName(path) {
186
+ return path.split("/").filter(Boolean).pop() ?? path;
187
+ }
188
+ /**
189
+ * Build the cached display facts for a file node.
190
+ *
191
+ * Content is optional by design: a snapshot built without it still produces a
192
+ * usable `blank` card, so a node can be created the instant a file is dropped
193
+ * and enriched later without blocking on a read.
194
+ */
195
+ function buildFileSnapshot(input) {
196
+ const snapshot = { title: input.title?.trim() || fileBaseName(input.path) };
197
+ if (input.mimeType) snapshot.mimeType = input.mimeType;
198
+ if (typeof input.size === "number" && Number.isFinite(input.size)) snapshot.size = input.size;
199
+ if (typeof input.mtimeMs === "number" && Number.isFinite(input.mtimeMs)) snapshot.mtimeMs = input.mtimeMs;
200
+ const content = input.content;
201
+ if (typeof content !== "string" || content.length === 0) return snapshot;
202
+ const { frontmatter, body } = splitFrontmatter(content);
203
+ const cover = resolveCoverRef(input.path, readCoverFromFrontmatter(frontmatter));
204
+ if (cover?.kind === "url") snapshot.coverUrl = cover.url;
205
+ else if (cover?.kind === "path") snapshot.coverPath = cover.path;
206
+ const excerpt = buildFileExcerpt(body);
207
+ if (excerpt) snapshot.excerpt = excerpt;
208
+ return snapshot;
209
+ }
210
+ /** Whether a snapshot's cached facts still describe the file on disk. */
211
+ function isFileSnapshotFresh(snapshot, file) {
212
+ if (!snapshot) return false;
213
+ if (snapshot.mtimeMs === void 0 || file.mtimeMs === void 0) return false;
214
+ if (snapshot.mtimeMs !== file.mtimeMs) return false;
215
+ if (snapshot.size !== void 0 && file.size !== void 0 && snapshot.size !== file.size) return false;
216
+ return true;
217
+ }
218
+ /** Whether a file is small enough that fetching a text preview is worthwhile. */
219
+ function shouldFetchFileExcerpt(input) {
220
+ if (input.size !== void 0 && input.size > 262144) return false;
221
+ return true;
222
+ }
223
+ /**
224
+ * Classify a failed read.
225
+ *
226
+ * Only a 404 (or 410 Gone) is treated as the file being absent. Everything else
227
+ * — offline, 5xx, timeout, 401/403 — is `unavailable`: the file may well exist and
228
+ * simply not be reachable by this client right now.
229
+ */
230
+ function availabilityFromError(error) {
231
+ const status = typeof error === "object" && error !== null ? error.status : void 0;
232
+ if (typeof status !== "number") return "unavailable";
233
+ if (status === 404 || status === 410) return "missing";
234
+ return "unavailable";
235
+ }
236
+ /**
237
+ * Cache key for a file within a space.
238
+ *
239
+ * A path only means anything relative to its space, and identical paths across
240
+ * spaces are the norm ("README.md"), so every preview cache is keyed by both. The
241
+ * separator is NUL because it cannot appear in a space id or a path, so no path can
242
+ * be crafted to collide with another space's entry.
243
+ */
244
+ function filePreviewScope(spaceId, path) {
245
+ return `${spaceId}\u0000${path}`;
246
+ }
247
+ /**
248
+ * Cache key for one *version* of a file. The mtime is part of the key so a changed
249
+ * file misses the cache instead of serving a stale excerpt or cover.
250
+ */
251
+ function filePreviewMemoKey(spaceId, path, mtimeMs) {
252
+ return `${filePreviewScope(spaceId, path)}@${mtimeMs ?? 0}`;
253
+ }
254
+ /**
255
+ * Fold a freshly read snapshot into the cached one.
256
+ *
257
+ * `complete` decides whether the incoming facts supersede the cached ones or are
258
+ * merged over them, and the difference is not cosmetic. A snapshot omits fields the
259
+ * file does not have, so merging a complete read would resurrect a cover or an
260
+ * excerpt the file no longer contains — permanently, since the stale value is then
261
+ * committed back. Merging is right only for an incomplete read, where an absent
262
+ * field means "could not establish", not "not there".
263
+ */
264
+ function mergeFileSnapshot(cached, incoming, complete) {
265
+ return complete ? incoming : {
266
+ ...cached,
267
+ ...incoming
268
+ };
269
+ }
270
+ //#endregion
271
+ export { FILE_EXCERPT_MAX_BYTES, FILE_EXCERPT_MAX_CHARS, availabilityFromError, buildFileExcerpt, buildFileSnapshot, fileBaseName, filePreviewKind, filePreviewMemoKey, filePreviewScope, fileTypeLabel, formatFileSize, isFileSnapshotFresh, mergeFileSnapshot, readCoverFromFrontmatter, resolveCoverRef, resolveSpacePath, shouldFetchFileExcerpt, splitFrontmatter };
@@ -0,0 +1,42 @@
1
+ //#region src/board/core/palette.d.ts
2
+ /**
3
+ * Board color palette — a small, named set of shape colors shared by note,
4
+ * geo, draw and arrow shapes. Colors are stored by id in shape props (never
5
+ * raw hex), so themes and space `.cohub/theme.css` can remap them via CSS
6
+ * tokens while persisted data stays compact and forward-compatible.
7
+ *
8
+ * Concrete values are resolved from CSS variables at render time:
9
+ * --board-color-{id}-stroke | -fill | -label
10
+ * with hard-coded light/dark tables as offline / export fallbacks.
11
+ */
12
+ type BoardColorId = "brand" | "neutral" | "blue" | "green" | "amber" | "violet" | "rose";
13
+ type BoardColorValue = {
14
+ /** Stroke / accent color (sRGB hex). */
15
+ stroke: number;
16
+ /** Translucent fill used behind shapes (sRGB hex). */
17
+ fill: number;
18
+ /** Readable label color on top of the fill. */
19
+ label: number;
20
+ };
21
+ type BoardColorEntry = {
22
+ id: BoardColorId;
23
+ label: string;
24
+ dark: BoardColorValue;
25
+ light: BoardColorValue;
26
+ };
27
+ declare const BOARD_COLORS: readonly BoardColorEntry[];
28
+ declare const DEFAULT_BOARD_COLOR: BoardColorId;
29
+ declare function isBoardColorId(value: unknown): value is BoardColorId;
30
+ declare function boardColorCssVar(id: BoardColorId, part: keyof BoardColorValue): string;
31
+ /** Resolve a color id to concrete values for a color mode. Unknown → brand. */
32
+ declare function resolveBoardColor(id: unknown, mode: "dark" | "light"): BoardColorValue;
33
+ type BoardShapeColors = Record<BoardColorId, BoardColorValue>;
34
+ /** Build a full shape-color table from hard-coded fallbacks (export / SSR). */
35
+ declare function buildFallbackShapeColors(mode: "dark" | "light"): BoardShapeColors;
36
+ /**
37
+ * Pick a concrete color from a live shape-color table, falling back to the
38
+ * default brand entry when the id is unknown.
39
+ */
40
+ declare function pickBoardColor(colors: BoardShapeColors | null | undefined, id: unknown, mode?: "dark" | "light"): BoardColorValue;
41
+ //#endregion
42
+ export { BOARD_COLORS, BoardColorEntry, BoardColorId, BoardColorValue, BoardShapeColors, DEFAULT_BOARD_COLOR, boardColorCssVar, buildFallbackShapeColors, isBoardColorId, pickBoardColor, resolveBoardColor };
@@ -0,0 +1,138 @@
1
+ //#region src/board/core/palette.ts
2
+ /**
3
+ * Label color fallbacks mirror `--board-color-*-label`, which every theme maps
4
+ * to `--text-primary`: labels sit on a *translucent* fill over the page, not on
5
+ * a saturated swatch, so a tinted label would be dark-on-dark. Keeping these in
6
+ * step with `theme.css` is what makes a headless export match the screen.
7
+ */
8
+ const LABEL_DARK = 16053492;
9
+ const LABEL_LIGHT = 1579035;
10
+ const BOARD_COLORS = [
11
+ {
12
+ id: "brand",
13
+ label: "Brand",
14
+ dark: {
15
+ stroke: 16734751,
16
+ fill: 16734751,
17
+ label: LABEL_DARK
18
+ },
19
+ light: {
20
+ stroke: 15222030,
21
+ fill: 15222030,
22
+ label: LABEL_LIGHT
23
+ }
24
+ },
25
+ {
26
+ id: "neutral",
27
+ label: "Neutral",
28
+ dark: {
29
+ stroke: 10133670,
30
+ fill: 10133670,
31
+ label: LABEL_DARK
32
+ },
33
+ light: {
34
+ stroke: 6251368,
35
+ fill: 6251368,
36
+ label: LABEL_LIGHT
37
+ }
38
+ },
39
+ {
40
+ id: "blue",
41
+ label: "Blue",
42
+ dark: {
43
+ stroke: 3718648,
44
+ fill: 3718648,
45
+ label: LABEL_DARK
46
+ },
47
+ light: {
48
+ stroke: 2450411,
49
+ fill: 2450411,
50
+ label: LABEL_LIGHT
51
+ }
52
+ },
53
+ {
54
+ id: "green",
55
+ label: "Green",
56
+ dark: {
57
+ stroke: 3462041,
58
+ fill: 3462041,
59
+ label: LABEL_DARK
60
+ },
61
+ light: {
62
+ stroke: 1483594,
63
+ fill: 1483594,
64
+ label: LABEL_LIGHT
65
+ }
66
+ },
67
+ {
68
+ id: "amber",
69
+ label: "Amber",
70
+ dark: {
71
+ stroke: 16096779,
72
+ fill: 16096779,
73
+ label: LABEL_DARK
74
+ },
75
+ light: {
76
+ stroke: 14251782,
77
+ fill: 14251782,
78
+ label: LABEL_LIGHT
79
+ }
80
+ },
81
+ {
82
+ id: "violet",
83
+ label: "Violet",
84
+ dark: {
85
+ stroke: 10980346,
86
+ fill: 10980346,
87
+ label: LABEL_DARK
88
+ },
89
+ light: {
90
+ stroke: 8141549,
91
+ fill: 8141549,
92
+ label: LABEL_LIGHT
93
+ }
94
+ },
95
+ {
96
+ id: "rose",
97
+ label: "Rose",
98
+ dark: {
99
+ stroke: 16478597,
100
+ fill: 16478597,
101
+ label: LABEL_DARK
102
+ },
103
+ light: {
104
+ stroke: 14753096,
105
+ fill: 14753096,
106
+ label: LABEL_LIGHT
107
+ }
108
+ }
109
+ ];
110
+ const DEFAULT_BOARD_COLOR = "brand";
111
+ const COLOR_INDEX = new Map(BOARD_COLORS.map((entry) => [entry.id, entry]));
112
+ function isBoardColorId(value) {
113
+ return typeof value === "string" && COLOR_INDEX.has(value);
114
+ }
115
+ function boardColorCssVar(id, part) {
116
+ return `--board-color-${id}-${part}`;
117
+ }
118
+ /** Resolve a color id to concrete values for a color mode. Unknown → brand. */
119
+ function resolveBoardColor(id, mode) {
120
+ return ((isBoardColorId(id) ? COLOR_INDEX.get(id) : void 0) ?? COLOR_INDEX.get("brand"))[mode];
121
+ }
122
+ /** Build a full shape-color table from hard-coded fallbacks (export / SSR). */
123
+ function buildFallbackShapeColors(mode) {
124
+ const out = {};
125
+ for (const entry of BOARD_COLORS) out[entry.id] = entry[mode];
126
+ return out;
127
+ }
128
+ /**
129
+ * Pick a concrete color from a live shape-color table, falling back to the
130
+ * default brand entry when the id is unknown.
131
+ */
132
+ function pickBoardColor(colors, id, mode = "dark") {
133
+ if (colors && isBoardColorId(id) && colors[id]) return colors[id];
134
+ if (colors) return colors[DEFAULT_BOARD_COLOR];
135
+ return resolveBoardColor(id, mode);
136
+ }
137
+ //#endregion
138
+ export { BOARD_COLORS, DEFAULT_BOARD_COLOR, boardColorCssVar, buildFallbackShapeColors, isBoardColorId, pickBoardColor, resolveBoardColor };
@@ -0,0 +1,35 @@
1
+ import { BoardFrame, BoardItem } from "../../protocol/dist/board-document.js";
2
+ import { Rect, WorldPoint } from "../geometry.js";
3
+ import { ShapeCapabilities, ShapeGeometry, ShapeHandle, ShapeResizeMode } from "./shape-types.js";
4
+ //#region src/board/core/shape-definition.d.ts
5
+ type ShapeDefinition = {
6
+ /** The item type this definition handles. */
7
+ type: string;
8
+ capabilities: ShapeCapabilities;
9
+ /** Rotation-aware world bounds (culling, marquee). Defaults to itemBounds. */
10
+ getBounds?: (item: BoardItem) => Rect;
11
+ /** Exact world-space containment (hit testing). Defaults to rotated rect. */
12
+ hitTest?: (item: BoardItem, point: WorldPoint) => boolean;
13
+ /** Interaction handles in the shape's local space. Defaults to none. */
14
+ getHandles?: (item: BoardItem) => ShapeHandle[];
15
+ /**
16
+ * Local-space geometry outline, used for precise hit tests and snapping.
17
+ * Optional; box shapes rely on the frame directly.
18
+ */
19
+ getGeometry?: (item: BoardItem) => ShapeGeometry;
20
+ };
21
+ declare function registerShapeDefinition(definition: ShapeDefinition): void;
22
+ declare function getShapeDefinition(type: string): ShapeDefinition | undefined;
23
+ /**
24
+ * The generic fallback for unknown shape types: a plain movable/resizable box.
25
+ * This guarantees a shape authored by a newer client is still interactive here.
26
+ */
27
+ declare const unknownShapeDefinition: ShapeDefinition;
28
+ declare function definitionForItem(item: BoardItem): ShapeDefinition;
29
+ declare function shapeBounds(item: BoardItem): Rect;
30
+ declare function shapeHitTest(item: BoardItem, point: WorldPoint): boolean;
31
+ declare function shapeHandles(item: BoardItem): ShapeHandle[];
32
+ declare function shapeCapabilities(item: BoardItem): ShapeCapabilities;
33
+ declare function shapeResizeMode(item: BoardItem): ShapeResizeMode;
34
+ //#endregion
35
+ export { type BoardFrame, ShapeDefinition, definitionForItem, getShapeDefinition, registerShapeDefinition, shapeBounds, shapeCapabilities, shapeHandles, shapeHitTest, shapeResizeMode, unknownShapeDefinition };
@@ -0,0 +1,64 @@
1
+ import { isUnknownItem } from "../../protocol/dist/board-document.js";
2
+ import { frameContainsPoint, itemBounds } from "../geometry.js";
3
+ import { resizeModeForCapabilities } from "./shape-types.js";
4
+ //#region src/board/core/shape-definition.ts
5
+ /**
6
+ * ShapeDefinition protocol + registry.
7
+ *
8
+ * A ShapeDefinition describes everything the editor needs to *behave* correctly
9
+ * around a shape — bounds, hit testing, handles, capabilities, snap targets —
10
+ * with no knowledge of how the shape is drawn. The Pixi renderers are a separate
11
+ * concern (board-renderer-registry). This separation is what lets us add a shape
12
+ * by writing one definition + one renderer, and keeps geometry fully unit-testable
13
+ * without a GPU.
14
+ *
15
+ * Definitions are looked up by `item.type`. Unknown types fall back to a generic
16
+ * box definition so unrecognised shapes still select, move and resize.
17
+ */
18
+ const definitions = /* @__PURE__ */ new Map();
19
+ function registerShapeDefinition(definition) {
20
+ definitions.set(definition.type, definition);
21
+ }
22
+ function getShapeDefinition(type) {
23
+ return definitions.get(type);
24
+ }
25
+ /**
26
+ * The generic fallback for unknown shape types: a plain movable/resizable box.
27
+ * This guarantees a shape authored by a newer client is still interactive here.
28
+ */
29
+ const unknownShapeDefinition = {
30
+ type: "__unknown__",
31
+ capabilities: {
32
+ canMove: true,
33
+ canResize: true,
34
+ aspectLocked: false,
35
+ canRotate: true,
36
+ canEdit: false,
37
+ canBind: true,
38
+ canSnap: true,
39
+ canLock: true
40
+ }
41
+ };
42
+ function definitionForItem(item) {
43
+ if (isUnknownItem(item)) return unknownShapeDefinition;
44
+ return definitions.get(item.type) ?? unknownShapeDefinition;
45
+ }
46
+ function shapeBounds(item) {
47
+ return definitionForItem(item).getBounds?.(item) ?? itemBounds(item.frame);
48
+ }
49
+ function shapeHitTest(item, point) {
50
+ const definition = definitionForItem(item);
51
+ if (definition.hitTest) return definition.hitTest(item, point);
52
+ return frameContainsPoint(item.frame, point);
53
+ }
54
+ function shapeHandles(item) {
55
+ return definitionForItem(item).getHandles?.(item) ?? [];
56
+ }
57
+ function shapeCapabilities(item) {
58
+ return definitionForItem(item).capabilities;
59
+ }
60
+ function shapeResizeMode(item) {
61
+ return resizeModeForCapabilities(shapeCapabilities(item));
62
+ }
63
+ //#endregion
64
+ export { definitionForItem, getShapeDefinition, registerShapeDefinition, shapeBounds, shapeCapabilities, shapeHandles, shapeHitTest, shapeResizeMode, unknownShapeDefinition };
@@ -0,0 +1,144 @@
1
+ import { BoardFrame } from "../../protocol/dist/board-document.js";
2
+ import { Rect, WorldPoint } from "../geometry.js";
3
+ //#region src/board/core/shape-types.d.ts
4
+ /**
5
+ * A shape's geometry, expressed in the shape's local space (origin at the
6
+ * frame's top-left, unrotated). Hit testing and handles are derived from this,
7
+ * so a shape only declares its outline once.
8
+ */
9
+ type ShapeGeometry = {
10
+ /** Axis-aligned bounds in local space (usually the full frame). */
11
+ bounds: Rect;
12
+ /**
13
+ * Exact point containment test in *world* space. Receives the shape frame so
14
+ * it can account for rotation. Defaults to the rotated-rect test when absent.
15
+ */
16
+ containsWorldPoint?: (frame: BoardFrame, point: WorldPoint) => boolean;
17
+ };
18
+ type ShapeHandleId = string;
19
+ type ShapeHandle = {
20
+ id: ShapeHandleId;
21
+ /** Position in the shape's local (unrotated, frame-origin) space. */
22
+ x: number;
23
+ y: number;
24
+ /** Visual + hit radius hint in screen px; the stage scales by zoom. */
25
+ radius?: number;
26
+ };
27
+ /** Result of dragging a handle: a patched frame and/or shape props. */
28
+ type HandleDragResult = {
29
+ frame?: BoardFrame;
30
+ props?: Record<string, unknown>;
31
+ };
32
+ /**
33
+ * What a shape supports. The editor, selection toolbar and tools read these so
34
+ * behaviour is data-driven: adding a shape never requires editing the editor.
35
+ */
36
+ type ShapeCapabilities = {
37
+ /** Can be moved by dragging. */
38
+ canMove: boolean;
39
+ /** Shows resize handles. */
40
+ canResize: boolean;
41
+ /**
42
+ * Resize always preserves the shape's aspect ratio, regardless of Shift.
43
+ * True for shapes whose content has a single intrinsic scale (text font size,
44
+ * media pixels, stroke geometry) — distorting the frame would either letterbox
45
+ * the content or have no representation in the data model.
46
+ */
47
+ aspectLocked: boolean;
48
+ /** Shows the rotation handle. */
49
+ canRotate: boolean;
50
+ /** Supports double-click inline editing (text/note). */
51
+ canEdit: boolean;
52
+ /** Can be a binding target for arrows. */
53
+ canBind: boolean;
54
+ /** Participates in snapping as a target. */
55
+ canSnap: boolean;
56
+ /** Can be locked against accidental edits. */
57
+ canLock: boolean;
58
+ };
59
+ /**
60
+ * Effective resize behaviour derived from the backwards-compatible capability
61
+ * flags. Consumers should use this mode instead of repeating the flag matrix.
62
+ */
63
+ type ShapeResizeMode = "none" | "uniform" | "free";
64
+ declare function resizeModeForCapabilities(capabilities: ShapeCapabilities): ShapeResizeMode;
65
+ declare const FULL_CAPABILITIES: ShapeCapabilities;
66
+ /**
67
+ * A single raw input sample of a freehand stroke. We persist the *raw* samples
68
+ * (not just a simplified path) so strokes can be re-smoothed, re-simplified or
69
+ * re-rendered at any LOD later without losing information — data first.
70
+ */
71
+ type DrawPoint = {
72
+ x: number;
73
+ y: number;
74
+ /** Pen pressure 0..1; 0.5 when unavailable (mouse). */
75
+ p: number;
76
+ };
77
+ type DrawShapeProps = {
78
+ /** Raw samples in the shape's local space. */
79
+ points: DrawPoint[];
80
+ color: string;
81
+ /** Stroke width in world units. */
82
+ size: number;
83
+ };
84
+ type GeoKind = "rectangle" | "ellipse" | "diamond" | "triangle" | "rounded";
85
+ declare const GEO_KINDS: readonly GeoKind[];
86
+ declare function isGeoKind(value: unknown): value is GeoKind;
87
+ type GeoShapeProps = {
88
+ geo: GeoKind;
89
+ /** Optional label rendered centered inside the shape. */
90
+ text: string;
91
+ color: string;
92
+ /** Fill opacity 0..1 (0 = outline only). */
93
+ fillOpacity: number;
94
+ };
95
+ /**
96
+ * An arrow endpoint: either a free point (local space) or a binding to another
97
+ * shape. Bindings use a normalized anchor (0..1 of the target's frame) so the
98
+ * arrow tracks the target through move/resize without storing absolute coords.
99
+ */
100
+ type ArrowEndpoint = {
101
+ kind: "point";
102
+ x: number;
103
+ y: number;
104
+ } | {
105
+ kind: "binding";
106
+ /** Target shape id. */
107
+ target: string;
108
+ /** Normalized anchor on the target frame (0..1). */
109
+ nx: number;
110
+ ny: number;
111
+ /** Whether the binding snaps to the nearest edge/center. */
112
+ precise: boolean;
113
+ };
114
+ type ArrowShapeProps = {
115
+ start: ArrowEndpoint;
116
+ end: ArrowEndpoint;
117
+ /** Bend of a curved arrow, as a fraction of the length (-0.5..0.5). */
118
+ bend: number;
119
+ color: string;
120
+ size: number;
121
+ /** Arrowhead placement. */
122
+ arrowStart: boolean;
123
+ arrowEnd: boolean;
124
+ label: string;
125
+ };
126
+ type NoteShapeProps = {
127
+ text: string;
128
+ color: string;
129
+ };
130
+ type TextShapeProps = {
131
+ text: string;
132
+ color: string;
133
+ fontSize: number;
134
+ };
135
+ type ImageShapeProps = {
136
+ path: string;
137
+ mimeType?: string;
138
+ };
139
+ type VideoShapeProps = {
140
+ path: string;
141
+ mimeType?: string;
142
+ };
143
+ //#endregion
144
+ export { ArrowEndpoint, ArrowShapeProps, DrawPoint, DrawShapeProps, FULL_CAPABILITIES, GEO_KINDS, GeoKind, GeoShapeProps, HandleDragResult, ImageShapeProps, NoteShapeProps, ShapeCapabilities, ShapeGeometry, ShapeHandle, ShapeHandleId, ShapeResizeMode, TextShapeProps, VideoShapeProps, isGeoKind, resizeModeForCapabilities };