@rapidmx/meet-plugin 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +35 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +508 -483
  4. package/apps/meet/_CallView.tsx +1060 -924
  5. package/apps/meet/_ChatPanel.tsx +194 -0
  6. package/apps/meet/_ColorWheel.tsx +136 -0
  7. package/apps/meet/_MeetLobby.tsx +224 -224
  8. package/apps/meet/_ParticipantTile.tsx +243 -242
  9. package/apps/meet/_Whiteboard.tsx +577 -0
  10. package/apps/meet/_icons.tsx +135 -90
  11. package/apps/meet/_meetApi.ts +213 -212
  12. package/apps/shared/collab/CollabSession.ts +675 -0
  13. package/apps/shared/collab/WhiteboardModel.ts +216 -0
  14. package/apps/shared/collab/chatMarkdown.tsx +375 -0
  15. package/apps/shared/collab/color.ts +67 -0
  16. package/apps/shared/collab/protocol.ts +321 -0
  17. package/apps/shared/collab/whiteboardRender.ts +311 -0
  18. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  19. package/apps/shared/media/filters/mlModels.ts +174 -174
  20. package/apps/shared/relay/RelayClient.ts +2 -1
  21. package/apps/shared/relay/RelayTransport.ts +2 -1
  22. package/apps/shared/relay/relayEnv.ts +1 -1
  23. package/apps/shared/signaling/SignalingClient.ts +335 -0
  24. package/apps/shared/webrtc/MeshConnectionManager.ts +971 -971
  25. package/apps/shared/webrtc/types.ts +335 -316
  26. package/dist/apps/meet/[token].js +1 -1
  27. package/dist/apps/meet/_CallControls.d.ts +12 -4
  28. package/dist/apps/meet/_CallControls.js +5 -5
  29. package/dist/apps/meet/_CallView.d.ts +19 -4
  30. package/dist/apps/meet/_CallView.js +100 -11
  31. package/dist/apps/meet/_ChatPanel.d.ts +24 -0
  32. package/dist/apps/meet/_ChatPanel.js +116 -0
  33. package/dist/apps/meet/_ColorWheel.d.ts +14 -0
  34. package/dist/apps/meet/_ColorWheel.js +82 -0
  35. package/dist/apps/meet/_ParticipantTile.d.ts +2 -1
  36. package/dist/apps/meet/_ParticipantTile.js +1 -1
  37. package/dist/apps/meet/_Whiteboard.d.ts +64 -0
  38. package/dist/apps/meet/_Whiteboard.js +312 -0
  39. package/dist/apps/meet/_icons.d.ts +12 -0
  40. package/dist/apps/meet/_icons.js +12 -0
  41. package/dist/apps/meet/_meetApi.d.ts +8 -7
  42. package/dist/apps/meet/_meetApi.js +2 -2
  43. package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
  44. package/dist/apps/shared/collab/CollabSession.js +559 -0
  45. package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
  46. package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
  47. package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
  48. package/dist/apps/shared/collab/chatMarkdown.js +311 -0
  49. package/dist/apps/shared/collab/color.d.ts +18 -0
  50. package/dist/apps/shared/collab/color.js +59 -0
  51. package/dist/apps/shared/collab/protocol.d.ts +157 -0
  52. package/dist/apps/shared/collab/protocol.js +222 -0
  53. package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
  54. package/dist/apps/shared/collab/whiteboardRender.js +268 -0
  55. package/dist/apps/shared/relay/RelayClient.js +2 -1
  56. package/dist/apps/shared/relay/RelayTransport.d.ts +2 -1
  57. package/dist/apps/shared/relay/RelayTransport.js +2 -1
  58. package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
  59. package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
  60. package/dist/apps/shared/signaling/SignalingClient.js +278 -0
  61. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
  62. package/dist/apps/shared/webrtc/MeshConnectionManager.js +8 -8
  63. package/dist/apps/shared/webrtc/types.d.ts +38 -24
  64. package/dist/apps/shared/webrtc/types.js +3 -4
  65. package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
  66. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  67. package/dist/lib/util/SignalingHub.js +339 -0
  68. package/dist/lib/util/SignalingHub.js.map +1 -0
  69. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
  70. package/dist/types/util/SignalingHub.d.ts +112 -0
  71. package/package.json +208 -208
  72. package/apps/shared/push/GuestSignalingClient.ts +0 -359
  73. package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
  74. package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
@@ -0,0 +1,321 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The payloads of the call's chat and shared whiteboard, carried as ordinary `SignalMessage`s (`chat`, `whiteboard`,
7
+ * `collab-sync-request`, `collab-sync` - see `../webrtc/types.ts`) over the meeting's signaling socket, and the
8
+ * validators every received one goes through. Everything here arrives from another participant's browser, which
9
+ * this one has no reason to trust beyond the sender identity the server stamps on the envelope: a payload that is
10
+ * malformed, out of range or too big is dropped whole by `parseChat()`/`parseWhiteboardOp()`/`parseSync()` before
11
+ * anything renders it.
12
+ *
13
+ * ## The board
14
+ *
15
+ * The whiteboard is a fixed `BOARD_WIDTH` x `BOARD_HEIGHT` coordinate space (16:9), whatever size each participant's
16
+ * window shows it at, holding three kinds of `BoardElement`: a freehand `stroke` drawn with one of `PEN_TYPES`, a
17
+ * `shape` drawn with the stencil (`STENCIL_SHAPES`), and a `stamp` (one of `STAMPS`). Coordinates are whole board
18
+ * units, so a stroke's points travel as a flat `[x0, y0, x1, y1, ...]` integer array.
19
+ *
20
+ * An element's id starts with its author's peer id (`<peer>:<n>`), and only its author may add to it - a `points`
21
+ * op or a second `add` under someone else's id is ignored, so nobody can rewrite another participant's drawing. Anyone
22
+ * may remove any element, though: erasing is a shared-board action, as on a real whiteboard.
23
+ *
24
+ * Clearing the board raises its `epoch`. Every element records the epoch it was drawn in, and an element from an
25
+ * older epoch is dropped wherever it shows up - so a participant who missed the clear, or a late joiner's catch-up
26
+ * sent by someone who did, cannot bring cleared drawings back.
27
+ */
28
+
29
+ export const BOARD_WIDTH = 1600;
30
+ export const BOARD_HEIGHT = 900;
31
+
32
+ /** The freehand pens, each with its own look - see `whiteboardRender.ts`. */
33
+ export const PEN_TYPES = ["ballpoint", "sharpie", "marker", "crayon", "pencil"] as const;
34
+ export type PenType = (typeof PEN_TYPES)[number];
35
+
36
+ /** What the stencil draws: dragged out from one corner (or end) to the other. */
37
+ export const STENCIL_SHAPES = ["line", "arrow", "rect", "ellipse", "triangle", "star"] as const;
38
+ export type StencilShape = (typeof STENCIL_SHAPES)[number];
39
+
40
+ /** The stamps a participant can place. A received stamp outside this list is ignored, so an arbitrary string from
41
+ * another participant is never drawn - the same rule as the call's reactions (`REACTION_EMOJIS`). */
42
+ export const STAMPS = ["⭐", "❤️", "✅", "❌", "❓", "❗", "💡", "👍", "👎", "😀", "🎉", "🔥", "➡️", "⬅️", "⬆️", "⬇️"] as const;
43
+ export type Stamp = (typeof STAMPS)[number];
44
+
45
+ /** The pen/line sizes, smallest first - an element's `size` is one of these. */
46
+ export const SIZES = [2, 4, 8, 14, 24] as const;
47
+ export const STAMP_SIZES = [32, 56, 96, 160] as const;
48
+
49
+ /** The longest chat message, in characters. */
50
+ export const MAX_CHAT_LENGTH = 4000;
51
+ /** The most points one stroke may hold - about a minute of continuous drawing. */
52
+ export const MAX_STROKE_POINTS = 8000;
53
+ /** The most points one `points` op (or the first batch of an `add`) may carry. */
54
+ export const MAX_POINTS_PER_OP = 2000;
55
+ /** The most elements a board holds; drawing past it is refused until something is erased. */
56
+ export const MAX_BOARD_ELEMENTS = 5000;
57
+ /** The most chat messages kept; older ones are dropped. */
58
+ export const MAX_CHAT_HISTORY = 500;
59
+ /** The most ids one `remove` op may name. */
60
+ export const MAX_REMOVE_IDS = 500;
61
+ /** The longest element or chat id. */
62
+ export const MAX_ID_LENGTH = 200;
63
+ /** The longest display name a catch-up may carry for a chat message. */
64
+ export const MAX_NAME_LENGTH = 200;
65
+
66
+ export interface StrokeElement {
67
+ kind: "stroke";
68
+ id: string;
69
+ epoch: number;
70
+ pen: PenType;
71
+ color: string;
72
+ size: number;
73
+ /** Flat `[x0, y0, x1, y1, ...]`, whole board units. */
74
+ points: number[];
75
+ }
76
+
77
+ export interface ShapeElement {
78
+ kind: "shape";
79
+ id: string;
80
+ epoch: number;
81
+ shape: StencilShape;
82
+ color: string;
83
+ size: number;
84
+ x1: number;
85
+ y1: number;
86
+ x2: number;
87
+ y2: number;
88
+ }
89
+
90
+ export interface StampElement {
91
+ kind: "stamp";
92
+ id: string;
93
+ epoch: number;
94
+ stamp: Stamp;
95
+ size: number;
96
+ x: number;
97
+ y: number;
98
+ }
99
+
100
+ export type BoardElement = StrokeElement | ShapeElement | StampElement;
101
+
102
+ export type WhiteboardOp =
103
+ /** A new element - for a stroke, its first points; the rest follow in `points` ops. */
104
+ | { op: "add"; element: BoardElement }
105
+ /** More points of the sender's own stroke `id`, written from point index `start` on (so a repeat is harmless). */
106
+ | { op: "points"; id: string; start: number; points: number[] }
107
+ | { op: "remove"; ids: string[] }
108
+ /** The board was cleared, now at `epoch`. */
109
+ | { op: "clear"; epoch: number };
110
+
111
+ /** One chat message as its author sends it. */
112
+ export interface ChatPayload {
113
+ id: string;
114
+ text: string;
115
+ /** The author's clock, ms since the epoch - only for display, never for ordering. */
116
+ sentAt: number;
117
+ }
118
+
119
+ /** One chat message as a catch-up carries it: who sent it travels inside, since the envelope names the catch-up's sender. */
120
+ export interface ChatHistoryEntry extends ChatPayload {
121
+ peer: string;
122
+ name: string;
123
+ }
124
+
125
+ /** One part of the catch-up an existing participant sends a newcomer - see `CollabSession`. */
126
+ export interface SyncPayload {
127
+ part: number;
128
+ of: number;
129
+ epoch: number;
130
+ chat: ChatHistoryEntry[];
131
+ ops: WhiteboardOp[];
132
+ }
133
+
134
+ const COLOR_PATTERN = /^#[0-9a-f]{6}$/;
135
+
136
+ function isRecord(value: unknown): value is Record<string, unknown> {
137
+ return !!value && typeof value === "object" && !Array.isArray(value);
138
+ }
139
+
140
+ function isId(value: unknown): value is string {
141
+ return typeof value === "string" && value.length > 0 && value.length <= MAX_ID_LENGTH;
142
+ }
143
+
144
+ function isCoordinate(value: unknown, max: number): value is number {
145
+ return Number.isInteger(value) && (value as number) >= -max && (value as number) <= 2 * max;
146
+ }
147
+
148
+ function isEpoch(value: unknown): value is number {
149
+ return Number.isInteger(value) && (value as number) >= 0;
150
+ }
151
+
152
+ function isPoints(value: unknown, max: number): value is number[] {
153
+ if (!Array.isArray(value) || value.length % 2 !== 0 || value.length / 2 > max) {
154
+ return false;
155
+ }
156
+ for (let i = 0; i < value.length; i += 2) {
157
+ if (!isCoordinate(value[i], BOARD_WIDTH) || !isCoordinate(value[i + 1], BOARD_HEIGHT)) {
158
+ return false;
159
+ }
160
+ }
161
+ return true;
162
+ }
163
+
164
+ /** `color` as a lowercase `#rrggbb`, or `undefined` when it is not one. */
165
+ export function normalizeColor(color: unknown): string | undefined {
166
+ if (typeof color !== "string") {
167
+ return undefined;
168
+ }
169
+ const lower = color.toLowerCase();
170
+ return COLOR_PATTERN.test(lower) ? lower : undefined;
171
+ }
172
+
173
+ /** Whether `id` is one `peer` may author - see this module's doc comment. */
174
+ export function isOwnId(id: string, peer: string): boolean {
175
+ return id.startsWith(`${peer}:`);
176
+ }
177
+
178
+ function parseElement(value: unknown): BoardElement | undefined {
179
+ if (!isRecord(value) || !isId(value.id) || !isEpoch(value.epoch)) {
180
+ return undefined;
181
+ }
182
+ const { id, epoch } = value;
183
+ switch (value.kind) {
184
+ case "stroke": {
185
+ const color = normalizeColor(value.color);
186
+ if (!PEN_TYPES.includes(value.pen as PenType) || !color || !SIZES.includes(value.size as never) || !isPoints(value.points, MAX_POINTS_PER_OP) || value.points.length === 0) {
187
+ return undefined;
188
+ }
189
+ return { kind: "stroke", id, epoch, pen: value.pen as PenType, color, size: value.size as number, points: [...value.points] };
190
+ }
191
+ case "shape": {
192
+ const color = normalizeColor(value.color);
193
+ if (
194
+ !STENCIL_SHAPES.includes(value.shape as StencilShape) ||
195
+ !color ||
196
+ !SIZES.includes(value.size as never) ||
197
+ !isCoordinate(value.x1, BOARD_WIDTH) ||
198
+ !isCoordinate(value.y1, BOARD_HEIGHT) ||
199
+ !isCoordinate(value.x2, BOARD_WIDTH) ||
200
+ !isCoordinate(value.y2, BOARD_HEIGHT)
201
+ ) {
202
+ return undefined;
203
+ }
204
+ const { x1, y1, x2, y2 } = value as unknown as ShapeElement;
205
+ return { kind: "shape", id, epoch, shape: value.shape as StencilShape, color, size: value.size as number, x1, y1, x2, y2 };
206
+ }
207
+ case "stamp": {
208
+ if (!STAMPS.includes(value.stamp as Stamp) || !STAMP_SIZES.includes(value.size as never) || !isCoordinate(value.x, BOARD_WIDTH) || !isCoordinate(value.y, BOARD_HEIGHT)) {
209
+ return undefined;
210
+ }
211
+ return { kind: "stamp", id, epoch, stamp: value.stamp as Stamp, size: value.size as number, x: value.x, y: value.y };
212
+ }
213
+ default:
214
+ return undefined;
215
+ }
216
+ }
217
+
218
+ /** A received whiteboard op, validated and copied, or `undefined` to drop it. */
219
+ export function parseWhiteboardOp(value: unknown): WhiteboardOp | undefined {
220
+ if (!isRecord(value)) {
221
+ return undefined;
222
+ }
223
+ switch (value.op) {
224
+ case "add": {
225
+ const element = parseElement(value.element);
226
+ return element ? { op: "add", element } : undefined;
227
+ }
228
+ case "points":
229
+ if (!isId(value.id) || !Number.isInteger(value.start) || (value.start as number) < 0 || (value.start as number) >= MAX_STROKE_POINTS) {
230
+ return undefined;
231
+ }
232
+ if (!isPoints(value.points, MAX_POINTS_PER_OP) || value.points.length === 0) {
233
+ return undefined;
234
+ }
235
+ return { op: "points", id: value.id, start: value.start as number, points: [...value.points] };
236
+ case "remove":
237
+ if (!Array.isArray(value.ids) || value.ids.length === 0 || value.ids.length > MAX_REMOVE_IDS || !value.ids.every(isId)) {
238
+ return undefined;
239
+ }
240
+ return { op: "remove", ids: [...(value.ids)] };
241
+ case "clear":
242
+ return isEpoch(value.epoch) ? { op: "clear", epoch: value.epoch } : undefined;
243
+ default:
244
+ return undefined;
245
+ }
246
+ }
247
+
248
+ /** A received chat message, validated and copied, or `undefined` to drop it. Blank text is dropped too. */
249
+ export function parseChat(value: unknown): ChatPayload | undefined {
250
+ if (!isRecord(value) || !isId(value.id) || typeof value.text !== "string" || typeof value.sentAt !== "number" || !Number.isFinite(value.sentAt)) {
251
+ return undefined;
252
+ }
253
+ if (value.text.length > MAX_CHAT_LENGTH || !value.text.trim()) {
254
+ return undefined;
255
+ }
256
+ return { id: value.id, text: value.text, sentAt: value.sentAt };
257
+ }
258
+
259
+ /** A received catch-up part, validated and copied; entries that fail validation are left out rather than dropping
260
+ * the whole part, and `undefined` only when the part itself is malformed. */
261
+ export function parseSync(value: unknown): SyncPayload | undefined {
262
+ if (!isRecord(value) || !Number.isInteger(value.part) || !Number.isInteger(value.of) || !isEpoch(value.epoch)) {
263
+ return undefined;
264
+ }
265
+ const part = value.part as number;
266
+ const of = value.of as number;
267
+ if (of < 1 || part < 0 || part >= of || !Array.isArray(value.chat) || !Array.isArray(value.ops)) {
268
+ return undefined;
269
+ }
270
+ const chat: ChatHistoryEntry[] = [];
271
+ for (const entry of value.chat) {
272
+ const parsed = parseChat(entry);
273
+ if (parsed && isRecord(entry) && isId(entry.peer) && typeof entry.name === "string" && entry.name.length <= MAX_NAME_LENGTH) {
274
+ chat.push({ ...parsed, peer: entry.peer, name: entry.name });
275
+ }
276
+ }
277
+ const ops: WhiteboardOp[] = [];
278
+ for (const op of value.ops) {
279
+ const parsed = parseWhiteboardOp(op);
280
+ if (parsed) {
281
+ ops.push(parsed);
282
+ }
283
+ }
284
+ return { part, of, epoch: value.epoch, chat, ops };
285
+ }
286
+
287
+ /** What a participant's pointer on the board shows: the pen, or the stencil, stamp or eraser, they have picked. */
288
+ export const CURSOR_TOOLS = [...PEN_TYPES, "stencil", "stamp", "eraser"] as const;
289
+ export type CursorTool = (typeof CURSOR_TOOLS)[number];
290
+
291
+ /** Where a participant's pointer is on the board, and as what - or that it has left the board. Never stored, never
292
+ * part of a catch-up: a pointer is only ever where it was a moment ago. */
293
+ export type CursorPayload = { hidden: true } | { x: number; y: number; tool: CursorTool; color: string; stamp?: Stamp };
294
+
295
+ /** A received pointer update, validated and copied, or `undefined` to drop it. */
296
+ export function parseCursor(value: unknown): CursorPayload | undefined {
297
+ if (!isRecord(value)) {
298
+ return undefined;
299
+ }
300
+ if (value.hidden === true) {
301
+ return { hidden: true };
302
+ }
303
+ const color = normalizeColor(value.color);
304
+ if (!CURSOR_TOOLS.includes(value.tool as CursorTool) || !color || !isCoordinate(value.x, BOARD_WIDTH) || !isCoordinate(value.y, BOARD_HEIGHT)) {
305
+ return undefined;
306
+ }
307
+ if (value.stamp !== undefined && !STAMPS.includes(value.stamp as Stamp)) {
308
+ return undefined;
309
+ }
310
+ const cursor: CursorPayload = { x: value.x, y: value.y, tool: value.tool as CursorTool, color };
311
+ if (value.stamp !== undefined) {
312
+ cursor.stamp = value.stamp as Stamp;
313
+ }
314
+ return cursor;
315
+ }
316
+
317
+ /** `value` rounded and kept within `[0, max]` - every coordinate this participant sends is, so no receiver's
318
+ * validation (which allows some slack beyond the board) ever drops it. */
319
+ export function clampCoordinate(value: number, max: number): number {
320
+ return Math.min(max, Math.max(0, Math.round(Number.isFinite(value) ? value : 0)));
321
+ }
@@ -0,0 +1,311 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Draws the whiteboard's elements on a 2D canvas, in board units (`protocol.ts`'s `BOARD_WIDTH` x `BOARD_HEIGHT`)
7
+ * scaled to the canvas. Each pen has its own look:
8
+ *
9
+ * - **ballpoint** - a thin, even, opaque line.
10
+ * - **sharpie** - a bolder opaque line with round ends.
11
+ * - **marker** - a broad, square-tipped, translucent stroke laid down in `multiply`, so it highlights what is under
12
+ * it instead of covering it, and doesn't darken where it overlaps itself (the stroke is one path).
13
+ * - **crayon** - a waxy translucent base speckled with grain, like wax catching on paper.
14
+ * - **pencil** - a coloring pencil: several fine, slightly offset, faint passes that build up a grainy line.
15
+ *
16
+ * The grain of a crayon or pencil comes from a random generator seeded by the element's id, so every participant's
17
+ * board draws the very same texture for the same stroke - it is not re-randomized per screen or per redraw.
18
+ */
19
+ import { BOARD_HEIGHT, BOARD_WIDTH, type BoardElement, type ShapeElement, type StampElement, type StrokeElement } from "./protocol.js";
20
+
21
+ /** The subset of `CanvasRenderingContext2D` the renderer uses, so a test can record calls on a plain object. */
22
+ export type BoardContext = Pick<
23
+ CanvasRenderingContext2D,
24
+ | "save"
25
+ | "restore"
26
+ | "setTransform"
27
+ | "clearRect"
28
+ | "fillRect"
29
+ | "beginPath"
30
+ | "closePath"
31
+ | "moveTo"
32
+ | "lineTo"
33
+ | "quadraticCurveTo"
34
+ | "ellipse"
35
+ | "stroke"
36
+ | "fill"
37
+ | "fillText"
38
+ | "lineWidth"
39
+ | "lineCap"
40
+ | "lineJoin"
41
+ | "strokeStyle"
42
+ | "fillStyle"
43
+ | "globalAlpha"
44
+ | "globalCompositeOperation"
45
+ | "font"
46
+ | "textAlign"
47
+ | "textBaseline"
48
+ >;
49
+
50
+ /** The board's paper color. */
51
+ export const BOARD_BACKGROUND = "#ffffff";
52
+
53
+ /** The font stack stamps are drawn in - each platform's color emoji font first. */
54
+ export const STAMP_FONT_FAMILY = '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif';
55
+
56
+ /** A small, fast, seedable PRNG (mulberry32) - returns numbers in [0, 1). */
57
+ export function seededRandom(seed: string): () => number {
58
+ let state = 2166136261;
59
+ for (let i = 0; i < seed.length; i++) {
60
+ state = Math.imul(state ^ seed.charCodeAt(i), 16777619);
61
+ }
62
+ return () => {
63
+ state = (state + 0x6d2b79f5) | 0;
64
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
65
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
66
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
67
+ };
68
+ }
69
+
70
+ /** Clears the canvas to paper and draws `elements` in order, `scale` canvas pixels per board unit. */
71
+ export function renderBoard(ctx: BoardContext, elements: readonly BoardElement[], scale: number): void {
72
+ ctx.save();
73
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
74
+ ctx.globalAlpha = 1;
75
+ ctx.globalCompositeOperation = "source-over";
76
+ ctx.fillStyle = BOARD_BACKGROUND;
77
+ ctx.fillRect(0, 0, BOARD_WIDTH * scale, BOARD_HEIGHT * scale);
78
+ ctx.setTransform(scale, 0, 0, scale, 0, 0);
79
+ for (const element of elements) {
80
+ drawElement(ctx, element);
81
+ }
82
+ ctx.restore();
83
+ }
84
+
85
+ export function drawElement(ctx: BoardContext, element: BoardElement): void {
86
+ ctx.save();
87
+ switch (element.kind) {
88
+ case "stroke":
89
+ drawStroke(ctx, element);
90
+ break;
91
+ case "shape":
92
+ drawShape(ctx, element);
93
+ break;
94
+ case "stamp":
95
+ drawStamp(ctx, element);
96
+ break;
97
+ }
98
+ ctx.restore();
99
+ }
100
+
101
+ /** Traces a smooth path through `points` (curves through the midpoints between them), offset by (`dx`, `dy`). */
102
+ function tracePath(ctx: BoardContext, points: readonly number[], dx = 0, dy = 0): void {
103
+ ctx.beginPath();
104
+ ctx.moveTo(points[0] + dx, points[1] + dy);
105
+ if (points.length === 2) {
106
+ // A single tap still leaves a dot.
107
+ ctx.lineTo(points[0] + dx + 0.01, points[1] + dy);
108
+ return;
109
+ }
110
+ for (let i = 2; i < points.length - 2; i += 2) {
111
+ const midX = (points[i] + points[i + 2]) / 2;
112
+ const midY = (points[i + 1] + points[i + 3]) / 2;
113
+ ctx.quadraticCurveTo(points[i] + dx, points[i + 1] + dy, midX + dx, midY + dy);
114
+ }
115
+ ctx.lineTo(points[points.length - 2] + dx, points[points.length - 1] + dy);
116
+ }
117
+
118
+ function drawStroke(ctx: BoardContext, stroke: StrokeElement): void {
119
+ const { points, size, color } = stroke;
120
+ ctx.strokeStyle = color;
121
+ ctx.fillStyle = color;
122
+ ctx.lineJoin = "round";
123
+ ctx.lineCap = "round";
124
+ switch (stroke.pen) {
125
+ case "ballpoint":
126
+ ctx.lineWidth = Math.max(1.2, size * 0.6);
127
+ tracePath(ctx, points);
128
+ ctx.stroke();
129
+ return;
130
+ case "sharpie":
131
+ ctx.lineWidth = size * 1.4;
132
+ tracePath(ctx, points);
133
+ ctx.stroke();
134
+ return;
135
+ case "marker":
136
+ ctx.globalCompositeOperation = "multiply";
137
+ ctx.globalAlpha = 0.4;
138
+ ctx.lineCap = "square";
139
+ ctx.lineWidth = size * 3;
140
+ tracePath(ctx, points);
141
+ ctx.stroke();
142
+ return;
143
+ case "crayon":
144
+ drawCrayon(ctx, stroke);
145
+ return;
146
+ case "pencil":
147
+ drawPencil(ctx, stroke);
148
+ return;
149
+ }
150
+ }
151
+
152
+ function drawCrayon(ctx: BoardContext, stroke: StrokeElement): void {
153
+ const random = seededRandom(stroke.id);
154
+ const width = stroke.size * 1.8;
155
+ ctx.globalAlpha = 0.55;
156
+ ctx.lineWidth = width;
157
+ tracePath(ctx, stroke.points);
158
+ ctx.stroke();
159
+ // Grain: specks scattered across the stroke's width along every segment, more for a longer segment.
160
+ ctx.globalAlpha = 0.7;
161
+ const speck = Math.max(1, width / 6);
162
+ eachSegment(stroke.points, (x0, y0, x1, y1, length) => {
163
+ const count = Math.ceil((length * width) / 18);
164
+ for (let i = 0; i < count; i++) {
165
+ const t = random();
166
+ const spread = (random() - 0.5) * width;
167
+ const along = (random() - 0.5) * speck;
168
+ ctx.fillRect(x0 + (x1 - x0) * t + spread * 0.7 + along, y0 + (y1 - y0) * t + spread * 0.7 - along, speck, speck);
169
+ }
170
+ });
171
+ }
172
+
173
+ function drawPencil(ctx: BoardContext, stroke: StrokeElement): void {
174
+ const random = seededRandom(stroke.id);
175
+ ctx.lineWidth = Math.max(0.8, stroke.size * 0.45);
176
+ ctx.globalAlpha = 0.35;
177
+ const passes = 3;
178
+ const jitter = Math.max(0.6, stroke.size * 0.35);
179
+ for (let pass = 0; pass < passes; pass++) {
180
+ tracePath(ctx, stroke.points, (random() - 0.5) * jitter, (random() - 0.5) * jitter);
181
+ ctx.stroke();
182
+ }
183
+ }
184
+
185
+ /** Calls `visit` with each segment of `points` and its length; a single point is a zero-length segment. */
186
+ function eachSegment(points: readonly number[], visit: (x0: number, y0: number, x1: number, y1: number, length: number) => void): void {
187
+ if (points.length === 2) {
188
+ visit(points[0], points[1], points[0], points[1], 1);
189
+ return;
190
+ }
191
+ for (let i = 0; i < points.length - 2; i += 2) {
192
+ const [x0, y0, x1, y1] = [points[i], points[i + 1], points[i + 2], points[i + 3]];
193
+ visit(x0, y0, x1, y1, Math.hypot(x1 - x0, y1 - y0));
194
+ }
195
+ }
196
+
197
+ function drawShape(ctx: BoardContext, shape: ShapeElement): void {
198
+ const { x1, y1, x2, y2 } = shape;
199
+ ctx.strokeStyle = shape.color;
200
+ ctx.lineWidth = shape.size;
201
+ ctx.lineJoin = "round";
202
+ ctx.lineCap = "round";
203
+ ctx.beginPath();
204
+ const left = Math.min(x1, x2);
205
+ const top = Math.min(y1, y2);
206
+ const width = Math.abs(x2 - x1);
207
+ const height = Math.abs(y2 - y1);
208
+ switch (shape.shape) {
209
+ case "line":
210
+ ctx.moveTo(x1, y1);
211
+ ctx.lineTo(x2, y2);
212
+ break;
213
+ case "arrow": {
214
+ ctx.moveTo(x1, y1);
215
+ ctx.lineTo(x2, y2);
216
+ const angle = Math.atan2(y2 - y1, x2 - x1);
217
+ const head = Math.max(12, shape.size * 4);
218
+ for (const side of [-1, 1]) {
219
+ ctx.moveTo(x2, y2);
220
+ ctx.lineTo(x2 - head * Math.cos(angle + side * (Math.PI / 7)), y2 - head * Math.sin(angle + side * (Math.PI / 7)));
221
+ }
222
+ break;
223
+ }
224
+ case "rect":
225
+ ctx.moveTo(left, top);
226
+ ctx.lineTo(left + width, top);
227
+ ctx.lineTo(left + width, top + height);
228
+ ctx.lineTo(left, top + height);
229
+ ctx.closePath();
230
+ break;
231
+ case "ellipse":
232
+ ctx.ellipse(left + width / 2, top + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
233
+ break;
234
+ case "triangle":
235
+ ctx.moveTo(left + width / 2, top);
236
+ ctx.lineTo(left + width, top + height);
237
+ ctx.lineTo(left, top + height);
238
+ ctx.closePath();
239
+ break;
240
+ case "star":
241
+ for (const [i, [px, py]] of starPoints(left, top, width, height).entries()) {
242
+ if (i === 0) {
243
+ ctx.moveTo(px, py);
244
+ } else {
245
+ ctx.lineTo(px, py);
246
+ }
247
+ }
248
+ ctx.closePath();
249
+ break;
250
+ }
251
+ ctx.stroke();
252
+ }
253
+
254
+ /** The ten corners of a five-pointed star filling the given box, starting at its top point. */
255
+ export function starPoints(left: number, top: number, width: number, height: number): [number, number][] {
256
+ const cx = left + width / 2;
257
+ const cy = top + height / 2;
258
+ const corners: [number, number][] = [];
259
+ for (let i = 0; i < 10; i++) {
260
+ const radius = i % 2 === 0 ? 1 : 0.4;
261
+ const angle = -Math.PI / 2 + (i * Math.PI) / 5;
262
+ corners.push([cx + (Math.cos(angle) * radius * width) / 2, cy + (Math.sin(angle) * radius * height) / 2]);
263
+ }
264
+ return corners;
265
+ }
266
+
267
+ function drawStamp(ctx: BoardContext, stamp: StampElement): void {
268
+ ctx.font = `${stamp.size}px ${STAMP_FONT_FAMILY}`;
269
+ ctx.textAlign = "center";
270
+ ctx.textBaseline = "middle";
271
+ ctx.fillStyle = "#000000";
272
+ ctx.fillText(stamp.stamp, stamp.x, stamp.y);
273
+ }
274
+
275
+ function distanceToSegment(px: number, py: number, x0: number, y0: number, x1: number, y1: number): number {
276
+ const dx = x1 - x0;
277
+ const dy = y1 - y0;
278
+ const lengthSquared = dx * dx + dy * dy;
279
+ const t = lengthSquared === 0 ? 0 : Math.max(0, Math.min(1, ((px - x0) * dx + (py - y0) * dy) / lengthSquared));
280
+ return Math.hypot(px - (x0 + t * dx), py - (y0 + t * dy));
281
+ }
282
+
283
+ /** Whether the eraser at (`x`, `y`) with `radius`, in board units, touches `element`. A stroke or line counts its own
284
+ * width; a closed shape is touched on its outline's bounding box, a stamp anywhere on its square. */
285
+ export function hitTest(element: BoardElement, x: number, y: number, radius: number): boolean {
286
+ switch (element.kind) {
287
+ case "stroke": {
288
+ const reach = radius + element.size;
289
+ let hit = false;
290
+ eachSegment(element.points, (x0, y0, x1, y1) => {
291
+ hit = hit || distanceToSegment(x, y, x0, y0, x1, y1) <= reach;
292
+ });
293
+ return hit;
294
+ }
295
+ case "shape": {
296
+ const reach = radius + element.size;
297
+ if (element.shape === "line" || element.shape === "arrow") {
298
+ return distanceToSegment(x, y, element.x1, element.y1, element.x2, element.y2) <= reach;
299
+ }
300
+ const left = Math.min(element.x1, element.x2) - reach;
301
+ const right = Math.max(element.x1, element.x2) + reach;
302
+ const top = Math.min(element.y1, element.y2) - reach;
303
+ const bottom = Math.max(element.y1, element.y2) + reach;
304
+ return x >= left && x <= right && y >= top && y <= bottom;
305
+ }
306
+ case "stamp": {
307
+ const half = element.size / 2 + radius;
308
+ return Math.abs(x - element.x) <= half && Math.abs(y - element.y) <= half;
309
+ }
310
+ }
311
+ }