@scrawl-board/board 0.1.0-beta.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/LICENSE +201 -0
- package/NOTICE +28 -0
- package/README.md +106 -0
- package/dist/browser.d.ts +1012 -0
- package/dist/browser.js +49190 -0
- package/dist/core.d.ts +1084 -0
- package/dist/core.js +2253 -0
- package/dist/index.d.ts +1797 -0
- package/dist/index.js +50827 -0
- package/dist/react.d.ts +1171 -0
- package/dist/react.js +50797 -0
- package/dist/styles.css +297 -0
- package/package.json +72 -0
package/dist/core.js
ADDED
|
@@ -0,0 +1,2253 @@
|
|
|
1
|
+
//#region src/core-internal/assets.ts
|
|
2
|
+
var ASSET_REF_PATTERN = /^asset:[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?:[A-Za-z0-9._~-]+$/;
|
|
3
|
+
function isAssetRef(value) {
|
|
4
|
+
return typeof value === "string" && ASSET_REF_PATTERN.test(value) && new TextEncoder().encode(value).length <= 512;
|
|
5
|
+
}
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/core-internal/identifiers.ts
|
|
8
|
+
function documentId(value) {
|
|
9
|
+
if (!value) throw new TypeError("DocumentId cannot be empty");
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function strokeId(value) {
|
|
13
|
+
if (!value) throw new TypeError("StrokeId cannot be empty");
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/document-schema.ts
|
|
18
|
+
var CURRENT_DOCUMENT_SCHEMA_VERSION = 1;
|
|
19
|
+
var DocumentRecoveryError = class extends Error {
|
|
20
|
+
constructor(code, message, originalBytes, path) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.originalBytes = originalBytes;
|
|
24
|
+
this.path = path;
|
|
25
|
+
this.name = "DocumentRecoveryError";
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
function loadDocumentBytes(originalBytes) {
|
|
29
|
+
let raw;
|
|
30
|
+
try {
|
|
31
|
+
raw = JSON.parse(originalBytes);
|
|
32
|
+
} catch {
|
|
33
|
+
return failure("DOCUMENT_JSON_INVALID", "Document is not valid JSON", originalBytes);
|
|
34
|
+
}
|
|
35
|
+
const result = migrateDocument(raw);
|
|
36
|
+
if (result.ok) return result;
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
error: new DocumentRecoveryError(result.error.code, result.error.message, originalBytes, result.error.path)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function migrateDocument(raw) {
|
|
43
|
+
if (!isRecord(raw)) return failure("DOCUMENT_VALIDATION_FAILED", "Document must be an object");
|
|
44
|
+
const version = raw.schemaVersion ?? 0;
|
|
45
|
+
if (version !== 0 && version !== 1) return failure("DOCUMENT_VERSION_UNSUPPORTED", `Document schema version ${String(version)} is unsupported`, void 0, "schemaVersion");
|
|
46
|
+
if (!isJsonValue(raw)) return failure("DOCUMENT_VALIDATION_FAILED", "Document must contain JSON values only");
|
|
47
|
+
const strokes = validateStrokes(raw.strokes);
|
|
48
|
+
if (strokes instanceof DocumentRecoveryError) return {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: strokes
|
|
51
|
+
};
|
|
52
|
+
const collections = {
|
|
53
|
+
notes: validateCollection(raw, "notes", version, validateNote),
|
|
54
|
+
textBlocks: validateCollection(raw, "textBlocks", version, validateText),
|
|
55
|
+
tables: validateCollection(raw, "tables", version, validateTable),
|
|
56
|
+
images: validateCollection(raw, "images", version, validateImage),
|
|
57
|
+
timers: validateCollection(raw, "timers", version, validateTimer),
|
|
58
|
+
customObjects: validateCollection(raw, "customObjects", version, validateCustomObject, false)
|
|
59
|
+
};
|
|
60
|
+
if (collections.notes instanceof DocumentRecoveryError) return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: collections.notes
|
|
63
|
+
};
|
|
64
|
+
if (collections.textBlocks instanceof DocumentRecoveryError) return {
|
|
65
|
+
ok: false,
|
|
66
|
+
error: collections.textBlocks
|
|
67
|
+
};
|
|
68
|
+
if (collections.tables instanceof DocumentRecoveryError) return {
|
|
69
|
+
ok: false,
|
|
70
|
+
error: collections.tables
|
|
71
|
+
};
|
|
72
|
+
if (collections.images instanceof DocumentRecoveryError) return {
|
|
73
|
+
ok: false,
|
|
74
|
+
error: collections.images
|
|
75
|
+
};
|
|
76
|
+
if (collections.timers instanceof DocumentRecoveryError) return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: collections.timers
|
|
79
|
+
};
|
|
80
|
+
if (collections.customObjects instanceof DocumentRecoveryError) return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: collections.customObjects
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
migratedFrom: version,
|
|
87
|
+
document: {
|
|
88
|
+
schemaVersion: 1,
|
|
89
|
+
strokes,
|
|
90
|
+
notes: collections.notes,
|
|
91
|
+
textBlocks: collections.textBlocks,
|
|
92
|
+
tables: collections.tables,
|
|
93
|
+
images: collections.images,
|
|
94
|
+
timers: collections.timers,
|
|
95
|
+
customObjects: collections.customObjects
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function serializeDocument(document) {
|
|
100
|
+
const result = migrateDocument(document);
|
|
101
|
+
if (!result.ok || result.migratedFrom !== 1) throw result.ok ? new DocumentRecoveryError("DOCUMENT_VALIDATION_FAILED", "Saving requires the current Document schema") : result.error;
|
|
102
|
+
return JSON.stringify(result.document);
|
|
103
|
+
}
|
|
104
|
+
function validateCollection(raw, name, version, validator, requiredAtCurrentVersion = true) {
|
|
105
|
+
if (requiredAtCurrentVersion && version === 1 && !(name in raw)) return validation(`${name} is required`, name);
|
|
106
|
+
const value = raw[name] ?? [];
|
|
107
|
+
if (!Array.isArray(value)) return validation(`${name} must be an array`, name);
|
|
108
|
+
for (let index = 0; index < value.length; index += 1) if (!isRecord(value[index]) || !validator(value[index])) return validation(`invalid ${name} entry`, `${name}[${index}]`);
|
|
109
|
+
return structuredClone(value);
|
|
110
|
+
}
|
|
111
|
+
function validateStrokes(raw) {
|
|
112
|
+
if (!Array.isArray(raw)) return validation("strokes must be an array", "strokes");
|
|
113
|
+
const strokes = [];
|
|
114
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
115
|
+
const value = raw[index];
|
|
116
|
+
const path = `strokes[${index}]`;
|
|
117
|
+
if (!isRecord(value) || !nonempty(value.id) || !nonempty(value.color) || !positive(value.baseWidth) || !optionalEnum(value.tool, ["marker", "highlighter"]) || !validLock(value) || !optionalString(value.clusterId) || !validMatrix(value.matrix) || !Array.isArray(value.points)) return validation("invalid stroke", path);
|
|
118
|
+
const points = [];
|
|
119
|
+
for (let pointIndex = 0; pointIndex < value.points.length; pointIndex += 1) {
|
|
120
|
+
const point = normalizePoint(value.points[pointIndex]);
|
|
121
|
+
if (!point) return validation("invalid stroke point", `${path}.points[${pointIndex}]`);
|
|
122
|
+
points.push(point);
|
|
123
|
+
}
|
|
124
|
+
strokes.push({
|
|
125
|
+
...structuredClone(value),
|
|
126
|
+
id: strokeId(value.id),
|
|
127
|
+
points
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return strokes;
|
|
131
|
+
}
|
|
132
|
+
function normalizePoint(raw) {
|
|
133
|
+
const values = Array.isArray(raw) ? raw : isRecord(raw) ? [
|
|
134
|
+
raw.x,
|
|
135
|
+
raw.y,
|
|
136
|
+
raw.pressure,
|
|
137
|
+
raw.erase ?? 0
|
|
138
|
+
] : [];
|
|
139
|
+
if (values.length !== 4 || !values.every(finite) || !unit(values[2]) || !unit(values[3])) return null;
|
|
140
|
+
return values;
|
|
141
|
+
}
|
|
142
|
+
function validateNote(v) {
|
|
143
|
+
return basePosition(v) && positive(v.size) && finite(v.zOffset) && nonempty(v.color) && typeof v.text === "string" && validLock(v) && (v.votes === void 0 || Array.isArray(v.votes) && v.votes.every((vote) => isRecord(vote) && nonempty(vote.userId) && nonempty(vote.name) && nonempty(vote.color)));
|
|
144
|
+
}
|
|
145
|
+
function validateText(v) {
|
|
146
|
+
return basePosition(v) && typeof v.text === "string" && positive(v.fontSize) && nonempty(v.color) && validLock(v) && optionalString(v.clusterId);
|
|
147
|
+
}
|
|
148
|
+
function validateTable(v) {
|
|
149
|
+
return basePosition(v) && integer(v.rows) && integer(v.cols) && numberArray(v.colWidths, v.cols) && numberArray(v.rowHeights, v.rows) && isStringRecord(v.cells) && validLock(v) && optionalString(v.color) && optionalString(v.backgroundColor) && optionalString(v.clusterId);
|
|
150
|
+
}
|
|
151
|
+
function validateImage(v) {
|
|
152
|
+
const identifiesAnImage = v.ref !== void 0 ? isAssetRef(v.ref) : nonempty(v.src);
|
|
153
|
+
return basePosition(v) && identifiesAnImage && positive(v.width) && positive(v.height) && positive(v.aspectRatio) && validLock(v) && optionalString(v.name) && optionalString(v.createdAt) && optionalString(v.stamp);
|
|
154
|
+
}
|
|
155
|
+
function validateTimer(v) {
|
|
156
|
+
return basePosition(v) && positive(v.size) && positive(v.durationMs) && finite(v.remainingMs) && v.remainingMs >= 0 && (v.runningSince === void 0 || finite(v.runningSince)) && validLock(v);
|
|
157
|
+
}
|
|
158
|
+
function validateCustomObject(v) {
|
|
159
|
+
return nonempty(v.id) && nonempty(v.type) && integer(v.schemaVersion) && validMatrix(v.transform) && Array.isArray(v.transform) && validCustomFallback(v.fallback) && (v.lock === void 0 || validCustomLock(v.lock)) && isJsonValue(v.props);
|
|
160
|
+
}
|
|
161
|
+
function validCustomFallback(v) {
|
|
162
|
+
if (!isRecord(v)) return false;
|
|
163
|
+
const bounds = v.bounds;
|
|
164
|
+
return isRecord(bounds) && finite(bounds.x) && finite(bounds.y) && finite(bounds.width) && finite(bounds.height) && optionalString(v.label);
|
|
165
|
+
}
|
|
166
|
+
function validCustomLock(v) {
|
|
167
|
+
return isRecord(v) && nonempty(v.holderId) && finite(v.acquiredAt);
|
|
168
|
+
}
|
|
169
|
+
function basePosition(v) {
|
|
170
|
+
return nonempty(v.id) && finite(v.x) && finite(v.y);
|
|
171
|
+
}
|
|
172
|
+
function validLock(v) {
|
|
173
|
+
return (v.locked === void 0 || typeof v.locked === "boolean") && optionalString(v.lockedBy) && optionalString(v.lockedByName);
|
|
174
|
+
}
|
|
175
|
+
function validMatrix(v) {
|
|
176
|
+
return v === void 0 || Array.isArray(v) && v.length === 6 && v.every(finite);
|
|
177
|
+
}
|
|
178
|
+
function numberArray(v, length) {
|
|
179
|
+
return Array.isArray(v) && v.length === length && v.every(positive);
|
|
180
|
+
}
|
|
181
|
+
function isStringRecord(v) {
|
|
182
|
+
return isRecord(v) && Object.values(v).every((item) => typeof item === "string");
|
|
183
|
+
}
|
|
184
|
+
function optionalString(v) {
|
|
185
|
+
return v === void 0 || typeof v === "string";
|
|
186
|
+
}
|
|
187
|
+
function optionalEnum(v, values) {
|
|
188
|
+
return v === void 0 || typeof v === "string" && values.includes(v);
|
|
189
|
+
}
|
|
190
|
+
function nonempty(v) {
|
|
191
|
+
return typeof v === "string" && v.length > 0;
|
|
192
|
+
}
|
|
193
|
+
function finite(v) {
|
|
194
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
195
|
+
}
|
|
196
|
+
function positive(v) {
|
|
197
|
+
return finite(v) && v > 0;
|
|
198
|
+
}
|
|
199
|
+
function integer(v) {
|
|
200
|
+
return Number.isInteger(v) && v > 0;
|
|
201
|
+
}
|
|
202
|
+
function unit(v) {
|
|
203
|
+
return finite(v) && v >= 0 && v <= 1;
|
|
204
|
+
}
|
|
205
|
+
function isRecord(v) {
|
|
206
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
|
|
207
|
+
const prototype = Object.getPrototypeOf(v);
|
|
208
|
+
return prototype === Object.prototype || prototype === null;
|
|
209
|
+
}
|
|
210
|
+
function isJsonValue(v) {
|
|
211
|
+
if (v === null || typeof v === "string" || typeof v === "boolean" || finite(v)) return true;
|
|
212
|
+
if (Array.isArray(v)) return v.every(isJsonValue);
|
|
213
|
+
return isRecord(v) && Object.values(v).every(isJsonValue);
|
|
214
|
+
}
|
|
215
|
+
function failure(code, message, originalBytes, path) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
error: new DocumentRecoveryError(code, message, originalBytes, path)
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function validation(message, path) {
|
|
222
|
+
return new DocumentRecoveryError("DOCUMENT_VALIDATION_FAILED", message, void 0, path);
|
|
223
|
+
}
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region src/core-internal/affine2d.ts
|
|
226
|
+
var IDENTITY = [
|
|
227
|
+
1,
|
|
228
|
+
0,
|
|
229
|
+
0,
|
|
230
|
+
1,
|
|
231
|
+
0,
|
|
232
|
+
0
|
|
233
|
+
];
|
|
234
|
+
function isIdentity(m) {
|
|
235
|
+
return m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1 && m[4] === 0 && m[5] === 0;
|
|
236
|
+
}
|
|
237
|
+
/** Compose: apply `first`, then `second`. */
|
|
238
|
+
function mul(second, first) {
|
|
239
|
+
const [a2, b2, c2, d2, tx2, ty2] = second;
|
|
240
|
+
const [a1, b1, c1, d1, tx1, ty1] = first;
|
|
241
|
+
return [
|
|
242
|
+
a2 * a1 + c2 * b1,
|
|
243
|
+
b2 * a1 + d2 * b1,
|
|
244
|
+
a2 * c1 + c2 * d1,
|
|
245
|
+
b2 * c1 + d2 * d1,
|
|
246
|
+
a2 * tx1 + c2 * ty1 + tx2,
|
|
247
|
+
b2 * tx1 + d2 * ty1 + ty2
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
function invert(m) {
|
|
251
|
+
const [a, b, c, d, tx, ty] = m;
|
|
252
|
+
const det = a * d - b * c;
|
|
253
|
+
const ia = d / det;
|
|
254
|
+
const ib = -b / det;
|
|
255
|
+
const ic = -c / det;
|
|
256
|
+
const id = a / det;
|
|
257
|
+
return [
|
|
258
|
+
ia,
|
|
259
|
+
ib,
|
|
260
|
+
ic,
|
|
261
|
+
id,
|
|
262
|
+
-(ia * tx + ic * ty),
|
|
263
|
+
-(ib * tx + id * ty)
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
function apply(m, p) {
|
|
267
|
+
return {
|
|
268
|
+
x: m[0] * p.x + m[2] * p.y + m[4],
|
|
269
|
+
y: m[1] * p.x + m[3] * p.y + m[5]
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function translation(dx, dy) {
|
|
273
|
+
return [
|
|
274
|
+
1,
|
|
275
|
+
0,
|
|
276
|
+
0,
|
|
277
|
+
1,
|
|
278
|
+
dx,
|
|
279
|
+
dy
|
|
280
|
+
];
|
|
281
|
+
}
|
|
282
|
+
function rotationAbout(angle, cx, cy) {
|
|
283
|
+
const cos = Math.cos(angle);
|
|
284
|
+
const sin = Math.sin(angle);
|
|
285
|
+
return [
|
|
286
|
+
cos,
|
|
287
|
+
sin,
|
|
288
|
+
-sin,
|
|
289
|
+
cos,
|
|
290
|
+
cx - cos * cx + sin * cy,
|
|
291
|
+
cy - sin * cx - cos * cy
|
|
292
|
+
];
|
|
293
|
+
}
|
|
294
|
+
function scalingAbout(sx, sy, cx, cy) {
|
|
295
|
+
return [
|
|
296
|
+
sx,
|
|
297
|
+
0,
|
|
298
|
+
0,
|
|
299
|
+
sy,
|
|
300
|
+
cx - sx * cx,
|
|
301
|
+
cy - sy * cy
|
|
302
|
+
];
|
|
303
|
+
}
|
|
304
|
+
/** Approximate uniform length scale (average of the axis scales). */
|
|
305
|
+
function avgScale(m) {
|
|
306
|
+
return (Math.hypot(m[0], m[1]) + Math.hypot(m[2], m[3])) / 2;
|
|
307
|
+
}
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/core-internal/boardSearch.ts
|
|
310
|
+
function hay(s) {
|
|
311
|
+
return s.toLowerCase();
|
|
312
|
+
}
|
|
313
|
+
function snippet(text, q, width = 72) {
|
|
314
|
+
const i = hay(text).indexOf(q);
|
|
315
|
+
if (i < 0) return text.slice(0, width);
|
|
316
|
+
const start = Math.max(0, i - 16);
|
|
317
|
+
const slice = text.slice(start, start + width).trim();
|
|
318
|
+
return (start > 0 ? "…" : "") + slice;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Find notes, text, table cells, stamps, and comments whose text contains
|
|
322
|
+
* `query`. Empty / whitespace queries return nothing.
|
|
323
|
+
*/
|
|
324
|
+
function searchBoard(query, board) {
|
|
325
|
+
const q = hay(query.trim());
|
|
326
|
+
if (!q) return [];
|
|
327
|
+
const hits = [];
|
|
328
|
+
for (const note of board.notes) if (hay(note.text).includes(q)) hits.push({
|
|
329
|
+
kind: "note",
|
|
330
|
+
id: note.id,
|
|
331
|
+
title: "Note",
|
|
332
|
+
snippet: snippet(note.text, q) || "(empty note)",
|
|
333
|
+
x: note.x,
|
|
334
|
+
y: note.y
|
|
335
|
+
});
|
|
336
|
+
for (const block of board.texts) if (hay(block.text).includes(q)) hits.push({
|
|
337
|
+
kind: "text",
|
|
338
|
+
id: block.id,
|
|
339
|
+
title: "Text",
|
|
340
|
+
snippet: snippet(block.text, q),
|
|
341
|
+
x: block.x,
|
|
342
|
+
y: block.y
|
|
343
|
+
});
|
|
344
|
+
for (const table of board.tables) {
|
|
345
|
+
const cells = Object.entries(table.cells).filter(([, text]) => hay(text).includes(q)).map(([, text]) => text);
|
|
346
|
+
if (cells.length) hits.push({
|
|
347
|
+
kind: "table",
|
|
348
|
+
id: table.id,
|
|
349
|
+
title: "Table",
|
|
350
|
+
snippet: snippet(cells.join(" · "), q),
|
|
351
|
+
x: table.x,
|
|
352
|
+
y: table.y
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
for (const image of board.images) if (hay(`${image.stamp ?? ""} ${image.name ?? ""}`).includes(q)) hits.push({
|
|
356
|
+
kind: "stamp",
|
|
357
|
+
id: image.id,
|
|
358
|
+
title: image.stamp ? "Stamp" : "Image",
|
|
359
|
+
snippet: image.name ?? image.stamp ?? "image",
|
|
360
|
+
x: image.x,
|
|
361
|
+
y: image.y
|
|
362
|
+
});
|
|
363
|
+
for (const comment of board.comments) if (hay([comment.text, ...comment.replies.map((r) => r.text)].join("\n")).includes(q) || hay(comment.authorName).includes(q)) hits.push({
|
|
364
|
+
kind: "comment",
|
|
365
|
+
id: comment.id,
|
|
366
|
+
title: comment.authorName,
|
|
367
|
+
snippet: snippet(comment.text, q),
|
|
368
|
+
x: comment.x,
|
|
369
|
+
y: comment.y
|
|
370
|
+
});
|
|
371
|
+
return hits;
|
|
372
|
+
}
|
|
373
|
+
//#endregion
|
|
374
|
+
//#region src/core-internal/types.ts
|
|
375
|
+
/** Erasure level at which a point counts as fully erased. */
|
|
376
|
+
var ERASE_THRESHOLD = .95;
|
|
377
|
+
function cloneStroke(stroke) {
|
|
378
|
+
return {
|
|
379
|
+
...stroke,
|
|
380
|
+
points: stroke.points.map((p) => ({ ...p })),
|
|
381
|
+
...stroke.matrix ? { matrix: [...stroke.matrix] } : {}
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
var INK_COLORS = {
|
|
385
|
+
black: "#1C1C1E",
|
|
386
|
+
red: "#E0333D",
|
|
387
|
+
blue: "#2563EB",
|
|
388
|
+
green: "#16A34A"
|
|
389
|
+
};
|
|
390
|
+
var HIGHLIGHT_COLORS = {
|
|
391
|
+
yellow: "#FDE047",
|
|
392
|
+
green: "#86EFAC",
|
|
393
|
+
pink: "#F9A8D4",
|
|
394
|
+
blue: "#93C5FD"
|
|
395
|
+
};
|
|
396
|
+
var NOTE_COLORS = {
|
|
397
|
+
yellow: "#FDE68A",
|
|
398
|
+
pink: "#FBCFE8",
|
|
399
|
+
blue: "#BFDBFE",
|
|
400
|
+
green: "#BBF7D0"
|
|
401
|
+
};
|
|
402
|
+
var TEXT_DEFAULT_SIZE = 1.8;
|
|
403
|
+
function cloneText(block) {
|
|
404
|
+
return { ...block };
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Estimated bounds of a text block (average-glyph-width heuristic — no DOM,
|
|
408
|
+
* usable from clustering, export, and tests alike).
|
|
409
|
+
*/
|
|
410
|
+
function measureTextBlock(text, fontSize) {
|
|
411
|
+
const lines = text.split("\n");
|
|
412
|
+
return {
|
|
413
|
+
width: Math.max(1, ...lines.map((l) => l.length)) * fontSize * .55,
|
|
414
|
+
height: lines.length * fontSize * 1.25
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
var NOTE_DEFAULT_SIZE = 10;
|
|
418
|
+
var NOTE_DEFAULT_Z = 1;
|
|
419
|
+
var NOTE_MIN_Z = .4;
|
|
420
|
+
var NOTE_MAX_Z = 6;
|
|
421
|
+
var NOTE_PEEL_STEP = .7;
|
|
422
|
+
function cloneNote(note) {
|
|
423
|
+
return {
|
|
424
|
+
...note,
|
|
425
|
+
...note.votes ? { votes: note.votes.map((v) => ({ ...v })) } : {}
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
var TABLE_DEFAULT_CELL_WIDTH = 8;
|
|
429
|
+
var TABLE_DEFAULT_CELL_HEIGHT = 3.6;
|
|
430
|
+
var TABLE_DEFAULT_FONT_SIZE = 1.25;
|
|
431
|
+
function cloneTable(table) {
|
|
432
|
+
return {
|
|
433
|
+
...table,
|
|
434
|
+
colWidths: [...table.colWidths],
|
|
435
|
+
rowHeights: [...table.rowHeights],
|
|
436
|
+
cells: { ...table.cells }
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
function measureTable(table) {
|
|
440
|
+
return {
|
|
441
|
+
width: table.colWidths.reduce((sum, w) => sum + w, 0),
|
|
442
|
+
height: table.rowHeights.reduce((sum, h) => sum + h, 0)
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
var BOARD_COLOR = "#FFFFFF";
|
|
446
|
+
var FOG_COLOR = "#FFFFFF";
|
|
447
|
+
function cloneImage(img) {
|
|
448
|
+
return { ...img };
|
|
449
|
+
}
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/core-internal/clusterStore.ts
|
|
452
|
+
/** A stroke joins a cluster last written to within this window at full reach. */
|
|
453
|
+
var RECENT_MS = 8e3;
|
|
454
|
+
/** Older clusters are still joinable, at half the gap threshold. */
|
|
455
|
+
var STALE_FACTOR = .5;
|
|
456
|
+
var GAP_FACTOR = .9;
|
|
457
|
+
var MIN_GAP = 1;
|
|
458
|
+
var MAX_GAP = 8;
|
|
459
|
+
var ClusterStore = class {
|
|
460
|
+
constructor(doc, createId, now) {
|
|
461
|
+
this.doc = doc;
|
|
462
|
+
this.createId = createId;
|
|
463
|
+
this.now = now;
|
|
464
|
+
this.clusters = /* @__PURE__ */ new Map();
|
|
465
|
+
this.byStroke = /* @__PURE__ */ new Map();
|
|
466
|
+
this.unsubscribe = doc.subscribe((change) => {
|
|
467
|
+
for (const id of change.removed) this.removeMember(id);
|
|
468
|
+
for (const id of change.textRemoved) this.removeMember(id);
|
|
469
|
+
for (const stroke of change.added) this.addMember(stroke.id, stroke.clusterId);
|
|
470
|
+
for (const block of change.textAdded) this.addMember(block.id, block.clusterId);
|
|
471
|
+
const moved = /* @__PURE__ */ new Set();
|
|
472
|
+
for (const stroke of change.transformed) {
|
|
473
|
+
const cid = this.byStroke.get(stroke.id);
|
|
474
|
+
if (cid) moved.add(cid);
|
|
475
|
+
}
|
|
476
|
+
for (const block of change.textUpdated) {
|
|
477
|
+
const cid = this.byStroke.get(block.id);
|
|
478
|
+
if (cid) moved.add(cid);
|
|
479
|
+
}
|
|
480
|
+
for (const cid of moved) this.recompute(cid);
|
|
481
|
+
});
|
|
482
|
+
for (const stroke of doc.all()) this.addMember(stroke.id, stroke.clusterId, 0);
|
|
483
|
+
for (const block of doc.allTexts()) this.addMember(block.id, block.clusterId, 0);
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Pick (or create) the cluster for a freshly drawn stroke and stamp its
|
|
487
|
+
* clusterId. Gap threshold scales with stroke height, per the build prompt.
|
|
488
|
+
*/
|
|
489
|
+
assign(stroke, now = this.now()) {
|
|
490
|
+
const box = bboxOfPoints(stroke.points);
|
|
491
|
+
stroke.clusterId = this.pick(box, now);
|
|
492
|
+
}
|
|
493
|
+
/** Same assignment for a typed text block, using its estimated bounds. */
|
|
494
|
+
assignText(block, now = this.now()) {
|
|
495
|
+
block.clusterId = this.pick(textBBox(block), now);
|
|
496
|
+
}
|
|
497
|
+
pick(box, now) {
|
|
498
|
+
const height = box.maxY - box.minY;
|
|
499
|
+
let best = null;
|
|
500
|
+
for (const cluster of this.clusters.values()) {
|
|
501
|
+
const avgHeight = cluster.heightSum / cluster.strokeIds.size;
|
|
502
|
+
let threshold = clamp$1(GAP_FACTOR * Math.max(height, avgHeight), MIN_GAP, MAX_GAP);
|
|
503
|
+
if (now - cluster.lastStrokeAt > RECENT_MS) threshold *= STALE_FACTOR;
|
|
504
|
+
const gap = bboxGap(box, cluster.bbox);
|
|
505
|
+
if (gap <= threshold && (!best || gap < best.gap)) best = {
|
|
506
|
+
id: cluster.id,
|
|
507
|
+
gap
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
return best?.id ?? this.createId();
|
|
511
|
+
}
|
|
512
|
+
/** All strokes in the same cluster; a clusterless stroke is its own group. */
|
|
513
|
+
membersOf(strokeId) {
|
|
514
|
+
const cid = this.byStroke.get(strokeId);
|
|
515
|
+
const cluster = cid ? this.clusters.get(cid) : void 0;
|
|
516
|
+
return cluster ? [...cluster.strokeIds] : [strokeId];
|
|
517
|
+
}
|
|
518
|
+
dispose() {
|
|
519
|
+
this.unsubscribe();
|
|
520
|
+
this.clusters.clear();
|
|
521
|
+
this.byStroke.clear();
|
|
522
|
+
}
|
|
523
|
+
/** Strokes and text blocks index identically; bbox source differs. */
|
|
524
|
+
memberBBox(id) {
|
|
525
|
+
const strokeBox = this.doc.bbox(id);
|
|
526
|
+
if (strokeBox) return strokeBox;
|
|
527
|
+
const block = this.doc.getText(id);
|
|
528
|
+
return block ? textBBox(block) : void 0;
|
|
529
|
+
}
|
|
530
|
+
addMember(id, clusterId, at = this.now()) {
|
|
531
|
+
if (!clusterId) return;
|
|
532
|
+
const box = this.memberBBox(id);
|
|
533
|
+
if (!box) return;
|
|
534
|
+
let cluster = this.clusters.get(clusterId);
|
|
535
|
+
if (!cluster) {
|
|
536
|
+
cluster = {
|
|
537
|
+
id: clusterId,
|
|
538
|
+
strokeIds: /* @__PURE__ */ new Set(),
|
|
539
|
+
bbox: { ...box },
|
|
540
|
+
lastStrokeAt: at,
|
|
541
|
+
heightSum: 0
|
|
542
|
+
};
|
|
543
|
+
this.clusters.set(cluster.id, cluster);
|
|
544
|
+
}
|
|
545
|
+
cluster.strokeIds.add(id);
|
|
546
|
+
cluster.bbox.minX = Math.min(cluster.bbox.minX, box.minX);
|
|
547
|
+
cluster.bbox.minY = Math.min(cluster.bbox.minY, box.minY);
|
|
548
|
+
cluster.bbox.maxX = Math.max(cluster.bbox.maxX, box.maxX);
|
|
549
|
+
cluster.bbox.maxY = Math.max(cluster.bbox.maxY, box.maxY);
|
|
550
|
+
cluster.lastStrokeAt = Math.max(cluster.lastStrokeAt, at);
|
|
551
|
+
cluster.heightSum += box.maxY - box.minY;
|
|
552
|
+
this.byStroke.set(id, cluster.id);
|
|
553
|
+
}
|
|
554
|
+
removeMember(memberId) {
|
|
555
|
+
const cid = this.byStroke.get(memberId);
|
|
556
|
+
if (!cid) return;
|
|
557
|
+
this.byStroke.delete(memberId);
|
|
558
|
+
const cluster = this.clusters.get(cid);
|
|
559
|
+
if (!cluster) return;
|
|
560
|
+
cluster.strokeIds.delete(memberId);
|
|
561
|
+
if (cluster.strokeIds.size === 0) this.clusters.delete(cid);
|
|
562
|
+
else this.recompute(cid);
|
|
563
|
+
}
|
|
564
|
+
recompute(cid) {
|
|
565
|
+
const cluster = this.clusters.get(cid);
|
|
566
|
+
if (!cluster) return;
|
|
567
|
+
const box = {
|
|
568
|
+
minX: Infinity,
|
|
569
|
+
minY: Infinity,
|
|
570
|
+
maxX: -Infinity,
|
|
571
|
+
maxY: -Infinity
|
|
572
|
+
};
|
|
573
|
+
let heightSum = 0;
|
|
574
|
+
for (const id of cluster.strokeIds) {
|
|
575
|
+
const b = this.memberBBox(id);
|
|
576
|
+
if (!b) continue;
|
|
577
|
+
box.minX = Math.min(box.minX, b.minX);
|
|
578
|
+
box.minY = Math.min(box.minY, b.minY);
|
|
579
|
+
box.maxX = Math.max(box.maxX, b.maxX);
|
|
580
|
+
box.maxY = Math.max(box.maxY, b.maxY);
|
|
581
|
+
heightSum += b.maxY - b.minY;
|
|
582
|
+
}
|
|
583
|
+
cluster.bbox = box;
|
|
584
|
+
cluster.heightSum = heightSum;
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
/** Estimated world bounds of a text block: top-left anchor, lines flow -y. */
|
|
588
|
+
function textBBox(block) {
|
|
589
|
+
const { width, height } = measureTextBlock(block.text, block.fontSize);
|
|
590
|
+
return {
|
|
591
|
+
minX: block.x,
|
|
592
|
+
maxX: block.x + width,
|
|
593
|
+
minY: block.y - height,
|
|
594
|
+
maxY: block.y
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
function bboxOfPoints(points) {
|
|
598
|
+
const box = {
|
|
599
|
+
minX: Infinity,
|
|
600
|
+
minY: Infinity,
|
|
601
|
+
maxX: -Infinity,
|
|
602
|
+
maxY: -Infinity
|
|
603
|
+
};
|
|
604
|
+
for (const p of points) {
|
|
605
|
+
if (p.x < box.minX) box.minX = p.x;
|
|
606
|
+
if (p.y < box.minY) box.minY = p.y;
|
|
607
|
+
if (p.x > box.maxX) box.maxX = p.x;
|
|
608
|
+
if (p.y > box.maxY) box.maxY = p.y;
|
|
609
|
+
}
|
|
610
|
+
return box;
|
|
611
|
+
}
|
|
612
|
+
/** Shortest distance between two bboxes; 0 when they touch or overlap. */
|
|
613
|
+
function bboxGap(a, b) {
|
|
614
|
+
const dx = Math.max(0, b.minX - a.maxX, a.minX - b.maxX);
|
|
615
|
+
const dy = Math.max(0, b.minY - a.maxY, a.minY - b.maxY);
|
|
616
|
+
return Math.hypot(dx, dy);
|
|
617
|
+
}
|
|
618
|
+
function clamp$1(v, lo, hi) {
|
|
619
|
+
return Math.min(hi, Math.max(lo, v));
|
|
620
|
+
}
|
|
621
|
+
//#endregion
|
|
622
|
+
//#region src/core-internal/extensionTypes.ts
|
|
623
|
+
function cloneCustomObject(object) {
|
|
624
|
+
return {
|
|
625
|
+
...object,
|
|
626
|
+
transform: [...object.transform],
|
|
627
|
+
fallback: { ...object.fallback },
|
|
628
|
+
...object.lock ? { lock: { ...object.lock } } : {}
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
//#endregion
|
|
632
|
+
//#region src/core-internal/kitchenTimer.ts
|
|
633
|
+
var TIMER_DEFAULT_SIZE = 12;
|
|
634
|
+
var TIMER_DEFAULT_DURATION_MS = 300 * 1e3;
|
|
635
|
+
var TIMER_PRESETS_MS = [
|
|
636
|
+
6e4,
|
|
637
|
+
5 * 6e4,
|
|
638
|
+
10 * 6e4,
|
|
639
|
+
15 * 6e4
|
|
640
|
+
];
|
|
641
|
+
function cloneTimer(timer) {
|
|
642
|
+
return { ...timer };
|
|
643
|
+
}
|
|
644
|
+
function timerRemaining(timer, now) {
|
|
645
|
+
if (!timer.runningSince) return Math.max(0, timer.remainingMs);
|
|
646
|
+
return Math.max(0, timer.remainingMs - (now - timer.runningSince));
|
|
647
|
+
}
|
|
648
|
+
function timerExpired(timer, now) {
|
|
649
|
+
return timerRemaining(timer, now) <= 0;
|
|
650
|
+
}
|
|
651
|
+
function startTimer(timer, now) {
|
|
652
|
+
const remaining = timerRemaining(timer, now);
|
|
653
|
+
if (remaining <= 0) return {
|
|
654
|
+
...timer,
|
|
655
|
+
remainingMs: timer.durationMs,
|
|
656
|
+
runningSince: now
|
|
657
|
+
};
|
|
658
|
+
if (timer.runningSince) return timer;
|
|
659
|
+
return {
|
|
660
|
+
...timer,
|
|
661
|
+
remainingMs: remaining,
|
|
662
|
+
runningSince: now
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
function pauseTimer(timer, now) {
|
|
666
|
+
if (!timer.runningSince) return timer;
|
|
667
|
+
return {
|
|
668
|
+
...timer,
|
|
669
|
+
remainingMs: timerRemaining(timer, now),
|
|
670
|
+
runningSince: void 0
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
function toggleTimer(timer, now) {
|
|
674
|
+
return timer.runningSince ? pauseTimer(timer, now) : startTimer(timer, now);
|
|
675
|
+
}
|
|
676
|
+
function setTimerDuration(timer, durationMs) {
|
|
677
|
+
return {
|
|
678
|
+
...timer,
|
|
679
|
+
durationMs,
|
|
680
|
+
remainingMs: durationMs,
|
|
681
|
+
runningSince: void 0
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
function formatTimer(ms) {
|
|
685
|
+
const total = Math.max(0, Math.ceil(ms / 1e3));
|
|
686
|
+
return `${Math.floor(total / 60)}:${(total % 60).toString().padStart(2, "0")}`;
|
|
687
|
+
}
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region src/core-internal/itemLock.ts
|
|
690
|
+
/**
|
|
691
|
+
* Apply or clear a lock in place. Unlock always drops the holder so a stale
|
|
692
|
+
* name cannot linger on an unlocked item.
|
|
693
|
+
*/
|
|
694
|
+
function applyItemLock(item, locked, by) {
|
|
695
|
+
if (locked) {
|
|
696
|
+
item.locked = true;
|
|
697
|
+
if (by) {
|
|
698
|
+
item.lockedBy = by.userId;
|
|
699
|
+
item.lockedByName = by.name;
|
|
700
|
+
}
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
delete item.locked;
|
|
704
|
+
delete item.lockedBy;
|
|
705
|
+
delete item.lockedByName;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Only the person who locked it can unlock it. Items locked before ownership
|
|
709
|
+
* existed (no `lockedBy`) stay unlockable by anyone, so old boards don't brick.
|
|
710
|
+
*/
|
|
711
|
+
function canUnlockItem(item, userId) {
|
|
712
|
+
if (!item?.locked) return true;
|
|
713
|
+
if (!item.lockedBy) return true;
|
|
714
|
+
return !!userId && item.lockedBy === userId;
|
|
715
|
+
}
|
|
716
|
+
/** Wire fields for a locked item; omitted entirely when unlocked. */
|
|
717
|
+
function serializeLock(item) {
|
|
718
|
+
if (!item.locked) return {};
|
|
719
|
+
return {
|
|
720
|
+
locked: true,
|
|
721
|
+
...item.lockedBy ? { lockedBy: item.lockedBy } : {},
|
|
722
|
+
...item.lockedByName ? { lockedByName: item.lockedByName } : {}
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
//#endregion
|
|
726
|
+
//#region src/core-internal/document.ts
|
|
727
|
+
/**
|
|
728
|
+
* The one place a live Stroke becomes its wire representation — in
|
|
729
|
+
* particular, points collapse from {x,y,pressure,erase?} objects into
|
|
730
|
+
* [x,y,pressure,erase] tuples. Anything that persists a stroke (toJSON, and
|
|
731
|
+
* the incremental ops opSync.ts sends) must go through this, or the two
|
|
732
|
+
* paths drift: a stroke saved with points-as-objects looks fine until the
|
|
733
|
+
* next load, where deserializeStrokes' array-destructuring throws "object is
|
|
734
|
+
* not iterable" — exactly what shipping the live object straight into an op
|
|
735
|
+
* used to do.
|
|
736
|
+
*/
|
|
737
|
+
function serializeStroke(s) {
|
|
738
|
+
return {
|
|
739
|
+
id: s.id,
|
|
740
|
+
color: s.color,
|
|
741
|
+
baseWidth: s.baseWidth,
|
|
742
|
+
...s.tool && s.tool !== "marker" ? { tool: s.tool } : {},
|
|
743
|
+
points: s.points.map((p) => [
|
|
744
|
+
p.x,
|
|
745
|
+
p.y,
|
|
746
|
+
p.pressure,
|
|
747
|
+
p.erase ?? 0
|
|
748
|
+
]),
|
|
749
|
+
...s.matrix && !isIdentity(s.matrix) ? { matrix: [...s.matrix] } : {},
|
|
750
|
+
...s.clusterId ? { clusterId: s.clusterId } : {},
|
|
751
|
+
...serializeLock(s)
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
var EMPTY_CHANGE = {
|
|
755
|
+
added: [],
|
|
756
|
+
removed: [],
|
|
757
|
+
updated: [],
|
|
758
|
+
transformed: [],
|
|
759
|
+
notesAdded: [],
|
|
760
|
+
notesRemoved: [],
|
|
761
|
+
notesUpdated: [],
|
|
762
|
+
textAdded: [],
|
|
763
|
+
textRemoved: [],
|
|
764
|
+
textUpdated: [],
|
|
765
|
+
tablesAdded: [],
|
|
766
|
+
tablesRemoved: [],
|
|
767
|
+
tablesUpdated: [],
|
|
768
|
+
imagesAdded: [],
|
|
769
|
+
imagesRemoved: [],
|
|
770
|
+
imagesUpdated: [],
|
|
771
|
+
timersAdded: [],
|
|
772
|
+
timersRemoved: [],
|
|
773
|
+
timersUpdated: [],
|
|
774
|
+
customObjectsAdded: [],
|
|
775
|
+
customObjectsRemoved: [],
|
|
776
|
+
customObjectsUpdated: []
|
|
777
|
+
};
|
|
778
|
+
var BoardDocument = class {
|
|
779
|
+
constructor(id) {
|
|
780
|
+
this.id = id;
|
|
781
|
+
this.version = 0;
|
|
782
|
+
this.strokes = /* @__PURE__ */ new Map();
|
|
783
|
+
this.notes = /* @__PURE__ */ new Map();
|
|
784
|
+
this.texts = /* @__PURE__ */ new Map();
|
|
785
|
+
this.tables = /* @__PURE__ */ new Map();
|
|
786
|
+
this.images = /* @__PURE__ */ new Map();
|
|
787
|
+
this.timers = /* @__PURE__ */ new Map();
|
|
788
|
+
this.customObjects = /* @__PURE__ */ new Map();
|
|
789
|
+
this.bboxes = /* @__PURE__ */ new Map();
|
|
790
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
791
|
+
}
|
|
792
|
+
get(id) {
|
|
793
|
+
return this.strokes.get(id);
|
|
794
|
+
}
|
|
795
|
+
all() {
|
|
796
|
+
return this.strokes.values();
|
|
797
|
+
}
|
|
798
|
+
bbox(id) {
|
|
799
|
+
return this.bboxes.get(id);
|
|
800
|
+
}
|
|
801
|
+
subscribe(listener) {
|
|
802
|
+
this.listeners.add(listener);
|
|
803
|
+
return () => this.listeners.delete(listener);
|
|
804
|
+
}
|
|
805
|
+
addStrokes(strokes) {
|
|
806
|
+
for (const stroke of strokes) {
|
|
807
|
+
this.strokes.set(stroke.id, stroke);
|
|
808
|
+
this.bboxes.set(stroke.id, computeBBox(stroke));
|
|
809
|
+
}
|
|
810
|
+
this.emit({ added: strokes });
|
|
811
|
+
}
|
|
812
|
+
removeStrokes(ids) {
|
|
813
|
+
const removed = [];
|
|
814
|
+
for (const id of ids) if (this.strokes.delete(id)) {
|
|
815
|
+
this.bboxes.delete(id);
|
|
816
|
+
removed.push(id);
|
|
817
|
+
}
|
|
818
|
+
if (removed.length) this.emit({ removed });
|
|
819
|
+
}
|
|
820
|
+
/** Announce in-place point mutations (erasure decay; bbox is unchanged). */
|
|
821
|
+
touchStrokes(strokes) {
|
|
822
|
+
this.emit({ updated: strokes });
|
|
823
|
+
}
|
|
824
|
+
/** Announce matrix changes; recomputes world bboxes. */
|
|
825
|
+
transformStrokes(strokes) {
|
|
826
|
+
for (const stroke of strokes) this.bboxes.set(stroke.id, computeBBox(stroke));
|
|
827
|
+
this.emit({ transformed: strokes });
|
|
828
|
+
}
|
|
829
|
+
getNote(id) {
|
|
830
|
+
return this.notes.get(id);
|
|
831
|
+
}
|
|
832
|
+
allNotes() {
|
|
833
|
+
return this.notes.values();
|
|
834
|
+
}
|
|
835
|
+
addNotes(notes) {
|
|
836
|
+
for (const note of notes) this.notes.set(note.id, note);
|
|
837
|
+
this.emit({ notesAdded: notes });
|
|
838
|
+
}
|
|
839
|
+
removeNotes(ids) {
|
|
840
|
+
const notesRemoved = [];
|
|
841
|
+
for (const id of ids) if (this.notes.delete(id)) notesRemoved.push(id);
|
|
842
|
+
if (notesRemoved.length) this.emit({ notesRemoved });
|
|
843
|
+
}
|
|
844
|
+
/** Replace a note's contents (move, peel, retext) under the same id. */
|
|
845
|
+
setNote(note) {
|
|
846
|
+
this.notes.set(note.id, note);
|
|
847
|
+
this.emit({ notesUpdated: [note] });
|
|
848
|
+
}
|
|
849
|
+
getText(id) {
|
|
850
|
+
return this.texts.get(id);
|
|
851
|
+
}
|
|
852
|
+
allTexts() {
|
|
853
|
+
return this.texts.values();
|
|
854
|
+
}
|
|
855
|
+
addTexts(blocks) {
|
|
856
|
+
for (const block of blocks) this.texts.set(block.id, block);
|
|
857
|
+
this.emit({ textAdded: blocks });
|
|
858
|
+
}
|
|
859
|
+
removeTexts(ids) {
|
|
860
|
+
const textRemoved = [];
|
|
861
|
+
for (const id of ids) if (this.texts.delete(id)) textRemoved.push(id);
|
|
862
|
+
if (textRemoved.length) this.emit({ textRemoved });
|
|
863
|
+
}
|
|
864
|
+
/** Replace a text block's contents (move, retext) under the same id. */
|
|
865
|
+
setText(block) {
|
|
866
|
+
this.texts.set(block.id, block);
|
|
867
|
+
this.emit({ textUpdated: [block] });
|
|
868
|
+
}
|
|
869
|
+
getTable(id) {
|
|
870
|
+
return this.tables.get(id);
|
|
871
|
+
}
|
|
872
|
+
allTables() {
|
|
873
|
+
return this.tables.values();
|
|
874
|
+
}
|
|
875
|
+
addTables(tables) {
|
|
876
|
+
for (const table of tables) this.tables.set(table.id, table);
|
|
877
|
+
this.emit({ tablesAdded: tables });
|
|
878
|
+
}
|
|
879
|
+
removeTables(ids) {
|
|
880
|
+
const tablesRemoved = [];
|
|
881
|
+
for (const id of ids) if (this.tables.delete(id)) tablesRemoved.push(id);
|
|
882
|
+
if (tablesRemoved.length) this.emit({ tablesRemoved });
|
|
883
|
+
}
|
|
884
|
+
/** Replace a table's contents (move, resize, change cells) under the same id. */
|
|
885
|
+
setTable(table) {
|
|
886
|
+
this.tables.set(table.id, table);
|
|
887
|
+
this.emit({ tablesUpdated: [table] });
|
|
888
|
+
}
|
|
889
|
+
getImage(id) {
|
|
890
|
+
return this.images.get(id);
|
|
891
|
+
}
|
|
892
|
+
allImages() {
|
|
893
|
+
return this.images.values();
|
|
894
|
+
}
|
|
895
|
+
addImages(images) {
|
|
896
|
+
for (const img of images) this.images.set(img.id, img);
|
|
897
|
+
this.emit({ imagesAdded: images });
|
|
898
|
+
}
|
|
899
|
+
removeImages(ids) {
|
|
900
|
+
const imagesRemoved = [];
|
|
901
|
+
for (const id of ids) if (this.images.delete(id)) imagesRemoved.push(id);
|
|
902
|
+
if (imagesRemoved.length) this.emit({ imagesRemoved });
|
|
903
|
+
}
|
|
904
|
+
/** Replace an image block's contents (move, resize) under the same id. */
|
|
905
|
+
setImage(image) {
|
|
906
|
+
this.images.set(image.id, image);
|
|
907
|
+
this.emit({ imagesUpdated: [image] });
|
|
908
|
+
}
|
|
909
|
+
getTimer(id) {
|
|
910
|
+
return this.timers.get(id);
|
|
911
|
+
}
|
|
912
|
+
allTimers() {
|
|
913
|
+
return this.timers.values();
|
|
914
|
+
}
|
|
915
|
+
addTimers(timers) {
|
|
916
|
+
for (const timer of timers) this.timers.set(timer.id, timer);
|
|
917
|
+
this.emit({ timersAdded: timers });
|
|
918
|
+
}
|
|
919
|
+
removeTimers(ids) {
|
|
920
|
+
const timersRemoved = [];
|
|
921
|
+
for (const id of ids) if (this.timers.delete(id)) timersRemoved.push(id);
|
|
922
|
+
if (timersRemoved.length) this.emit({ timersRemoved });
|
|
923
|
+
}
|
|
924
|
+
setTimer(timer) {
|
|
925
|
+
this.timers.set(timer.id, timer);
|
|
926
|
+
this.emit({ timersUpdated: [timer] });
|
|
927
|
+
}
|
|
928
|
+
getCustomObject(id) {
|
|
929
|
+
return this.customObjects.get(id);
|
|
930
|
+
}
|
|
931
|
+
allCustomObjects() {
|
|
932
|
+
return this.customObjects.values();
|
|
933
|
+
}
|
|
934
|
+
addCustomObjects(objects) {
|
|
935
|
+
for (const object of objects) this.customObjects.set(object.id, object);
|
|
936
|
+
this.emit({ customObjectsAdded: objects });
|
|
937
|
+
}
|
|
938
|
+
removeCustomObjects(ids) {
|
|
939
|
+
const customObjectsRemoved = [];
|
|
940
|
+
for (const id of ids) if (this.customObjects.delete(id)) customObjectsRemoved.push(id);
|
|
941
|
+
if (customObjectsRemoved.length) this.emit({ customObjectsRemoved });
|
|
942
|
+
}
|
|
943
|
+
/** Replace a custom object's contents under the same id. */
|
|
944
|
+
setCustomObject(object) {
|
|
945
|
+
this.customObjects.set(object.id, object);
|
|
946
|
+
this.emit({ customObjectsUpdated: [object] });
|
|
947
|
+
}
|
|
948
|
+
setStrokeLocked(id, locked, by) {
|
|
949
|
+
const stroke = this.strokes.get(id);
|
|
950
|
+
if (!stroke) return;
|
|
951
|
+
applyItemLock(stroke, locked, by);
|
|
952
|
+
this.emit({ updated: [stroke] });
|
|
953
|
+
}
|
|
954
|
+
setStrokesLocked(ids, locked, by) {
|
|
955
|
+
const updated = [];
|
|
956
|
+
for (const id of ids) {
|
|
957
|
+
const stroke = this.strokes.get(id);
|
|
958
|
+
if (stroke) {
|
|
959
|
+
applyItemLock(stroke, locked, by);
|
|
960
|
+
updated.push(stroke);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
if (updated.length) this.emit({ updated });
|
|
964
|
+
}
|
|
965
|
+
setNoteLocked(id, locked, by) {
|
|
966
|
+
const note = this.notes.get(id);
|
|
967
|
+
if (!note) return;
|
|
968
|
+
applyItemLock(note, locked, by);
|
|
969
|
+
this.emit({ notesUpdated: [note] });
|
|
970
|
+
}
|
|
971
|
+
setTextLocked(id, locked, by) {
|
|
972
|
+
const text = this.texts.get(id);
|
|
973
|
+
if (!text) return;
|
|
974
|
+
applyItemLock(text, locked, by);
|
|
975
|
+
this.emit({ textUpdated: [text] });
|
|
976
|
+
}
|
|
977
|
+
setTableLocked(id, locked, by) {
|
|
978
|
+
const table = this.tables.get(id);
|
|
979
|
+
if (!table) return;
|
|
980
|
+
applyItemLock(table, locked, by);
|
|
981
|
+
this.emit({ tablesUpdated: [table] });
|
|
982
|
+
}
|
|
983
|
+
setImageLocked(id, locked, by) {
|
|
984
|
+
const img = this.images.get(id);
|
|
985
|
+
if (!img) return;
|
|
986
|
+
applyItemLock(img, locked, by);
|
|
987
|
+
this.emit({ imagesUpdated: [img] });
|
|
988
|
+
}
|
|
989
|
+
setTimerLocked(id, locked, by) {
|
|
990
|
+
const timer = this.timers.get(id);
|
|
991
|
+
if (!timer) return;
|
|
992
|
+
applyItemLock(timer, locked, by);
|
|
993
|
+
this.emit({ timersUpdated: [timer] });
|
|
994
|
+
}
|
|
995
|
+
/** Replace all content (initial load). Does not touch `version`. */
|
|
996
|
+
replaceAll(strokes, notes, texts, tables = [], images = [], timers = [], customObjects = []) {
|
|
997
|
+
const removed = [...this.strokes.keys()];
|
|
998
|
+
const notesRemoved = [...this.notes.keys()];
|
|
999
|
+
const textRemoved = [...this.texts.keys()];
|
|
1000
|
+
const tablesRemoved = [...this.tables.keys()];
|
|
1001
|
+
const imagesRemoved = [...this.images.keys()];
|
|
1002
|
+
const timersRemoved = [...this.timers.keys()];
|
|
1003
|
+
const customObjectsRemoved = [...this.customObjects.keys()];
|
|
1004
|
+
this.strokes.clear();
|
|
1005
|
+
this.notes.clear();
|
|
1006
|
+
this.texts.clear();
|
|
1007
|
+
this.tables.clear();
|
|
1008
|
+
this.images.clear();
|
|
1009
|
+
this.timers.clear();
|
|
1010
|
+
this.customObjects.clear();
|
|
1011
|
+
this.bboxes.clear();
|
|
1012
|
+
for (const stroke of strokes) {
|
|
1013
|
+
this.strokes.set(stroke.id, stroke);
|
|
1014
|
+
this.bboxes.set(stroke.id, computeBBox(stroke));
|
|
1015
|
+
}
|
|
1016
|
+
for (const note of notes) this.notes.set(note.id, note);
|
|
1017
|
+
for (const text of texts) this.texts.set(text.id, text);
|
|
1018
|
+
for (const table of tables) this.tables.set(table.id, table);
|
|
1019
|
+
for (const img of images) this.images.set(img.id, img);
|
|
1020
|
+
for (const timer of timers) this.timers.set(timer.id, timer);
|
|
1021
|
+
for (const object of customObjects) this.customObjects.set(object.id, object);
|
|
1022
|
+
this.emit({
|
|
1023
|
+
added: strokes,
|
|
1024
|
+
removed,
|
|
1025
|
+
notesAdded: notes,
|
|
1026
|
+
notesRemoved,
|
|
1027
|
+
textAdded: texts,
|
|
1028
|
+
textRemoved,
|
|
1029
|
+
tablesAdded: tables,
|
|
1030
|
+
tablesRemoved,
|
|
1031
|
+
imagesAdded: images,
|
|
1032
|
+
imagesRemoved,
|
|
1033
|
+
timersAdded: timers,
|
|
1034
|
+
timersRemoved,
|
|
1035
|
+
customObjectsAdded: customObjects,
|
|
1036
|
+
customObjectsRemoved
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
/** Apply incremental real-time change received from a remote collaborator over WebSocket. */
|
|
1040
|
+
applyRemoteChange(change) {
|
|
1041
|
+
const events = {};
|
|
1042
|
+
if (change.added && change.added.length > 0) {
|
|
1043
|
+
for (const stroke of change.added) {
|
|
1044
|
+
this.strokes.set(stroke.id, stroke);
|
|
1045
|
+
this.bboxes.set(stroke.id, computeBBox(stroke));
|
|
1046
|
+
}
|
|
1047
|
+
events.added = change.added;
|
|
1048
|
+
}
|
|
1049
|
+
if (change.removed && change.removed.length > 0) {
|
|
1050
|
+
const removed = [];
|
|
1051
|
+
for (const id of change.removed) if (this.strokes.delete(id)) {
|
|
1052
|
+
this.bboxes.delete(id);
|
|
1053
|
+
removed.push(id);
|
|
1054
|
+
}
|
|
1055
|
+
if (removed.length > 0) events.removed = removed;
|
|
1056
|
+
}
|
|
1057
|
+
if (change.updated && change.updated.length > 0) {
|
|
1058
|
+
for (const stroke of change.updated) {
|
|
1059
|
+
this.strokes.set(stroke.id, stroke);
|
|
1060
|
+
this.bboxes.set(stroke.id, computeBBox(stroke));
|
|
1061
|
+
}
|
|
1062
|
+
events.updated = change.updated;
|
|
1063
|
+
}
|
|
1064
|
+
if (change.transformed && change.transformed.length > 0) {
|
|
1065
|
+
for (const stroke of change.transformed) {
|
|
1066
|
+
this.strokes.set(stroke.id, stroke);
|
|
1067
|
+
this.bboxes.set(stroke.id, computeBBox(stroke));
|
|
1068
|
+
}
|
|
1069
|
+
events.transformed = change.transformed;
|
|
1070
|
+
}
|
|
1071
|
+
if (change.notesAdded && change.notesAdded.length > 0) {
|
|
1072
|
+
for (const note of change.notesAdded) this.notes.set(note.id, note);
|
|
1073
|
+
events.notesAdded = change.notesAdded;
|
|
1074
|
+
}
|
|
1075
|
+
if (change.notesRemoved && change.notesRemoved.length > 0) {
|
|
1076
|
+
const notesRemoved = [];
|
|
1077
|
+
for (const id of change.notesRemoved) if (this.notes.delete(id)) notesRemoved.push(id);
|
|
1078
|
+
if (notesRemoved.length > 0) events.notesRemoved = notesRemoved;
|
|
1079
|
+
}
|
|
1080
|
+
if (change.notesUpdated && change.notesUpdated.length > 0) {
|
|
1081
|
+
for (const note of change.notesUpdated) this.notes.set(note.id, note);
|
|
1082
|
+
events.notesUpdated = change.notesUpdated;
|
|
1083
|
+
}
|
|
1084
|
+
if (change.textAdded && change.textAdded.length > 0) {
|
|
1085
|
+
for (const t of change.textAdded) this.texts.set(t.id, t);
|
|
1086
|
+
events.textAdded = change.textAdded;
|
|
1087
|
+
}
|
|
1088
|
+
if (change.textRemoved && change.textRemoved.length > 0) {
|
|
1089
|
+
const textRemoved = [];
|
|
1090
|
+
for (const id of change.textRemoved) if (this.texts.delete(id)) textRemoved.push(id);
|
|
1091
|
+
if (textRemoved.length > 0) events.textRemoved = textRemoved;
|
|
1092
|
+
}
|
|
1093
|
+
if (change.textUpdated && change.textUpdated.length > 0) {
|
|
1094
|
+
for (const t of change.textUpdated) this.texts.set(t.id, t);
|
|
1095
|
+
events.textUpdated = change.textUpdated;
|
|
1096
|
+
}
|
|
1097
|
+
if (change.tablesAdded && change.tablesAdded.length > 0) {
|
|
1098
|
+
for (const tbl of change.tablesAdded) this.tables.set(tbl.id, tbl);
|
|
1099
|
+
events.tablesAdded = change.tablesAdded;
|
|
1100
|
+
}
|
|
1101
|
+
if (change.tablesRemoved && change.tablesRemoved.length > 0) {
|
|
1102
|
+
const tablesRemoved = [];
|
|
1103
|
+
for (const id of change.tablesRemoved) if (this.tables.delete(id)) tablesRemoved.push(id);
|
|
1104
|
+
if (tablesRemoved.length > 0) events.tablesRemoved = tablesRemoved;
|
|
1105
|
+
}
|
|
1106
|
+
if (change.tablesUpdated && change.tablesUpdated.length > 0) {
|
|
1107
|
+
for (const tbl of change.tablesUpdated) this.tables.set(tbl.id, tbl);
|
|
1108
|
+
events.tablesUpdated = change.tablesUpdated;
|
|
1109
|
+
}
|
|
1110
|
+
if (change.imagesAdded && change.imagesAdded.length > 0) {
|
|
1111
|
+
for (const img of change.imagesAdded) this.images.set(img.id, img);
|
|
1112
|
+
events.imagesAdded = change.imagesAdded;
|
|
1113
|
+
}
|
|
1114
|
+
if (change.imagesRemoved && change.imagesRemoved.length > 0) {
|
|
1115
|
+
const imagesRemoved = [];
|
|
1116
|
+
for (const id of change.imagesRemoved) if (this.images.delete(id)) imagesRemoved.push(id);
|
|
1117
|
+
if (imagesRemoved.length > 0) events.imagesRemoved = imagesRemoved;
|
|
1118
|
+
}
|
|
1119
|
+
if (change.imagesUpdated && change.imagesUpdated.length > 0) {
|
|
1120
|
+
for (const img of change.imagesUpdated) this.images.set(img.id, img);
|
|
1121
|
+
events.imagesUpdated = change.imagesUpdated;
|
|
1122
|
+
}
|
|
1123
|
+
if (change.timersAdded && change.timersAdded.length > 0) {
|
|
1124
|
+
for (const timer of change.timersAdded) this.timers.set(timer.id, timer);
|
|
1125
|
+
events.timersAdded = change.timersAdded;
|
|
1126
|
+
}
|
|
1127
|
+
if (change.timersRemoved && change.timersRemoved.length > 0) {
|
|
1128
|
+
const timersRemoved = [];
|
|
1129
|
+
for (const id of change.timersRemoved) if (this.timers.delete(id)) timersRemoved.push(id);
|
|
1130
|
+
if (timersRemoved.length > 0) events.timersRemoved = timersRemoved;
|
|
1131
|
+
}
|
|
1132
|
+
if (change.timersUpdated && change.timersUpdated.length > 0) {
|
|
1133
|
+
for (const timer of change.timersUpdated) this.timers.set(timer.id, timer);
|
|
1134
|
+
events.timersUpdated = change.timersUpdated;
|
|
1135
|
+
}
|
|
1136
|
+
if (change.customObjectsAdded && change.customObjectsAdded.length > 0) {
|
|
1137
|
+
for (const object of change.customObjectsAdded) this.customObjects.set(object.id, object);
|
|
1138
|
+
events.customObjectsAdded = change.customObjectsAdded;
|
|
1139
|
+
}
|
|
1140
|
+
if (change.customObjectsRemoved && change.customObjectsRemoved.length > 0) {
|
|
1141
|
+
const customObjectsRemoved = [];
|
|
1142
|
+
for (const id of change.customObjectsRemoved) if (this.customObjects.delete(id)) customObjectsRemoved.push(id);
|
|
1143
|
+
if (customObjectsRemoved.length > 0) events.customObjectsRemoved = customObjectsRemoved;
|
|
1144
|
+
}
|
|
1145
|
+
if (change.customObjectsUpdated && change.customObjectsUpdated.length > 0) {
|
|
1146
|
+
for (const object of change.customObjectsUpdated) this.customObjects.set(object.id, object);
|
|
1147
|
+
events.customObjectsUpdated = change.customObjectsUpdated;
|
|
1148
|
+
}
|
|
1149
|
+
this.emit(events);
|
|
1150
|
+
}
|
|
1151
|
+
toJSON() {
|
|
1152
|
+
return {
|
|
1153
|
+
schemaVersion: 1,
|
|
1154
|
+
strokes: [...this.strokes.values()].map(serializeStroke),
|
|
1155
|
+
notes: [...this.notes.values()].map(cloneNote),
|
|
1156
|
+
textBlocks: [...this.texts.values()].map(cloneText),
|
|
1157
|
+
tables: [...this.tables.values()].map(cloneTable),
|
|
1158
|
+
images: [...this.images.values()].map(cloneImage),
|
|
1159
|
+
timers: [...this.timers.values()].map(cloneTimer),
|
|
1160
|
+
customObjects: [...this.customObjects.values()].map(cloneCustomObject)
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
static deserializeCustomObjects(data) {
|
|
1164
|
+
return (data.customObjects ?? []).map(cloneCustomObject);
|
|
1165
|
+
}
|
|
1166
|
+
static deserializeImages(data) {
|
|
1167
|
+
return (data.images ?? []).map(cloneImage);
|
|
1168
|
+
}
|
|
1169
|
+
static deserializeTimers(data) {
|
|
1170
|
+
return (data.timers ?? []).map(cloneTimer);
|
|
1171
|
+
}
|
|
1172
|
+
static deserializeNotes(data) {
|
|
1173
|
+
return (data.notes ?? []).map(cloneNote);
|
|
1174
|
+
}
|
|
1175
|
+
static deserializeTexts(data) {
|
|
1176
|
+
return (data.textBlocks ?? []).map(cloneText);
|
|
1177
|
+
}
|
|
1178
|
+
static deserializeTables(data) {
|
|
1179
|
+
return (data.tables ?? []).map(cloneTable);
|
|
1180
|
+
}
|
|
1181
|
+
static deserializeStrokes(data) {
|
|
1182
|
+
const strokes = [];
|
|
1183
|
+
for (const s of data.strokes) try {
|
|
1184
|
+
strokes.push({
|
|
1185
|
+
id: s.id,
|
|
1186
|
+
color: s.color,
|
|
1187
|
+
baseWidth: s.baseWidth,
|
|
1188
|
+
...s.tool ? { tool: s.tool } : {},
|
|
1189
|
+
points: s.points.map(parseStrokePoint),
|
|
1190
|
+
...s.matrix ? { matrix: [...s.matrix] } : {},
|
|
1191
|
+
...s.clusterId ? { clusterId: s.clusterId } : {},
|
|
1192
|
+
...serializeLock(s)
|
|
1193
|
+
});
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
console.error(`Skipping malformed stroke ${s?.id ?? "(no id)"}:`, err);
|
|
1196
|
+
}
|
|
1197
|
+
return strokes;
|
|
1198
|
+
}
|
|
1199
|
+
emit(change) {
|
|
1200
|
+
const full = {
|
|
1201
|
+
...EMPTY_CHANGE,
|
|
1202
|
+
...change
|
|
1203
|
+
};
|
|
1204
|
+
for (const listener of this.listeners) listener(full);
|
|
1205
|
+
}
|
|
1206
|
+
};
|
|
1207
|
+
/** World-space bbox: local point bounds pushed through the stroke matrix. */
|
|
1208
|
+
/**
|
|
1209
|
+
* A point should be the wire tuple [x,y,pressure,erase]. Also accepts the
|
|
1210
|
+
* live {x,y,pressure,erase?} object shape, which is what a stroke saved
|
|
1211
|
+
* through the buggy pre-fix ops path was persisted as — so boards written
|
|
1212
|
+
* during that window keep loading instead of throwing "object is not
|
|
1213
|
+
* iterable" forever. Write paths always produce tuples; this is read-side
|
|
1214
|
+
* tolerance for data already on disk, not a second accepted format.
|
|
1215
|
+
*/
|
|
1216
|
+
function parseStrokePoint(raw) {
|
|
1217
|
+
if (Array.isArray(raw)) {
|
|
1218
|
+
const [x, y, pressure, erase] = raw;
|
|
1219
|
+
return {
|
|
1220
|
+
x,
|
|
1221
|
+
y,
|
|
1222
|
+
pressure,
|
|
1223
|
+
...erase > 0 ? { erase } : {}
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
if (raw && typeof raw === "object") {
|
|
1227
|
+
const p = raw;
|
|
1228
|
+
return {
|
|
1229
|
+
x: p.x,
|
|
1230
|
+
y: p.y,
|
|
1231
|
+
pressure: p.pressure,
|
|
1232
|
+
...p.erase ? { erase: p.erase } : {}
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
throw new Error("point is neither a tuple nor an object");
|
|
1236
|
+
}
|
|
1237
|
+
function computeBBox(stroke) {
|
|
1238
|
+
const box = {
|
|
1239
|
+
minX: Infinity,
|
|
1240
|
+
minY: Infinity,
|
|
1241
|
+
maxX: -Infinity,
|
|
1242
|
+
maxY: -Infinity
|
|
1243
|
+
};
|
|
1244
|
+
for (const p of stroke.points) {
|
|
1245
|
+
if (p.x < box.minX) box.minX = p.x;
|
|
1246
|
+
if (p.y < box.minY) box.minY = p.y;
|
|
1247
|
+
if (p.x > box.maxX) box.maxX = p.x;
|
|
1248
|
+
if (p.y > box.maxY) box.maxY = p.y;
|
|
1249
|
+
}
|
|
1250
|
+
const m = stroke.matrix;
|
|
1251
|
+
if (!m || isIdentity(m)) return box;
|
|
1252
|
+
const world = {
|
|
1253
|
+
minX: Infinity,
|
|
1254
|
+
minY: Infinity,
|
|
1255
|
+
maxX: -Infinity,
|
|
1256
|
+
maxY: -Infinity
|
|
1257
|
+
};
|
|
1258
|
+
for (const corner of [
|
|
1259
|
+
{
|
|
1260
|
+
x: box.minX,
|
|
1261
|
+
y: box.minY
|
|
1262
|
+
},
|
|
1263
|
+
{
|
|
1264
|
+
x: box.maxX,
|
|
1265
|
+
y: box.minY
|
|
1266
|
+
},
|
|
1267
|
+
{
|
|
1268
|
+
x: box.minX,
|
|
1269
|
+
y: box.maxY
|
|
1270
|
+
},
|
|
1271
|
+
{
|
|
1272
|
+
x: box.maxX,
|
|
1273
|
+
y: box.maxY
|
|
1274
|
+
}
|
|
1275
|
+
]) {
|
|
1276
|
+
const p = apply(m, corner);
|
|
1277
|
+
if (p.x < world.minX) world.minX = p.x;
|
|
1278
|
+
if (p.y < world.minY) world.minY = p.y;
|
|
1279
|
+
if (p.x > world.maxX) world.maxX = p.x;
|
|
1280
|
+
if (p.y > world.maxY) world.maxY = p.y;
|
|
1281
|
+
}
|
|
1282
|
+
return world;
|
|
1283
|
+
}
|
|
1284
|
+
//#endregion
|
|
1285
|
+
//#region src/core-internal/commands.ts
|
|
1286
|
+
/** One drawing action — a marker stroke, or a shape's strokes as one unit. */
|
|
1287
|
+
var AddStrokesCommand = class {
|
|
1288
|
+
constructor(strokes) {
|
|
1289
|
+
this.label = "draw";
|
|
1290
|
+
this.strokes = strokes.map(cloneStroke);
|
|
1291
|
+
}
|
|
1292
|
+
apply(doc) {
|
|
1293
|
+
doc.addStrokes(this.strokes.map(cloneStroke));
|
|
1294
|
+
}
|
|
1295
|
+
revert(doc) {
|
|
1296
|
+
doc.removeStrokes(this.strokes.map((s) => s.id));
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
/**
|
|
1300
|
+
* One eraser swipe (ADR 0003): `before` are the touched strokes as they were
|
|
1301
|
+
* at swipe start; `after` is what survived — smudged, split, or gone.
|
|
1302
|
+
*/
|
|
1303
|
+
var EraseCommand = class {
|
|
1304
|
+
constructor(before, after) {
|
|
1305
|
+
this.label = "erase";
|
|
1306
|
+
this.before = before.map(cloneStroke);
|
|
1307
|
+
this.after = after.map(cloneStroke);
|
|
1308
|
+
}
|
|
1309
|
+
apply(doc) {
|
|
1310
|
+
doc.removeStrokes(this.before.map((s) => s.id));
|
|
1311
|
+
doc.addStrokes(this.after.map(cloneStroke));
|
|
1312
|
+
}
|
|
1313
|
+
revert(doc) {
|
|
1314
|
+
doc.removeStrokes(this.after.map((s) => s.id));
|
|
1315
|
+
doc.addStrokes(this.before.map(cloneStroke));
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
/**
|
|
1319
|
+
* One transform gesture: `delta` composed onto each member's matrix
|
|
1320
|
+
* (`child = delta × child`, per §6.3 — never baked into geometry).
|
|
1321
|
+
*/
|
|
1322
|
+
var TransformCommand = class {
|
|
1323
|
+
constructor(ids, delta) {
|
|
1324
|
+
this.ids = ids;
|
|
1325
|
+
this.delta = delta;
|
|
1326
|
+
this.label = "transform selection";
|
|
1327
|
+
this.inverse = invert(delta);
|
|
1328
|
+
}
|
|
1329
|
+
apply(doc) {
|
|
1330
|
+
this.compose(doc, this.delta);
|
|
1331
|
+
}
|
|
1332
|
+
revert(doc) {
|
|
1333
|
+
this.compose(doc, this.inverse);
|
|
1334
|
+
}
|
|
1335
|
+
compose(doc, m) {
|
|
1336
|
+
const changed = [];
|
|
1337
|
+
for (const id of this.ids) {
|
|
1338
|
+
const stroke = doc.get(id);
|
|
1339
|
+
if (!stroke) continue;
|
|
1340
|
+
stroke.matrix = mul(m, stroke.matrix ?? [
|
|
1341
|
+
1,
|
|
1342
|
+
0,
|
|
1343
|
+
0,
|
|
1344
|
+
1,
|
|
1345
|
+
0,
|
|
1346
|
+
0
|
|
1347
|
+
]);
|
|
1348
|
+
changed.push(stroke);
|
|
1349
|
+
}
|
|
1350
|
+
if (changed.length) doc.transformStrokes(changed);
|
|
1351
|
+
}
|
|
1352
|
+
};
|
|
1353
|
+
var AddNoteCommand = class {
|
|
1354
|
+
constructor(note) {
|
|
1355
|
+
this.label = "add note";
|
|
1356
|
+
this.note = cloneNote(note);
|
|
1357
|
+
}
|
|
1358
|
+
apply(doc) {
|
|
1359
|
+
doc.addNotes([cloneNote(this.note)]);
|
|
1360
|
+
}
|
|
1361
|
+
revert(doc) {
|
|
1362
|
+
doc.removeNotes([this.note.id]);
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
/** Any note mutation — move, peel, recolor, retext — as before/after. */
|
|
1366
|
+
var UpdateNoteCommand = class {
|
|
1367
|
+
constructor(before, after) {
|
|
1368
|
+
this.label = "update note";
|
|
1369
|
+
this.before = cloneNote(before);
|
|
1370
|
+
this.after = cloneNote(after);
|
|
1371
|
+
}
|
|
1372
|
+
apply(doc) {
|
|
1373
|
+
doc.setNote(cloneNote(this.after));
|
|
1374
|
+
}
|
|
1375
|
+
revert(doc) {
|
|
1376
|
+
doc.setNote(cloneNote(this.before));
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
var DeleteNoteCommand = class {
|
|
1380
|
+
constructor(note) {
|
|
1381
|
+
this.label = "delete note";
|
|
1382
|
+
this.note = cloneNote(note);
|
|
1383
|
+
}
|
|
1384
|
+
apply(doc) {
|
|
1385
|
+
doc.removeNotes([this.note.id]);
|
|
1386
|
+
}
|
|
1387
|
+
revert(doc) {
|
|
1388
|
+
doc.addNotes([cloneNote(this.note)]);
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
var AddTextCommand = class {
|
|
1392
|
+
constructor(block) {
|
|
1393
|
+
this.label = "add text";
|
|
1394
|
+
this.block = cloneText(block);
|
|
1395
|
+
}
|
|
1396
|
+
apply(doc) {
|
|
1397
|
+
doc.addTexts([cloneText(this.block)]);
|
|
1398
|
+
}
|
|
1399
|
+
revert(doc) {
|
|
1400
|
+
doc.removeTexts([this.block.id]);
|
|
1401
|
+
}
|
|
1402
|
+
};
|
|
1403
|
+
/** Any text-block mutation — move or retext — as before/after. */
|
|
1404
|
+
var UpdateTextCommand = class {
|
|
1405
|
+
constructor(before, after) {
|
|
1406
|
+
this.label = "update text";
|
|
1407
|
+
this.before = cloneText(before);
|
|
1408
|
+
this.after = cloneText(after);
|
|
1409
|
+
}
|
|
1410
|
+
apply(doc) {
|
|
1411
|
+
doc.setText(cloneText(this.after));
|
|
1412
|
+
}
|
|
1413
|
+
revert(doc) {
|
|
1414
|
+
doc.setText(cloneText(this.before));
|
|
1415
|
+
}
|
|
1416
|
+
};
|
|
1417
|
+
var DeleteTextCommand = class {
|
|
1418
|
+
constructor(block) {
|
|
1419
|
+
this.label = "delete text";
|
|
1420
|
+
this.block = cloneText(block);
|
|
1421
|
+
}
|
|
1422
|
+
apply(doc) {
|
|
1423
|
+
doc.removeTexts([this.block.id]);
|
|
1424
|
+
}
|
|
1425
|
+
revert(doc) {
|
|
1426
|
+
doc.addTexts([cloneText(this.block)]);
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
var AddTableCommand = class {
|
|
1430
|
+
constructor(table) {
|
|
1431
|
+
this.label = "add table";
|
|
1432
|
+
this.table = cloneTable(table);
|
|
1433
|
+
}
|
|
1434
|
+
apply(doc) {
|
|
1435
|
+
doc.addTables([cloneTable(this.table)]);
|
|
1436
|
+
}
|
|
1437
|
+
revert(doc) {
|
|
1438
|
+
doc.removeTables([this.table.id]);
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
/** Any table mutation — move, resize, cell text edit — as before/after. */
|
|
1442
|
+
var UpdateTableCommand = class {
|
|
1443
|
+
constructor(before, after) {
|
|
1444
|
+
this.label = "update table";
|
|
1445
|
+
this.before = cloneTable(before);
|
|
1446
|
+
this.after = cloneTable(after);
|
|
1447
|
+
}
|
|
1448
|
+
apply(doc) {
|
|
1449
|
+
doc.setTable(cloneTable(this.after));
|
|
1450
|
+
}
|
|
1451
|
+
revert(doc) {
|
|
1452
|
+
doc.setTable(cloneTable(this.before));
|
|
1453
|
+
}
|
|
1454
|
+
};
|
|
1455
|
+
var DeleteTableCommand = class {
|
|
1456
|
+
constructor(table) {
|
|
1457
|
+
this.label = "delete table";
|
|
1458
|
+
this.table = cloneTable(table);
|
|
1459
|
+
}
|
|
1460
|
+
apply(doc) {
|
|
1461
|
+
doc.removeTables([this.table.id]);
|
|
1462
|
+
}
|
|
1463
|
+
revert(doc) {
|
|
1464
|
+
doc.addTables([cloneTable(this.table)]);
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
var DeleteStrokesCommand = class {
|
|
1468
|
+
constructor(strokes) {
|
|
1469
|
+
this.label = "delete selection";
|
|
1470
|
+
this.strokes = strokes.map(cloneStroke);
|
|
1471
|
+
}
|
|
1472
|
+
apply(doc) {
|
|
1473
|
+
doc.removeStrokes(this.strokes.map((s) => s.id));
|
|
1474
|
+
}
|
|
1475
|
+
revert(doc) {
|
|
1476
|
+
doc.addStrokes(this.strokes.map(cloneStroke));
|
|
1477
|
+
}
|
|
1478
|
+
};
|
|
1479
|
+
var AddImageCommand = class {
|
|
1480
|
+
constructor(image) {
|
|
1481
|
+
this.label = "add image";
|
|
1482
|
+
this.image = cloneImage(image);
|
|
1483
|
+
}
|
|
1484
|
+
apply(doc) {
|
|
1485
|
+
doc.addImages([cloneImage(this.image)]);
|
|
1486
|
+
}
|
|
1487
|
+
revert(doc) {
|
|
1488
|
+
doc.removeImages([this.image.id]);
|
|
1489
|
+
}
|
|
1490
|
+
};
|
|
1491
|
+
/** Any image mutation — move, resize — as before/after. */
|
|
1492
|
+
var UpdateImageCommand = class {
|
|
1493
|
+
constructor(before, after) {
|
|
1494
|
+
this.label = "update image";
|
|
1495
|
+
this.before = cloneImage(before);
|
|
1496
|
+
this.after = cloneImage(after);
|
|
1497
|
+
}
|
|
1498
|
+
apply(doc) {
|
|
1499
|
+
doc.setImage(cloneImage(this.after));
|
|
1500
|
+
}
|
|
1501
|
+
revert(doc) {
|
|
1502
|
+
doc.setImage(cloneImage(this.before));
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
var DeleteImageCommand = class {
|
|
1506
|
+
constructor(image) {
|
|
1507
|
+
this.label = "delete image";
|
|
1508
|
+
this.image = cloneImage(image);
|
|
1509
|
+
}
|
|
1510
|
+
apply(doc) {
|
|
1511
|
+
doc.removeImages([this.image.id]);
|
|
1512
|
+
}
|
|
1513
|
+
revert(doc) {
|
|
1514
|
+
doc.addImages([cloneImage(this.image)]);
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
var AddTimerCommand = class {
|
|
1518
|
+
constructor(timer) {
|
|
1519
|
+
this.label = "add timer";
|
|
1520
|
+
this.timer = cloneTimer(timer);
|
|
1521
|
+
}
|
|
1522
|
+
apply(doc) {
|
|
1523
|
+
doc.addTimers([cloneTimer(this.timer)]);
|
|
1524
|
+
}
|
|
1525
|
+
revert(doc) {
|
|
1526
|
+
doc.removeTimers([this.timer.id]);
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
var UpdateTimerCommand = class {
|
|
1530
|
+
constructor(before, after) {
|
|
1531
|
+
this.label = "update timer";
|
|
1532
|
+
this.before = cloneTimer(before);
|
|
1533
|
+
this.after = cloneTimer(after);
|
|
1534
|
+
}
|
|
1535
|
+
apply(doc) {
|
|
1536
|
+
doc.setTimer(cloneTimer(this.after));
|
|
1537
|
+
}
|
|
1538
|
+
revert(doc) {
|
|
1539
|
+
doc.setTimer(cloneTimer(this.before));
|
|
1540
|
+
}
|
|
1541
|
+
};
|
|
1542
|
+
var DeleteTimerCommand = class {
|
|
1543
|
+
constructor(timer) {
|
|
1544
|
+
this.label = "delete timer";
|
|
1545
|
+
this.timer = cloneTimer(timer);
|
|
1546
|
+
}
|
|
1547
|
+
apply(doc) {
|
|
1548
|
+
doc.removeTimers([this.timer.id]);
|
|
1549
|
+
}
|
|
1550
|
+
revert(doc) {
|
|
1551
|
+
doc.addTimers([cloneTimer(this.timer)]);
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
var LockItemsCommand = class {
|
|
1555
|
+
constructor(targets, targetState, actor) {
|
|
1556
|
+
this.label = "toggle lock";
|
|
1557
|
+
this.targets = targets.map((t) => ({ ...t }));
|
|
1558
|
+
this.targetState = targetState;
|
|
1559
|
+
this.actor = actor ?? null;
|
|
1560
|
+
}
|
|
1561
|
+
apply(doc) {
|
|
1562
|
+
const by = this.targetState ? this.actor : null;
|
|
1563
|
+
for (const t of this.targets) this.applyLock(doc, t.type, t.id, this.targetState, by);
|
|
1564
|
+
}
|
|
1565
|
+
revert(doc) {
|
|
1566
|
+
for (const t of this.targets) {
|
|
1567
|
+
const by = t.locked && t.lockedBy ? {
|
|
1568
|
+
userId: t.lockedBy,
|
|
1569
|
+
name: t.lockedByName ?? ""
|
|
1570
|
+
} : null;
|
|
1571
|
+
this.applyLock(doc, t.type, t.id, t.locked, by);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
applyLock(doc, type, id, locked, by) {
|
|
1575
|
+
if (type === "stroke") doc.setStrokeLocked(id, locked, by);
|
|
1576
|
+
else if (type === "note") doc.setNoteLocked(id, locked, by);
|
|
1577
|
+
else if (type === "text") doc.setTextLocked(id, locked, by);
|
|
1578
|
+
else if (type === "table") doc.setTableLocked(id, locked, by);
|
|
1579
|
+
else if (type === "image") doc.setImageLocked(id, locked, by);
|
|
1580
|
+
else if (type === "timer") doc.setTimerLocked(id, locked, by);
|
|
1581
|
+
}
|
|
1582
|
+
};
|
|
1583
|
+
//#endregion
|
|
1584
|
+
//#region src/core-internal/history.ts
|
|
1585
|
+
var LIMIT = 200;
|
|
1586
|
+
var History = class {
|
|
1587
|
+
constructor(doc) {
|
|
1588
|
+
this.doc = doc;
|
|
1589
|
+
this.undoStack = [];
|
|
1590
|
+
this.redoStack = [];
|
|
1591
|
+
this.onCommand = null;
|
|
1592
|
+
this.undoing = false;
|
|
1593
|
+
}
|
|
1594
|
+
/** Apply a command and make it undoable. */
|
|
1595
|
+
execute(command) {
|
|
1596
|
+
command.apply(this.doc);
|
|
1597
|
+
this.record(command);
|
|
1598
|
+
}
|
|
1599
|
+
/** Make an already-applied change undoable (e.g. a live erase swipe). */
|
|
1600
|
+
record(command) {
|
|
1601
|
+
this.undoStack.push(command);
|
|
1602
|
+
if (this.undoStack.length > LIMIT) this.undoStack.shift();
|
|
1603
|
+
this.redoStack.length = 0;
|
|
1604
|
+
this.onCommand?.(command, "do");
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* True while a revert is in flight. Persistence reads this: re-adding
|
|
1608
|
+
* something the author deleted must be sent as a `restore`, the only op the
|
|
1609
|
+
* server lets past a tombstone (ADR 0006).
|
|
1610
|
+
*/
|
|
1611
|
+
get isUndoing() {
|
|
1612
|
+
return this.undoing;
|
|
1613
|
+
}
|
|
1614
|
+
undo() {
|
|
1615
|
+
const command = this.undoStack.pop();
|
|
1616
|
+
if (!command) return;
|
|
1617
|
+
this.undoing = true;
|
|
1618
|
+
try {
|
|
1619
|
+
command.revert(this.doc);
|
|
1620
|
+
} finally {
|
|
1621
|
+
this.undoing = false;
|
|
1622
|
+
}
|
|
1623
|
+
this.redoStack.push(command);
|
|
1624
|
+
this.onCommand?.(command, "undo");
|
|
1625
|
+
}
|
|
1626
|
+
redo() {
|
|
1627
|
+
const command = this.redoStack.pop();
|
|
1628
|
+
if (!command) return;
|
|
1629
|
+
command.apply(this.doc);
|
|
1630
|
+
this.undoStack.push(command);
|
|
1631
|
+
this.onCommand?.(command, "redo");
|
|
1632
|
+
}
|
|
1633
|
+
get canUndo() {
|
|
1634
|
+
return this.undoStack.length > 0;
|
|
1635
|
+
}
|
|
1636
|
+
get canRedo() {
|
|
1637
|
+
return this.redoStack.length > 0;
|
|
1638
|
+
}
|
|
1639
|
+
clear() {
|
|
1640
|
+
this.undoStack.length = 0;
|
|
1641
|
+
this.redoStack.length = 0;
|
|
1642
|
+
}
|
|
1643
|
+
};
|
|
1644
|
+
//#endregion
|
|
1645
|
+
//#region src/core-internal/ops.ts
|
|
1646
|
+
var ADDED = [
|
|
1647
|
+
["added", "strokes"],
|
|
1648
|
+
["notesAdded", "notes"],
|
|
1649
|
+
["textAdded", "textBlocks"],
|
|
1650
|
+
["tablesAdded", "tables"],
|
|
1651
|
+
["imagesAdded", "images"],
|
|
1652
|
+
["timersAdded", "timers"],
|
|
1653
|
+
["customObjectsAdded", "customObjects"]
|
|
1654
|
+
];
|
|
1655
|
+
var UPDATED = [
|
|
1656
|
+
["updated", "strokes"],
|
|
1657
|
+
["transformed", "strokes"],
|
|
1658
|
+
["notesUpdated", "notes"],
|
|
1659
|
+
["textUpdated", "textBlocks"],
|
|
1660
|
+
["tablesUpdated", "tables"],
|
|
1661
|
+
["imagesUpdated", "images"],
|
|
1662
|
+
["timersUpdated", "timers"],
|
|
1663
|
+
["customObjectsUpdated", "customObjects"]
|
|
1664
|
+
];
|
|
1665
|
+
var REMOVED = [
|
|
1666
|
+
["removed", "strokes"],
|
|
1667
|
+
["notesRemoved", "notes"],
|
|
1668
|
+
["textRemoved", "textBlocks"],
|
|
1669
|
+
["tablesRemoved", "tables"],
|
|
1670
|
+
["imagesRemoved", "images"],
|
|
1671
|
+
["timersRemoved", "timers"],
|
|
1672
|
+
["customObjectsRemoved", "customObjects"]
|
|
1673
|
+
];
|
|
1674
|
+
function toWireObject(collection, object) {
|
|
1675
|
+
return collection === "strokes" ? serializeStroke(object) : object;
|
|
1676
|
+
}
|
|
1677
|
+
/** Convert a canonical Document change into durable, renderer-neutral Ops. */
|
|
1678
|
+
function changeToOps(change, restoring = false) {
|
|
1679
|
+
const ops = [];
|
|
1680
|
+
const addKind = restoring ? "restore" : "upsert";
|
|
1681
|
+
for (const [field, collection] of ADDED) for (const object of change[field] ?? []) ops.push({
|
|
1682
|
+
kind: addKind,
|
|
1683
|
+
collection,
|
|
1684
|
+
object: toWireObject(collection, object)
|
|
1685
|
+
});
|
|
1686
|
+
for (const [field, collection] of UPDATED) for (const object of change[field] ?? []) ops.push({
|
|
1687
|
+
kind: "upsert",
|
|
1688
|
+
collection,
|
|
1689
|
+
object: toWireObject(collection, object)
|
|
1690
|
+
});
|
|
1691
|
+
for (const [field, collection] of REMOVED) for (const id of change[field] ?? []) ops.push({
|
|
1692
|
+
kind: "remove",
|
|
1693
|
+
collection,
|
|
1694
|
+
id
|
|
1695
|
+
});
|
|
1696
|
+
return ops;
|
|
1697
|
+
}
|
|
1698
|
+
//#endregion
|
|
1699
|
+
//#region src/core-internal/ribbonEdges.ts
|
|
1700
|
+
var MIN_WIDTH_FACTOR = .35;
|
|
1701
|
+
var END_TAPER = .55;
|
|
1702
|
+
function ribbonEdges(points, baseWidth) {
|
|
1703
|
+
const pts = points.length === 1 ? [points[0], {
|
|
1704
|
+
...points[0],
|
|
1705
|
+
x: points[0].x + baseWidth * .05
|
|
1706
|
+
}] : points;
|
|
1707
|
+
const n = pts.length;
|
|
1708
|
+
const out = new Array(n);
|
|
1709
|
+
for (let i = 0; i < n; i++) {
|
|
1710
|
+
const prev = pts[Math.max(0, i - 1)];
|
|
1711
|
+
const next = pts[Math.min(n - 1, i + 1)];
|
|
1712
|
+
let dx = next.x - prev.x;
|
|
1713
|
+
let dy = next.y - prev.y;
|
|
1714
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
1715
|
+
dx /= len;
|
|
1716
|
+
dy /= len;
|
|
1717
|
+
let width = baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * pts[i].pressure);
|
|
1718
|
+
if (i === 0 || i === n - 1) width *= END_TAPER;
|
|
1719
|
+
const hw = width / 2;
|
|
1720
|
+
out[i] = {
|
|
1721
|
+
lx: pts[i].x - dy * hw,
|
|
1722
|
+
ly: pts[i].y + dx * hw,
|
|
1723
|
+
rx: pts[i].x + dy * hw,
|
|
1724
|
+
ry: pts[i].y - dx * hw,
|
|
1725
|
+
alpha: 1 - Math.min(1, pts[i].erase ?? 0)
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
return out;
|
|
1729
|
+
}
|
|
1730
|
+
//#endregion
|
|
1731
|
+
//#region src/core-internal/spatialIndex.ts
|
|
1732
|
+
var CELL = 8;
|
|
1733
|
+
var SpatialIndex = class {
|
|
1734
|
+
constructor(doc) {
|
|
1735
|
+
this.doc = doc;
|
|
1736
|
+
this.cells = /* @__PURE__ */ new Map();
|
|
1737
|
+
this.strokeCells = /* @__PURE__ */ new Map();
|
|
1738
|
+
this.unsubscribe = doc.subscribe((change) => {
|
|
1739
|
+
for (const id of change.removed) this.remove(id);
|
|
1740
|
+
for (const stroke of change.added) this.insert(stroke.id);
|
|
1741
|
+
for (const stroke of change.transformed) {
|
|
1742
|
+
this.remove(stroke.id);
|
|
1743
|
+
this.insert(stroke.id);
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1746
|
+
for (const stroke of doc.all()) this.insert(stroke.id);
|
|
1747
|
+
}
|
|
1748
|
+
/** Ids of strokes whose bbox may overlap the query rect. */
|
|
1749
|
+
query(minX, minY, maxX, maxY) {
|
|
1750
|
+
const result = /* @__PURE__ */ new Set();
|
|
1751
|
+
for (const key of cellsOf({
|
|
1752
|
+
minX,
|
|
1753
|
+
minY,
|
|
1754
|
+
maxX,
|
|
1755
|
+
maxY
|
|
1756
|
+
})) {
|
|
1757
|
+
const bucket = this.cells.get(key);
|
|
1758
|
+
if (bucket) for (const id of bucket) result.add(id);
|
|
1759
|
+
}
|
|
1760
|
+
return result;
|
|
1761
|
+
}
|
|
1762
|
+
dispose() {
|
|
1763
|
+
this.unsubscribe();
|
|
1764
|
+
this.cells.clear();
|
|
1765
|
+
this.strokeCells.clear();
|
|
1766
|
+
}
|
|
1767
|
+
insert(id) {
|
|
1768
|
+
const box = this.doc.bbox(id);
|
|
1769
|
+
if (!box) return;
|
|
1770
|
+
const keys = cellsOf(box);
|
|
1771
|
+
this.strokeCells.set(id, keys);
|
|
1772
|
+
for (const key of keys) {
|
|
1773
|
+
let bucket = this.cells.get(key);
|
|
1774
|
+
if (!bucket) {
|
|
1775
|
+
bucket = /* @__PURE__ */ new Set();
|
|
1776
|
+
this.cells.set(key, bucket);
|
|
1777
|
+
}
|
|
1778
|
+
bucket.add(id);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
remove(id) {
|
|
1782
|
+
const keys = this.strokeCells.get(id);
|
|
1783
|
+
if (!keys) return;
|
|
1784
|
+
this.strokeCells.delete(id);
|
|
1785
|
+
for (const key of keys) {
|
|
1786
|
+
const bucket = this.cells.get(key);
|
|
1787
|
+
if (bucket) {
|
|
1788
|
+
bucket.delete(id);
|
|
1789
|
+
if (bucket.size === 0) this.cells.delete(key);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
function cellsOf(box) {
|
|
1795
|
+
const x0 = Math.floor(box.minX / CELL);
|
|
1796
|
+
const x1 = Math.floor(box.maxX / CELL);
|
|
1797
|
+
const y0 = Math.floor(box.minY / CELL);
|
|
1798
|
+
const y1 = Math.floor(box.maxY / CELL);
|
|
1799
|
+
const keys = [];
|
|
1800
|
+
for (let x = x0; x <= x1; x++) for (let y = y0; y <= y1; y++) keys.push(`${x}:${y}`);
|
|
1801
|
+
return keys;
|
|
1802
|
+
}
|
|
1803
|
+
//#endregion
|
|
1804
|
+
//#region src/core-internal/stamps.ts
|
|
1805
|
+
var STAMP_SIZE = 6;
|
|
1806
|
+
var STAMPS = [
|
|
1807
|
+
{
|
|
1808
|
+
kind: "star",
|
|
1809
|
+
label: "Star",
|
|
1810
|
+
glyph: "★"
|
|
1811
|
+
},
|
|
1812
|
+
{
|
|
1813
|
+
kind: "check",
|
|
1814
|
+
label: "Check",
|
|
1815
|
+
glyph: "✓"
|
|
1816
|
+
},
|
|
1817
|
+
{
|
|
1818
|
+
kind: "ship",
|
|
1819
|
+
label: "Ship it",
|
|
1820
|
+
glyph: "⚑"
|
|
1821
|
+
},
|
|
1822
|
+
{
|
|
1823
|
+
kind: "heart",
|
|
1824
|
+
label: "Heart",
|
|
1825
|
+
glyph: "♥"
|
|
1826
|
+
},
|
|
1827
|
+
{
|
|
1828
|
+
kind: "plus",
|
|
1829
|
+
label: "Plus",
|
|
1830
|
+
glyph: "+"
|
|
1831
|
+
},
|
|
1832
|
+
{
|
|
1833
|
+
kind: "fire",
|
|
1834
|
+
label: "Fire",
|
|
1835
|
+
glyph: "!"
|
|
1836
|
+
}
|
|
1837
|
+
];
|
|
1838
|
+
var SVG = {
|
|
1839
|
+
star: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><polygon fill="#F59E0B" points="32,4 39.5,24.2 61,24.2 43.7,36.6 50.4,57 32,44.4 13.6,57 20.3,36.6 3,24.2 24.5,24.2"/></svg>`,
|
|
1840
|
+
check: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="28" fill="#16A34A"/><path fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" d="M18 33l10 10 18-20"/></svg>`,
|
|
1841
|
+
ship: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect x="8" y="8" width="48" height="48" rx="10" fill="#2563EB"/><text x="32" y="30" text-anchor="middle" font-size="11" font-family="ui-rounded,sans-serif" font-weight="700" fill="#fff">SHIP</text><text x="32" y="46" text-anchor="middle" font-size="11" font-family="ui-rounded,sans-serif" font-weight="700" fill="#fff">IT</text></svg>`,
|
|
1842
|
+
heart: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="#E11D48" d="M32 54S8 38 8 24c0-8 6-14 14-14 5 0 8 3 10 6 2-3 5-6 10-6 8 0 14 6 14 14 0 14-24 30-24 30z"/></svg>`,
|
|
1843
|
+
plus: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="28" fill="#7C3AED"/><path fill="none" stroke="#fff" stroke-width="7" stroke-linecap="round" d="M32 18v28M18 32h28"/></svg>`,
|
|
1844
|
+
fire: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="#EA580C" d="M32 6s4 10-2 18c6-2 14 4 14 16 0 12-8 20-14 20s-14-8-14-20c0-10 6-16 10-20 2 6 6 8 6-14z"/><path fill="#FDE047" d="M32 30s2 6-1 10c3-1 7 2 7 8 0 6-4 10-6 10s-6-4-6-10c0-5 3-8 5-10 1 3 3 4 3-8z"/></svg>`
|
|
1845
|
+
};
|
|
1846
|
+
function stampDataUrl(kind) {
|
|
1847
|
+
return `data:image/svg+xml;utf8,${encodeURIComponent(SVG[kind])}`;
|
|
1848
|
+
}
|
|
1849
|
+
function isStampKind(value) {
|
|
1850
|
+
return typeof value === "string" && STAMPS.some((s) => s.kind === value);
|
|
1851
|
+
}
|
|
1852
|
+
//#endregion
|
|
1853
|
+
//#region src/core-internal/presence.ts
|
|
1854
|
+
/** Keep beacons clear of typical chrome at the top and bottom of the Board. */
|
|
1855
|
+
var BEACON_INSET = {
|
|
1856
|
+
top: 72,
|
|
1857
|
+
right: 24,
|
|
1858
|
+
bottom: 108,
|
|
1859
|
+
left: 24
|
|
1860
|
+
};
|
|
1861
|
+
/**
|
|
1862
|
+
* If a collaborator is in the usable viewport, return their screen position.
|
|
1863
|
+
* If they are outside it, clamp to the nearest edge and return the angle a
|
|
1864
|
+
* chevron should point (screen space, radians, 0 = right, clockwise).
|
|
1865
|
+
*/
|
|
1866
|
+
function placePresenceBeacon(screen, viewport, inset = BEACON_INSET) {
|
|
1867
|
+
const minX = inset.left;
|
|
1868
|
+
const maxX = Math.max(inset.left, viewport.width - inset.right);
|
|
1869
|
+
const minY = inset.top;
|
|
1870
|
+
const maxY = Math.max(inset.top, viewport.height - inset.bottom);
|
|
1871
|
+
if (screen.x >= minX && screen.x <= maxX && screen.y >= minY && screen.y <= maxY) return {
|
|
1872
|
+
kind: "on-screen",
|
|
1873
|
+
x: screen.x,
|
|
1874
|
+
y: screen.y
|
|
1875
|
+
};
|
|
1876
|
+
const x = clamp(screen.x, minX, maxX);
|
|
1877
|
+
const y = clamp(screen.y, minY, maxY);
|
|
1878
|
+
return {
|
|
1879
|
+
kind: "edge",
|
|
1880
|
+
x,
|
|
1881
|
+
y,
|
|
1882
|
+
angle: Math.atan2(screen.y - y, screen.x - x)
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
function clamp(value, min, max) {
|
|
1886
|
+
return Math.min(max, Math.max(min, value));
|
|
1887
|
+
}
|
|
1888
|
+
//#endregion
|
|
1889
|
+
//#region src/core-internal/svg.ts
|
|
1890
|
+
/** Matches the key light used by the board shader and note shadows. */
|
|
1891
|
+
var LIGHT = {
|
|
1892
|
+
x: -.25,
|
|
1893
|
+
y: .4,
|
|
1894
|
+
z: .88
|
|
1895
|
+
};
|
|
1896
|
+
var MARGIN = 4;
|
|
1897
|
+
var NOTE_FONT_RATIO = 44 / 512;
|
|
1898
|
+
var NOTE_PAD_RATIO = 40 / 512;
|
|
1899
|
+
/**
|
|
1900
|
+
* `registry` is optional and only enables rendering Custom objects through
|
|
1901
|
+
* their own `describe()` — without it (or for an object whose extension
|
|
1902
|
+
* isn't in it), Custom objects still export via the standard fallback
|
|
1903
|
+
* placeholder (`fallback.bounds`/`label`), never silently dropped.
|
|
1904
|
+
*
|
|
1905
|
+
* `resolvedAssets` (ticket #23) maps an Asset reference to an already-
|
|
1906
|
+
* resolved `data:` URI — see `assetExport.ts`'s `exportDocumentSVGWithAssets`,
|
|
1907
|
+
* which is the only intended caller that ever passes one. Without it, every
|
|
1908
|
+
* `ref`-backed image (built-in or Custom `SceneImage`) renders its
|
|
1909
|
+
* placeholder instead of guessing at a URL; a legacy `src`-backed image is
|
|
1910
|
+
* unaffected either way.
|
|
1911
|
+
*/
|
|
1912
|
+
function documentToSVG(doc, now = 0, registry, resolvedAssets) {
|
|
1913
|
+
const notes = doc.notes ?? [];
|
|
1914
|
+
const texts = doc.textBlocks ?? [];
|
|
1915
|
+
const tables = doc.tables ?? [];
|
|
1916
|
+
const images = doc.images ?? [];
|
|
1917
|
+
const timers = doc.timers ?? [];
|
|
1918
|
+
const customObjects = doc.customObjects ?? [];
|
|
1919
|
+
const bounds = contentBounds(doc.strokes, notes, texts, tables, images, timers, customObjects);
|
|
1920
|
+
const view = `${fmt(bounds.minX)} ${fmt(bounds.minY)} ${fmt(bounds.width)} ${fmt(bounds.height)}`;
|
|
1921
|
+
const defs = notes.map(noteShadowFilter).join("\n");
|
|
1922
|
+
const highlights = doc.strokes.filter((s) => s.tool === "highlighter");
|
|
1923
|
+
const ink = doc.strokes.filter((s) => s.tool !== "highlighter");
|
|
1924
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${view}">\n<defs>\n${defs}\n</defs>\n${[
|
|
1925
|
+
`<rect x="${fmt(bounds.minX)}" y="${fmt(bounds.minY)}" width="${fmt(bounds.width)}" height="${fmt(bounds.height)}" fill="${BOARD_COLOR}"/>`,
|
|
1926
|
+
...images.map((image) => imageToElement(image, resolvedAssets)),
|
|
1927
|
+
...tables.map(tableToGroup),
|
|
1928
|
+
...highlights.flatMap(strokeToPaths),
|
|
1929
|
+
...ink.flatMap(strokeToPaths),
|
|
1930
|
+
...texts.map(textToElement),
|
|
1931
|
+
...notes.map(noteToGroup),
|
|
1932
|
+
...timers.map((timer) => timerToGroup(timer, now)),
|
|
1933
|
+
...customObjects.map((object) => customObjectToGroup(object, registry, resolvedAssets))
|
|
1934
|
+
].join("\n")}\n</svg>\n`;
|
|
1935
|
+
}
|
|
1936
|
+
/**
|
|
1937
|
+
* Renders through the object's own `describe()` when its extension is
|
|
1938
|
+
* registered; otherwise (or if `describe` throws) falls back to the
|
|
1939
|
+
* standard non-editable placeholder — see the spec's "unknown/invalid
|
|
1940
|
+
* Custom objects... display a standard non-editable fallback" rule.
|
|
1941
|
+
*/
|
|
1942
|
+
function customObjectToGroup(object, registry, resolvedAssets) {
|
|
1943
|
+
const definition = registry?.objectType(object.type);
|
|
1944
|
+
if (definition) try {
|
|
1945
|
+
const scene = definition.describe(object, { selected: false });
|
|
1946
|
+
return `<g data-id="${object.id}">${sceneNodeToSVG(scene, object.transform, resolvedAssets)}</g>`;
|
|
1947
|
+
} catch {}
|
|
1948
|
+
return fallbackToGroup(object);
|
|
1949
|
+
}
|
|
1950
|
+
/** Board-space matrix `m`, expressed as the equivalent SVG-space `matrix()`. */
|
|
1951
|
+
function svgTransformAttr(m) {
|
|
1952
|
+
if (isIdentity(m)) return "";
|
|
1953
|
+
return ` transform="matrix(${fmt(m[0])} ${fmt(-m[1])} ${fmt(-m[2])} ${fmt(m[3])} ${fmt(m[4])} ${fmt(-m[5])})"`;
|
|
1954
|
+
}
|
|
1955
|
+
/**
|
|
1956
|
+
* Walks one `BoardScene` node. Coordinates are object-local (pre-`parent`
|
|
1957
|
+
* transform, y grows the same "downward" direction `sceneGeometry.ts`'s
|
|
1958
|
+
* bounds walk assumes); `ScenePath` has no renderer yet (deferred, matching
|
|
1959
|
+
* `sceneGeometry.ts`'s own bounds walk) so it renders nothing rather than
|
|
1960
|
+
* guessing at SVG path-data semantics.
|
|
1961
|
+
*/
|
|
1962
|
+
function sceneNodeToSVG(node, parent, resolvedAssets) {
|
|
1963
|
+
const m = node.transform ? mul(parent, node.transform) : parent;
|
|
1964
|
+
const opacityAttr = node.opacity !== void 0 && node.opacity < 1 ? ` opacity="${fmt(node.opacity)}"` : "";
|
|
1965
|
+
const transformAttr = svgTransformAttr(m);
|
|
1966
|
+
switch (node.kind) {
|
|
1967
|
+
case "rect": {
|
|
1968
|
+
const svgY = -node.y - node.height;
|
|
1969
|
+
const rx = node.cornerRadius ? ` rx="${fmt(node.cornerRadius)}"` : "";
|
|
1970
|
+
const stroke = node.stroke ? ` stroke="${node.stroke}" stroke-width="${fmt(node.strokeWidth ?? .1)}"` : "";
|
|
1971
|
+
return `<rect x="${fmt(node.x)}" y="${fmt(svgY)}" width="${fmt(node.width)}" height="${fmt(node.height)}" fill="${node.fill ?? "none"}"${rx}${stroke}${opacityAttr}${transformAttr}/>`;
|
|
1972
|
+
}
|
|
1973
|
+
case "text": {
|
|
1974
|
+
const anchor = node.align === "center" ? ` text-anchor="middle"` : node.align === "end" ? ` text-anchor="end"` : "";
|
|
1975
|
+
const tspans = node.text.split("\n").map((line, i) => `<tspan x="${fmt(node.x)}" y="${fmt(-node.y + node.fontSize * (.8 + 1.25 * i))}">${escapeXML(line)}</tspan>`).join("");
|
|
1976
|
+
return `<text font-family="Inter, 'Segoe UI', sans-serif" font-size="${fmt(node.fontSize)}" font-weight="500" fill="${node.color}"${anchor}${opacityAttr}${transformAttr}>${tspans}</text>`;
|
|
1977
|
+
}
|
|
1978
|
+
case "ellipse": {
|
|
1979
|
+
const stroke = node.stroke ? ` stroke="${node.stroke}" stroke-width="${fmt(node.strokeWidth ?? .1)}"` : "";
|
|
1980
|
+
return `<ellipse cx="${fmt(node.cx)}" cy="${fmt(-node.cy)}" rx="${fmt(node.rx)}" ry="${fmt(node.ry)}" fill="${node.fill ?? "none"}"${stroke}${opacityAttr}${transformAttr}/>`;
|
|
1981
|
+
}
|
|
1982
|
+
case "image": {
|
|
1983
|
+
const svgY = -node.y - node.height;
|
|
1984
|
+
const dataUri = resolvedAssets?.get(node.ref);
|
|
1985
|
+
if (!dataUri) {
|
|
1986
|
+
const label = typeof node.alt === "string" ? node.alt : void 0;
|
|
1987
|
+
return `<g${opacityAttr}${transformAttr}>${imagePlaceholderElement(node.x, svgY, node.width, node.height, label)}</g>`;
|
|
1988
|
+
}
|
|
1989
|
+
const preserveAspectRatio = node.fit === "contain" ? "xMidYMid meet" : node.fit === "cover" ? "xMidYMid slice" : "none";
|
|
1990
|
+
return `<image href="${escapeXML(dataUri)}" x="${fmt(node.x)}" y="${fmt(svgY)}" width="${fmt(node.width)}" height="${fmt(node.height)}" preserveAspectRatio="${preserveAspectRatio}"${opacityAttr}${transformAttr}/>`;
|
|
1991
|
+
}
|
|
1992
|
+
case "group": return `<g${opacityAttr}${transformAttr}>${node.children.map((child) => sceneNodeToSVG(child, IDENTITY, resolvedAssets)).join("")}</g>`;
|
|
1993
|
+
case "path": return "";
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
/**
|
|
1997
|
+
* A never-resolved or missing Asset's placeholder (ticket #23) — same
|
|
1998
|
+
* visual language as `fallbackToGroup`'s Custom-object fallback. Never
|
|
1999
|
+
* reveals the reference, a path, a URL, or a resolver error detail.
|
|
2000
|
+
*/
|
|
2001
|
+
function imagePlaceholderElement(x, y, width, height, label) {
|
|
2002
|
+
const labelEl = label ? `<text x="${fmt(x + Math.min(.3, width * .1))}" y="${fmt(y + Math.min(1.2, height * .4))}" font-family="Inter, 'Segoe UI', sans-serif" font-size="${fmt(Math.min(1.1, height * .3))}" fill="#6B7280">${escapeXML(label)}</text>` : "";
|
|
2003
|
+
return [`<rect x="${fmt(x)}" y="${fmt(y)}" width="${fmt(width)}" height="${fmt(height)}" fill="#F3F4F6" stroke="#9CA3AF" stroke-width="0.15" stroke-dasharray="0.3 0.3"/>`, labelEl].join("");
|
|
2004
|
+
}
|
|
2005
|
+
function fallbackToGroup(object) {
|
|
2006
|
+
const b = object.fallback.bounds;
|
|
2007
|
+
const svgY = -b.y - b.height;
|
|
2008
|
+
const label = object.fallback.label;
|
|
2009
|
+
const labelEl = label ? `<text x="${fmt(b.x + Math.min(.3, b.width * .1))}" y="${fmt(svgY + Math.min(1.2, b.height * .4))}" font-family="Inter, 'Segoe UI', sans-serif" font-size="${fmt(Math.min(1.1, b.height * .3))}" fill="#6B7280">${escapeXML(label)}</text>` : "";
|
|
2010
|
+
return [
|
|
2011
|
+
`<g data-id="${object.id}" data-fallback="true"${svgTransformAttr(object.transform)}>`,
|
|
2012
|
+
`<rect x="${fmt(b.x)}" y="${fmt(svgY)}" width="${fmt(b.width)}" height="${fmt(b.height)}" fill="#F3F4F6" stroke="#9CA3AF" stroke-width="0.15" stroke-dasharray="0.3 0.3"/>`,
|
|
2013
|
+
labelEl,
|
|
2014
|
+
`</g>`
|
|
2015
|
+
].join("");
|
|
2016
|
+
}
|
|
2017
|
+
/** One filled outline path per run of similar erasure; y flipped at emit. */
|
|
2018
|
+
function strokeToPaths(stroke) {
|
|
2019
|
+
const edges = ribbonEdges(stroke.points.map(([x, y, pressure, erase]) => ({
|
|
2020
|
+
x,
|
|
2021
|
+
y,
|
|
2022
|
+
pressure,
|
|
2023
|
+
...erase > 0 ? { erase } : {}
|
|
2024
|
+
})), stroke.baseWidth);
|
|
2025
|
+
const transform = stroke.matrix ? ` transform="matrix(${fmt(stroke.matrix[0])} ${fmt(-stroke.matrix[1])} ${fmt(-stroke.matrix[2])} ${fmt(stroke.matrix[3])} ${fmt(stroke.matrix[4])} ${fmt(-stroke.matrix[5])})"` : "";
|
|
2026
|
+
const baseOpacity = stroke.tool === "highlighter" ? .4 : 1;
|
|
2027
|
+
const paths = [];
|
|
2028
|
+
for (const run of splitByOpacity(edges)) {
|
|
2029
|
+
if (run.edges.length < 2) continue;
|
|
2030
|
+
const left = run.edges.map((e) => `${fmt(e.lx)} ${fmt(-e.ly)}`);
|
|
2031
|
+
const right = [...run.edges].reverse().map((e) => `${fmt(e.rx)} ${fmt(-e.ry)}`);
|
|
2032
|
+
const d = `M ${left.join(" L ")} L ${right.join(" L ")} Z`;
|
|
2033
|
+
const opacity = run.opacity * baseOpacity;
|
|
2034
|
+
const opacityAttr = opacity < 1 ? ` fill-opacity="${fmt(opacity)}"` : "";
|
|
2035
|
+
paths.push(`<path data-id="${stroke.id}" d="${d}" fill="${stroke.color}"${opacityAttr}${transform}/>`);
|
|
2036
|
+
}
|
|
2037
|
+
return paths;
|
|
2038
|
+
}
|
|
2039
|
+
/** Consecutive edge points bucketed by quarter-step opacity; erased spans dropped. */
|
|
2040
|
+
function splitByOpacity(edges) {
|
|
2041
|
+
const runs = [];
|
|
2042
|
+
for (const edge of edges) {
|
|
2043
|
+
const opacity = Math.round(edge.alpha * 4) / 4;
|
|
2044
|
+
const current = runs[runs.length - 1];
|
|
2045
|
+
if (current && current.opacity === opacity) current.edges.push(edge);
|
|
2046
|
+
else runs.push({
|
|
2047
|
+
opacity,
|
|
2048
|
+
edges: [edge]
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
return runs.filter((r) => r.opacity > 0);
|
|
2052
|
+
}
|
|
2053
|
+
/** Typed text: top-left anchored, lines flow downward (svg +y after flip). */
|
|
2054
|
+
function textToElement(block) {
|
|
2055
|
+
const top = -block.y;
|
|
2056
|
+
const tspans = block.text.split("\n").map((line, i) => `<tspan x="${fmt(block.x)}" y="${fmt(top + block.fontSize * (.8 + 1.25 * i))}">${escapeXML(line)}</tspan>`).join("");
|
|
2057
|
+
return `<text font-family="Inter, 'Segoe UI', sans-serif" font-size="${fmt(block.fontSize)}" font-weight="500" fill="${block.color}">${tspans}</text>`;
|
|
2058
|
+
}
|
|
2059
|
+
function noteShadowFilter(note) {
|
|
2060
|
+
const dx = -LIGHT.x / LIGHT.z * note.zOffset;
|
|
2061
|
+
const dy = LIGHT.y / LIGHT.z * note.zOffset;
|
|
2062
|
+
const blur = (.35 + note.zOffset * .55) / 2;
|
|
2063
|
+
const opacity = Math.max(.05, .26 - note.zOffset * .028);
|
|
2064
|
+
return `<filter id="sh-${note.id}" x="-50%" y="-50%" width="200%" height="200%"><feDropShadow dx="${fmt(dx)}" dy="${fmt(dy)}" stdDeviation="${fmt(blur)}" flood-opacity="${fmt(opacity)}"/></filter>`;
|
|
2065
|
+
}
|
|
2066
|
+
function noteToGroup(note) {
|
|
2067
|
+
const left = note.x - note.size / 2;
|
|
2068
|
+
const top = -note.y - note.size / 2;
|
|
2069
|
+
const fontSize = note.size * NOTE_FONT_RATIO;
|
|
2070
|
+
const pad = note.size * NOTE_PAD_RATIO;
|
|
2071
|
+
const tspans = wrapText(note.text, note.size - pad * 2, fontSize).map((line, i) => `<tspan x="${fmt(left + pad)}" y="${fmt(top + pad + fontSize * (.8 + 1.25 * i))}">${escapeXML(line)}</tspan>`).join("");
|
|
2072
|
+
return [
|
|
2073
|
+
`<g>`,
|
|
2074
|
+
`<rect x="${fmt(left)}" y="${fmt(top)}" width="${fmt(note.size)}" height="${fmt(note.size)}" fill="${note.color}" filter="url(#sh-${note.id})"/>`,
|
|
2075
|
+
`<text font-family="ui-rounded, 'Segoe UI', sans-serif" font-size="${fmt(fontSize)}" font-weight="500" fill="#37352F">${tspans}</text>`,
|
|
2076
|
+
`</g>`
|
|
2077
|
+
].join("");
|
|
2078
|
+
}
|
|
2079
|
+
/**
|
|
2080
|
+
* Deterministic word wrap with an average-glyph-width estimate — no DOM
|
|
2081
|
+
* text measurement, so the walk stays pure and testable.
|
|
2082
|
+
*/
|
|
2083
|
+
function wrapText(text, maxWidth, fontSize) {
|
|
2084
|
+
const charWidth = fontSize * .55;
|
|
2085
|
+
const maxChars = Math.max(1, Math.floor(maxWidth / charWidth));
|
|
2086
|
+
const lines = [];
|
|
2087
|
+
for (const paragraph of text.split("\n")) {
|
|
2088
|
+
let line = "";
|
|
2089
|
+
for (const word of paragraph.split(" ")) {
|
|
2090
|
+
const attempt = line ? `${line} ${word}` : word;
|
|
2091
|
+
if (attempt.length > maxChars && line) {
|
|
2092
|
+
lines.push(line);
|
|
2093
|
+
line = word;
|
|
2094
|
+
} else line = attempt;
|
|
2095
|
+
}
|
|
2096
|
+
lines.push(line);
|
|
2097
|
+
}
|
|
2098
|
+
return lines;
|
|
2099
|
+
}
|
|
2100
|
+
function tableToGroup(table) {
|
|
2101
|
+
const { width, height } = measureTable(table);
|
|
2102
|
+
const left = table.x;
|
|
2103
|
+
const top = -table.y;
|
|
2104
|
+
const headerH = table.rowHeights[0] ?? height / table.rows;
|
|
2105
|
+
const borderColor = table.color || "#D1D5DB";
|
|
2106
|
+
const bgColor = table.backgroundColor || "#FFFFFF";
|
|
2107
|
+
const elements = [];
|
|
2108
|
+
elements.push(`<rect x="${fmt(left)}" y="${fmt(top)}" width="${fmt(width)}" height="${fmt(height)}" fill="${bgColor}" rx="0.5"/>`);
|
|
2109
|
+
elements.push(`<rect x="${fmt(left)}" y="${fmt(top)}" width="${fmt(width)}" height="${fmt(headerH)}" fill="#F3F4F6" rx="0.5"/>`);
|
|
2110
|
+
elements.push(`<rect x="${fmt(left)}" y="${fmt(top)}" width="${fmt(width)}" height="${fmt(height)}" fill="none" stroke="${borderColor}" stroke-width="0.3" rx="0.5"/>`);
|
|
2111
|
+
let curY = 0;
|
|
2112
|
+
for (let r = 0; r < table.rows - 1; r++) {
|
|
2113
|
+
curY += table.rowHeights[r];
|
|
2114
|
+
elements.push(`<line x1="${fmt(left)}" y1="${fmt(top + curY)}" x2="${fmt(left + width)}" y2="${fmt(top + curY)}" stroke="${borderColor}" stroke-width="0.2"/>`);
|
|
2115
|
+
}
|
|
2116
|
+
let curX = 0;
|
|
2117
|
+
for (let c = 0; c < table.cols - 1; c++) {
|
|
2118
|
+
curX += table.colWidths[c];
|
|
2119
|
+
elements.push(`<line x1="${fmt(left + curX)}" y1="${fmt(top)}" x2="${fmt(left + curX)}" y2="${fmt(top + height)}" stroke="${borderColor}" stroke-width="0.2"/>`);
|
|
2120
|
+
}
|
|
2121
|
+
let cellTop = 0;
|
|
2122
|
+
for (let r = 0; r < table.rows; r++) {
|
|
2123
|
+
const rowH = table.rowHeights[r];
|
|
2124
|
+
let cellLeft = 0;
|
|
2125
|
+
for (let c = 0; c < table.cols; c++) {
|
|
2126
|
+
const colW = table.colWidths[c];
|
|
2127
|
+
const key = `${r},${c}`;
|
|
2128
|
+
const text = table.cells[key] ?? "";
|
|
2129
|
+
if (text) {
|
|
2130
|
+
const padX = Math.min(.4, colW * .08);
|
|
2131
|
+
const padY = Math.min(.3, rowH * .12);
|
|
2132
|
+
const fontSize = Math.min(1.25, rowH * .45);
|
|
2133
|
+
const weight = r === 0 ? "bold" : "500";
|
|
2134
|
+
const color = r === 0 ? "#111827" : table.color ?? "#374151";
|
|
2135
|
+
elements.push(`<text x="${fmt(left + cellLeft + padX)}" y="${fmt(top + cellTop + padY + fontSize * .8)}" font-family="Inter, 'Segoe UI', sans-serif" font-size="${fmt(fontSize)}" font-weight="${weight}" fill="${color}">${escapeXML(text)}</text>`);
|
|
2136
|
+
}
|
|
2137
|
+
cellLeft += colW;
|
|
2138
|
+
}
|
|
2139
|
+
cellTop += rowH;
|
|
2140
|
+
}
|
|
2141
|
+
return `<g data-id="${table.id}">\n${elements.join("\n")}\n</g>`;
|
|
2142
|
+
}
|
|
2143
|
+
function timerToGroup(timer, now) {
|
|
2144
|
+
const r = timer.size / 2;
|
|
2145
|
+
const cx = timer.x;
|
|
2146
|
+
const cy = -timer.y;
|
|
2147
|
+
const label = formatTimer(timerRemaining(timer, now));
|
|
2148
|
+
return [
|
|
2149
|
+
`<g data-id="${timer.id}">`,
|
|
2150
|
+
`<circle cx="${fmt(cx + .4)}" cy="${fmt(cy + .4)}" r="${fmt(r)}" fill="#000" fill-opacity="0.12"/>`,
|
|
2151
|
+
`<circle cx="${fmt(cx)}" cy="${fmt(cy)}" r="${fmt(r)}" fill="#E11D48"/>`,
|
|
2152
|
+
`<circle cx="${fmt(cx)}" cy="${fmt(cy)}" r="${fmt(r * .72)}" fill="#FAFAF9"/>`,
|
|
2153
|
+
`<text x="${fmt(cx)}" y="${fmt(cy + timer.size * .08)}" text-anchor="middle" font-family="ui-rounded, 'Segoe UI', sans-serif" font-size="${fmt(timer.size * .22)}" font-weight="700" fill="#1C1917">${label}</text>`,
|
|
2154
|
+
`</g>`
|
|
2155
|
+
].join("");
|
|
2156
|
+
}
|
|
2157
|
+
function imageToElement(image, resolvedAssets) {
|
|
2158
|
+
const left = image.x - image.width / 2;
|
|
2159
|
+
const top = -image.y - image.height / 2;
|
|
2160
|
+
if (image.ref) {
|
|
2161
|
+
const dataUri = resolvedAssets?.get(image.ref);
|
|
2162
|
+
if (!dataUri) return `<g data-id="${image.id}" data-fallback="true">${imagePlaceholderElement(left, top, image.width, image.height, image.name)}</g>`;
|
|
2163
|
+
return `<image data-id="${image.id}" href="${escapeXML(dataUri)}" x="${fmt(left)}" y="${fmt(top)}" width="${fmt(image.width)}" height="${fmt(image.height)}" preserveAspectRatio="none"/>`;
|
|
2164
|
+
}
|
|
2165
|
+
return `<image href="${escapeXML(image.src)}" x="${fmt(left)}" y="${fmt(top)}" width="${fmt(image.width)}" height="${fmt(image.height)}" preserveAspectRatio="none"/>`;
|
|
2166
|
+
}
|
|
2167
|
+
function contentBounds(strokes, notes, texts, tables = [], images = [], timers = [], customObjects = []) {
|
|
2168
|
+
let minX = Infinity;
|
|
2169
|
+
let minY = Infinity;
|
|
2170
|
+
let maxX = -Infinity;
|
|
2171
|
+
let maxY = -Infinity;
|
|
2172
|
+
const grow = (x, y) => {
|
|
2173
|
+
if (x < minX) minX = x;
|
|
2174
|
+
if (y < minY) minY = y;
|
|
2175
|
+
if (x > maxX) maxX = x;
|
|
2176
|
+
if (y > maxY) maxY = y;
|
|
2177
|
+
};
|
|
2178
|
+
for (const s of strokes) {
|
|
2179
|
+
const m = s.matrix;
|
|
2180
|
+
for (const [x, y] of s.points) grow(m ? m[0] * x + m[2] * y + m[4] : x, -(m ? m[1] * x + m[3] * y + m[5] : y));
|
|
2181
|
+
}
|
|
2182
|
+
for (const n of notes) {
|
|
2183
|
+
grow(n.x - n.size / 2, -n.y - n.size / 2);
|
|
2184
|
+
grow(n.x + n.size / 2, -n.y + n.size / 2);
|
|
2185
|
+
}
|
|
2186
|
+
for (const t of texts) {
|
|
2187
|
+
const { width, height } = measureTextBlock(t.text, t.fontSize);
|
|
2188
|
+
grow(t.x, -t.y);
|
|
2189
|
+
grow(t.x + width, -t.y + height);
|
|
2190
|
+
}
|
|
2191
|
+
for (const table of tables) {
|
|
2192
|
+
const { width, height } = measureTable(table);
|
|
2193
|
+
grow(table.x, -table.y);
|
|
2194
|
+
grow(table.x + width, -table.y + height);
|
|
2195
|
+
}
|
|
2196
|
+
for (const img of images) {
|
|
2197
|
+
grow(img.x - img.width / 2, -img.y - img.height / 2);
|
|
2198
|
+
grow(img.x + img.width / 2, -img.y + img.height / 2);
|
|
2199
|
+
}
|
|
2200
|
+
for (const timer of timers) {
|
|
2201
|
+
const half = timer.size / 2;
|
|
2202
|
+
grow(timer.x - half, -timer.y - half);
|
|
2203
|
+
grow(timer.x + half, -timer.y + half);
|
|
2204
|
+
}
|
|
2205
|
+
for (const object of customObjects) {
|
|
2206
|
+
const b = object.fallback.bounds;
|
|
2207
|
+
for (const corner of [
|
|
2208
|
+
{
|
|
2209
|
+
x: b.x,
|
|
2210
|
+
y: b.y
|
|
2211
|
+
},
|
|
2212
|
+
{
|
|
2213
|
+
x: b.x + b.width,
|
|
2214
|
+
y: b.y
|
|
2215
|
+
},
|
|
2216
|
+
{
|
|
2217
|
+
x: b.x,
|
|
2218
|
+
y: b.y + b.height
|
|
2219
|
+
},
|
|
2220
|
+
{
|
|
2221
|
+
x: b.x + b.width,
|
|
2222
|
+
y: b.y + b.height
|
|
2223
|
+
}
|
|
2224
|
+
]) {
|
|
2225
|
+
const p = apply(object.transform, corner);
|
|
2226
|
+
grow(p.x, -p.y);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
if (minX > maxX) return {
|
|
2230
|
+
minX: 0,
|
|
2231
|
+
minY: 0,
|
|
2232
|
+
width: 100,
|
|
2233
|
+
height: 100
|
|
2234
|
+
};
|
|
2235
|
+
return {
|
|
2236
|
+
minX: minX - MARGIN,
|
|
2237
|
+
minY: minY - MARGIN,
|
|
2238
|
+
width: maxX - minX + MARGIN * 2,
|
|
2239
|
+
height: maxY - minY + MARGIN * 2
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
function escapeXML(s) {
|
|
2243
|
+
return s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
2244
|
+
}
|
|
2245
|
+
function fmt(n) {
|
|
2246
|
+
return String(Math.round(n * 100) / 100);
|
|
2247
|
+
}
|
|
2248
|
+
//#endregion
|
|
2249
|
+
//#region src/core.ts
|
|
2250
|
+
var SDK_PACKAGE_NAME = "@scrawl-board/board";
|
|
2251
|
+
var SDK_DEVELOPMENT_VERSION = "0.0.0-development";
|
|
2252
|
+
//#endregion
|
|
2253
|
+
export { AddImageCommand, AddNoteCommand, AddStrokesCommand, AddTableCommand, AddTextCommand, AddTimerCommand, BEACON_INSET, BOARD_COLOR, BoardDocument, CURRENT_DOCUMENT_SCHEMA_VERSION, ClusterStore, DeleteImageCommand, DeleteNoteCommand, DeleteStrokesCommand, DeleteTableCommand, DeleteTextCommand, DeleteTimerCommand, DocumentRecoveryError, END_TAPER, ERASE_THRESHOLD, EraseCommand, FOG_COLOR, HIGHLIGHT_COLORS, History, IDENTITY, INK_COLORS, LockItemsCommand, MIN_WIDTH_FACTOR, NOTE_COLORS, NOTE_DEFAULT_SIZE, NOTE_DEFAULT_Z, NOTE_MAX_Z, NOTE_MIN_Z, NOTE_PEEL_STEP, SDK_DEVELOPMENT_VERSION, SDK_PACKAGE_NAME, STAMPS, STAMP_SIZE, SpatialIndex, TABLE_DEFAULT_CELL_HEIGHT, TABLE_DEFAULT_CELL_WIDTH, TABLE_DEFAULT_FONT_SIZE, TEXT_DEFAULT_SIZE, TIMER_DEFAULT_DURATION_MS, TIMER_DEFAULT_SIZE, TIMER_PRESETS_MS, TransformCommand, UpdateImageCommand, UpdateNoteCommand, UpdateTableCommand, UpdateTextCommand, UpdateTimerCommand, apply, applyItemLock, avgScale, canUnlockItem, changeToOps, cloneImage, cloneNote, cloneStroke, cloneTable, cloneText, cloneTimer, documentId, documentToSVG, formatTimer, invert, isIdentity, isStampKind, loadDocumentBytes, measureTable, measureTextBlock, migrateDocument, mul, pauseTimer, placePresenceBeacon, ribbonEdges, rotationAbout, scalingAbout, searchBoard, serializeDocument, serializeLock, serializeStroke, setTimerDuration, stampDataUrl, startTimer, strokeId, timerExpired, timerRemaining, toggleTimer, translation };
|