@neta-art/cohub 3.1.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.
- package/README.md +33 -2
- package/dist/{board.d.ts → board/animation.d.ts} +4 -2
- package/dist/{chunks/board.js → board/animation.js} +5 -44
- package/dist/board/codec.d.ts +25 -0
- package/dist/board/codec.js +252 -0
- package/dist/board/core/bindings.d.ts +45 -0
- package/dist/board/core/bindings.js +162 -0
- package/dist/board/core/draw-geometry.d.ts +31 -0
- package/dist/board/core/draw-geometry.js +183 -0
- package/dist/board/core/export-assets.d.ts +14 -0
- package/dist/board/core/export-assets.js +22 -0
- package/dist/board/core/export-plan.d.ts +84 -0
- package/dist/board/core/export-plan.js +128 -0
- package/dist/board/core/file-preview.d.ts +176 -0
- package/dist/board/core/file-preview.js +271 -0
- package/dist/board/core/palette.d.ts +42 -0
- package/dist/board/core/palette.js +138 -0
- package/dist/board/core/shape-definition.d.ts +35 -0
- package/dist/board/core/shape-definition.js +64 -0
- package/dist/board/core/shape-types.d.ts +144 -0
- package/dist/board/core/shape-types.js +27 -0
- package/dist/board/core/text-metrics.d.ts +18 -0
- package/dist/board/core/text-metrics.js +43 -0
- package/dist/board/export/index.d.ts +56 -0
- package/dist/board/export/index.js +86 -0
- package/dist/board/export/scene.d.ts +35 -0
- package/dist/board/export/scene.js +69 -0
- package/dist/board/geometry.d.ts +131 -0
- package/dist/board/geometry.js +468 -0
- package/dist/board/headless/index.d.ts +69 -0
- package/dist/board/headless/index.js +150 -0
- package/dist/board/image-key.d.ts +19 -0
- package/dist/board/image-key.js +34 -0
- package/dist/board/index.d.ts +16 -0
- package/dist/board/index.js +16 -0
- package/dist/board/render/index.d.ts +6 -0
- package/dist/board/render/index.js +6 -0
- package/dist/board/render/palette.d.ts +5 -0
- package/dist/board/render/palette.js +30 -0
- package/dist/board/render/renderers/arrow-card-renderer.d.ts +5 -0
- package/dist/board/render/renderers/arrow-card-renderer.js +157 -0
- package/dist/board/render/renderers/base-card-renderer.d.ts +46 -0
- package/dist/board/render/renderers/base-card-renderer.js +153 -0
- package/dist/board/render/renderers/board-renderer-registry.d.ts +85 -0
- package/dist/board/render/renderers/board-renderer-registry.js +43 -0
- package/dist/board/render/renderers/draw-card-renderer.d.ts +5 -0
- package/dist/board/render/renderers/draw-card-renderer.js +82 -0
- package/dist/board/render/renderers/far-plate.d.ts +39 -0
- package/dist/board/render/renderers/far-plate.js +92 -0
- package/dist/board/render/renderers/file-card-renderer.d.ts +33 -0
- package/dist/board/render/renderers/file-card-renderer.js +371 -0
- package/dist/board/render/renderers/frame-card-renderer.d.ts +5 -0
- package/dist/board/render/renderers/frame-card-renderer.js +99 -0
- package/dist/board/render/renderers/geo-card-renderer.d.ts +5 -0
- package/dist/board/render/renderers/geo-card-renderer.js +128 -0
- package/dist/board/render/renderers/image-card-renderer.d.ts +8 -0
- package/dist/board/render/renderers/image-card-renderer.js +138 -0
- package/dist/board/render/renderers/note-card-renderer.d.ts +5 -0
- package/dist/board/render/renderers/note-card-renderer.js +112 -0
- package/dist/board/render/renderers/text-card-renderer.d.ts +7 -0
- package/dist/board/render/renderers/text-card-renderer.js +94 -0
- package/dist/board/render/renderers/unknown-card-renderer.d.ts +5 -0
- package/dist/board/render/renderers/unknown-card-renderer.js +102 -0
- package/dist/board/render/renderers/video-card-renderer.d.ts +7 -0
- package/dist/board/render/renderers/video-card-renderer.js +146 -0
- package/dist/board/render/text-measurement.d.ts +30 -0
- package/dist/board/render/text-measurement.js +57 -0
- package/dist/board/render/text-resolution.d.ts +16 -0
- package/dist/board/render/text-resolution.js +43 -0
- package/dist/board/render/themes/board-theme-registry.d.ts +20 -0
- package/dist/board/render/themes/board-theme-registry.js +13 -0
- package/dist/board/render/themes/clean-theme.d.ts +5 -0
- package/dist/board/render/themes/clean-theme.js +102 -0
- package/dist/chunks/http.d.ts +1 -2
- package/dist/chunks/websocket.d.ts +798 -352
- package/dist/http.d.ts +1 -2
- package/dist/index.d.ts +99 -4
- package/dist/index.js +391 -1
- package/dist/protocol/dist/board-constants.d.ts +23 -0
- package/dist/protocol/dist/board-constants.js +60 -0
- package/dist/protocol/dist/board-document.d.ts +1124 -0
- package/dist/protocol/dist/board-document.js +331 -0
- package/dist/protocol/dist/board.d.ts +186 -0
- package/dist/protocol/dist/board.js +207 -0
- package/dist/protocol/dist/index.d.ts +5 -0
- package/dist/protocol/dist/index.js +5 -0
- package/dist/protocol/dist/provenance.js +12 -0
- package/dist/protocol/dist/realtime/board-awareness.d.ts +1 -0
- package/dist/protocol/dist/realtime/board-awareness.js +117 -0
- package/dist/protocol/dist/realtime/types.d.ts +2 -0
- package/package.json +39 -4
- package/dist/board.js +0 -2
- 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 };
|