@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.
- package/README.md +35 -55
- package/apps/meet/[token].tsx +341 -341
- package/apps/meet/_CallControls.tsx +508 -483
- package/apps/meet/_CallView.tsx +1060 -924
- package/apps/meet/_ChatPanel.tsx +194 -0
- package/apps/meet/_ColorWheel.tsx +136 -0
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +243 -242
- package/apps/meet/_Whiteboard.tsx +577 -0
- package/apps/meet/_icons.tsx +135 -90
- package/apps/meet/_meetApi.ts +213 -212
- package/apps/shared/collab/CollabSession.ts +675 -0
- package/apps/shared/collab/WhiteboardModel.ts +216 -0
- package/apps/shared/collab/chatMarkdown.tsx +375 -0
- package/apps/shared/collab/color.ts +67 -0
- package/apps/shared/collab/protocol.ts +321 -0
- package/apps/shared/collab/whiteboardRender.ts +311 -0
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
- package/apps/shared/media/filters/mlModels.ts +174 -174
- package/apps/shared/relay/RelayClient.ts +2 -1
- package/apps/shared/relay/RelayTransport.ts +2 -1
- package/apps/shared/relay/relayEnv.ts +1 -1
- package/apps/shared/signaling/SignalingClient.ts +335 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +971 -971
- package/apps/shared/webrtc/types.ts +335 -316
- package/dist/apps/meet/[token].js +1 -1
- package/dist/apps/meet/_CallControls.d.ts +12 -4
- package/dist/apps/meet/_CallControls.js +5 -5
- package/dist/apps/meet/_CallView.d.ts +19 -4
- package/dist/apps/meet/_CallView.js +100 -11
- package/dist/apps/meet/_ChatPanel.d.ts +24 -0
- package/dist/apps/meet/_ChatPanel.js +116 -0
- package/dist/apps/meet/_ColorWheel.d.ts +14 -0
- package/dist/apps/meet/_ColorWheel.js +82 -0
- package/dist/apps/meet/_ParticipantTile.d.ts +2 -1
- package/dist/apps/meet/_ParticipantTile.js +1 -1
- package/dist/apps/meet/_Whiteboard.d.ts +64 -0
- package/dist/apps/meet/_Whiteboard.js +312 -0
- package/dist/apps/meet/_icons.d.ts +12 -0
- package/dist/apps/meet/_icons.js +12 -0
- package/dist/apps/meet/_meetApi.d.ts +8 -7
- package/dist/apps/meet/_meetApi.js +2 -2
- package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
- package/dist/apps/shared/collab/CollabSession.js +559 -0
- package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
- package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
- package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
- package/dist/apps/shared/collab/chatMarkdown.js +311 -0
- package/dist/apps/shared/collab/color.d.ts +18 -0
- package/dist/apps/shared/collab/color.js +59 -0
- package/dist/apps/shared/collab/protocol.d.ts +157 -0
- package/dist/apps/shared/collab/protocol.js +222 -0
- package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
- package/dist/apps/shared/collab/whiteboardRender.js +268 -0
- package/dist/apps/shared/relay/RelayClient.js +2 -1
- package/dist/apps/shared/relay/RelayTransport.d.ts +2 -1
- package/dist/apps/shared/relay/RelayTransport.js +2 -1
- package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
- package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
- package/dist/apps/shared/signaling/SignalingClient.js +278 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +8 -8
- package/dist/apps/shared/webrtc/types.d.ts +38 -24
- package/dist/apps/shared/webrtc/types.js +3 -4
- package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/SignalingHub.js +339 -0
- package/dist/lib/util/SignalingHub.js.map +1 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
- package/dist/types/util/SignalingHub.d.ts +112 -0
- package/package.json +208 -208
- package/apps/shared/push/GuestSignalingClient.ts +0 -359
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
- package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The payloads of the call's chat and shared whiteboard, carried as ordinary `SignalMessage`s (`chat`, `whiteboard`,
|
|
3
|
+
* `collab-sync-request`, `collab-sync` - see `../webrtc/types.ts`) over the meeting's signaling socket, and the
|
|
4
|
+
* validators every received one goes through. Everything here arrives from another participant's browser, which
|
|
5
|
+
* this one has no reason to trust beyond the sender identity the server stamps on the envelope: a payload that is
|
|
6
|
+
* malformed, out of range or too big is dropped whole by `parseChat()`/`parseWhiteboardOp()`/`parseSync()` before
|
|
7
|
+
* anything renders it.
|
|
8
|
+
*
|
|
9
|
+
* ## The board
|
|
10
|
+
*
|
|
11
|
+
* The whiteboard is a fixed `BOARD_WIDTH` x `BOARD_HEIGHT` coordinate space (16:9), whatever size each participant's
|
|
12
|
+
* window shows it at, holding three kinds of `BoardElement`: a freehand `stroke` drawn with one of `PEN_TYPES`, a
|
|
13
|
+
* `shape` drawn with the stencil (`STENCIL_SHAPES`), and a `stamp` (one of `STAMPS`). Coordinates are whole board
|
|
14
|
+
* units, so a stroke's points travel as a flat `[x0, y0, x1, y1, ...]` integer array.
|
|
15
|
+
*
|
|
16
|
+
* An element's id starts with its author's peer id (`<peer>:<n>`), and only its author may add to it - a `points`
|
|
17
|
+
* op or a second `add` under someone else's id is ignored, so nobody can rewrite another participant's drawing. Anyone
|
|
18
|
+
* may remove any element, though: erasing is a shared-board action, as on a real whiteboard.
|
|
19
|
+
*
|
|
20
|
+
* Clearing the board raises its `epoch`. Every element records the epoch it was drawn in, and an element from an
|
|
21
|
+
* older epoch is dropped wherever it shows up - so a participant who missed the clear, or a late joiner's catch-up
|
|
22
|
+
* sent by someone who did, cannot bring cleared drawings back.
|
|
23
|
+
*/
|
|
24
|
+
export declare const BOARD_WIDTH = 1600;
|
|
25
|
+
export declare const BOARD_HEIGHT = 900;
|
|
26
|
+
/** The freehand pens, each with its own look - see `whiteboardRender.ts`. */
|
|
27
|
+
export declare const PEN_TYPES: readonly ["ballpoint", "sharpie", "marker", "crayon", "pencil"];
|
|
28
|
+
export type PenType = (typeof PEN_TYPES)[number];
|
|
29
|
+
/** What the stencil draws: dragged out from one corner (or end) to the other. */
|
|
30
|
+
export declare const STENCIL_SHAPES: readonly ["line", "arrow", "rect", "ellipse", "triangle", "star"];
|
|
31
|
+
export type StencilShape = (typeof STENCIL_SHAPES)[number];
|
|
32
|
+
/** The stamps a participant can place. A received stamp outside this list is ignored, so an arbitrary string from
|
|
33
|
+
* another participant is never drawn - the same rule as the call's reactions (`REACTION_EMOJIS`). */
|
|
34
|
+
export declare const STAMPS: readonly ["⭐", "❤️", "✅", "❌", "❓", "❗", "💡", "👍", "👎", "😀", "🎉", "🔥", "➡️", "⬅️", "⬆️", "⬇️"];
|
|
35
|
+
export type Stamp = (typeof STAMPS)[number];
|
|
36
|
+
/** The pen/line sizes, smallest first - an element's `size` is one of these. */
|
|
37
|
+
export declare const SIZES: readonly [2, 4, 8, 14, 24];
|
|
38
|
+
export declare const STAMP_SIZES: readonly [32, 56, 96, 160];
|
|
39
|
+
/** The longest chat message, in characters. */
|
|
40
|
+
export declare const MAX_CHAT_LENGTH = 4000;
|
|
41
|
+
/** The most points one stroke may hold - about a minute of continuous drawing. */
|
|
42
|
+
export declare const MAX_STROKE_POINTS = 8000;
|
|
43
|
+
/** The most points one `points` op (or the first batch of an `add`) may carry. */
|
|
44
|
+
export declare const MAX_POINTS_PER_OP = 2000;
|
|
45
|
+
/** The most elements a board holds; drawing past it is refused until something is erased. */
|
|
46
|
+
export declare const MAX_BOARD_ELEMENTS = 5000;
|
|
47
|
+
/** The most chat messages kept; older ones are dropped. */
|
|
48
|
+
export declare const MAX_CHAT_HISTORY = 500;
|
|
49
|
+
/** The most ids one `remove` op may name. */
|
|
50
|
+
export declare const MAX_REMOVE_IDS = 500;
|
|
51
|
+
/** The longest element or chat id. */
|
|
52
|
+
export declare const MAX_ID_LENGTH = 200;
|
|
53
|
+
/** The longest display name a catch-up may carry for a chat message. */
|
|
54
|
+
export declare const MAX_NAME_LENGTH = 200;
|
|
55
|
+
export interface StrokeElement {
|
|
56
|
+
kind: "stroke";
|
|
57
|
+
id: string;
|
|
58
|
+
epoch: number;
|
|
59
|
+
pen: PenType;
|
|
60
|
+
color: string;
|
|
61
|
+
size: number;
|
|
62
|
+
/** Flat `[x0, y0, x1, y1, ...]`, whole board units. */
|
|
63
|
+
points: number[];
|
|
64
|
+
}
|
|
65
|
+
export interface ShapeElement {
|
|
66
|
+
kind: "shape";
|
|
67
|
+
id: string;
|
|
68
|
+
epoch: number;
|
|
69
|
+
shape: StencilShape;
|
|
70
|
+
color: string;
|
|
71
|
+
size: number;
|
|
72
|
+
x1: number;
|
|
73
|
+
y1: number;
|
|
74
|
+
x2: number;
|
|
75
|
+
y2: number;
|
|
76
|
+
}
|
|
77
|
+
export interface StampElement {
|
|
78
|
+
kind: "stamp";
|
|
79
|
+
id: string;
|
|
80
|
+
epoch: number;
|
|
81
|
+
stamp: Stamp;
|
|
82
|
+
size: number;
|
|
83
|
+
x: number;
|
|
84
|
+
y: number;
|
|
85
|
+
}
|
|
86
|
+
export type BoardElement = StrokeElement | ShapeElement | StampElement;
|
|
87
|
+
export type WhiteboardOp =
|
|
88
|
+
/** A new element - for a stroke, its first points; the rest follow in `points` ops. */
|
|
89
|
+
{
|
|
90
|
+
op: "add";
|
|
91
|
+
element: BoardElement;
|
|
92
|
+
}
|
|
93
|
+
/** More points of the sender's own stroke `id`, written from point index `start` on (so a repeat is harmless). */
|
|
94
|
+
| {
|
|
95
|
+
op: "points";
|
|
96
|
+
id: string;
|
|
97
|
+
start: number;
|
|
98
|
+
points: number[];
|
|
99
|
+
} | {
|
|
100
|
+
op: "remove";
|
|
101
|
+
ids: string[];
|
|
102
|
+
}
|
|
103
|
+
/** The board was cleared, now at `epoch`. */
|
|
104
|
+
| {
|
|
105
|
+
op: "clear";
|
|
106
|
+
epoch: number;
|
|
107
|
+
};
|
|
108
|
+
/** One chat message as its author sends it. */
|
|
109
|
+
export interface ChatPayload {
|
|
110
|
+
id: string;
|
|
111
|
+
text: string;
|
|
112
|
+
/** The author's clock, ms since the epoch - only for display, never for ordering. */
|
|
113
|
+
sentAt: number;
|
|
114
|
+
}
|
|
115
|
+
/** One chat message as a catch-up carries it: who sent it travels inside, since the envelope names the catch-up's sender. */
|
|
116
|
+
export interface ChatHistoryEntry extends ChatPayload {
|
|
117
|
+
peer: string;
|
|
118
|
+
name: string;
|
|
119
|
+
}
|
|
120
|
+
/** One part of the catch-up an existing participant sends a newcomer - see `CollabSession`. */
|
|
121
|
+
export interface SyncPayload {
|
|
122
|
+
part: number;
|
|
123
|
+
of: number;
|
|
124
|
+
epoch: number;
|
|
125
|
+
chat: ChatHistoryEntry[];
|
|
126
|
+
ops: WhiteboardOp[];
|
|
127
|
+
}
|
|
128
|
+
/** `color` as a lowercase `#rrggbb`, or `undefined` when it is not one. */
|
|
129
|
+
export declare function normalizeColor(color: unknown): string | undefined;
|
|
130
|
+
/** Whether `id` is one `peer` may author - see this module's doc comment. */
|
|
131
|
+
export declare function isOwnId(id: string, peer: string): boolean;
|
|
132
|
+
/** A received whiteboard op, validated and copied, or `undefined` to drop it. */
|
|
133
|
+
export declare function parseWhiteboardOp(value: unknown): WhiteboardOp | undefined;
|
|
134
|
+
/** A received chat message, validated and copied, or `undefined` to drop it. Blank text is dropped too. */
|
|
135
|
+
export declare function parseChat(value: unknown): ChatPayload | undefined;
|
|
136
|
+
/** A received catch-up part, validated and copied; entries that fail validation are left out rather than dropping
|
|
137
|
+
* the whole part, and `undefined` only when the part itself is malformed. */
|
|
138
|
+
export declare function parseSync(value: unknown): SyncPayload | undefined;
|
|
139
|
+
/** What a participant's pointer on the board shows: the pen, or the stencil, stamp or eraser, they have picked. */
|
|
140
|
+
export declare const CURSOR_TOOLS: readonly ["ballpoint", "sharpie", "marker", "crayon", "pencil", "stencil", "stamp", "eraser"];
|
|
141
|
+
export type CursorTool = (typeof CURSOR_TOOLS)[number];
|
|
142
|
+
/** Where a participant's pointer is on the board, and as what - or that it has left the board. Never stored, never
|
|
143
|
+
* part of a catch-up: a pointer is only ever where it was a moment ago. */
|
|
144
|
+
export type CursorPayload = {
|
|
145
|
+
hidden: true;
|
|
146
|
+
} | {
|
|
147
|
+
x: number;
|
|
148
|
+
y: number;
|
|
149
|
+
tool: CursorTool;
|
|
150
|
+
color: string;
|
|
151
|
+
stamp?: Stamp;
|
|
152
|
+
};
|
|
153
|
+
/** A received pointer update, validated and copied, or `undefined` to drop it. */
|
|
154
|
+
export declare function parseCursor(value: unknown): CursorPayload | undefined;
|
|
155
|
+
/** `value` rounded and kept within `[0, max]` - every coordinate this participant sends is, so no receiver's
|
|
156
|
+
* validation (which allows some slack beyond the board) ever drops it. */
|
|
157
|
+
export declare function clampCoordinate(value: number, max: number): number;
|
|
@@ -0,0 +1,222 @@
|
|
|
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
|
+
export const BOARD_WIDTH = 1600;
|
|
29
|
+
export const BOARD_HEIGHT = 900;
|
|
30
|
+
/** The freehand pens, each with its own look - see `whiteboardRender.ts`. */
|
|
31
|
+
export const PEN_TYPES = ["ballpoint", "sharpie", "marker", "crayon", "pencil"];
|
|
32
|
+
/** What the stencil draws: dragged out from one corner (or end) to the other. */
|
|
33
|
+
export const STENCIL_SHAPES = ["line", "arrow", "rect", "ellipse", "triangle", "star"];
|
|
34
|
+
/** The stamps a participant can place. A received stamp outside this list is ignored, so an arbitrary string from
|
|
35
|
+
* another participant is never drawn - the same rule as the call's reactions (`REACTION_EMOJIS`). */
|
|
36
|
+
export const STAMPS = ["⭐", "❤️", "✅", "❌", "❓", "❗", "💡", "👍", "👎", "😀", "🎉", "🔥", "➡️", "⬅️", "⬆️", "⬇️"];
|
|
37
|
+
/** The pen/line sizes, smallest first - an element's `size` is one of these. */
|
|
38
|
+
export const SIZES = [2, 4, 8, 14, 24];
|
|
39
|
+
export const STAMP_SIZES = [32, 56, 96, 160];
|
|
40
|
+
/** The longest chat message, in characters. */
|
|
41
|
+
export const MAX_CHAT_LENGTH = 4000;
|
|
42
|
+
/** The most points one stroke may hold - about a minute of continuous drawing. */
|
|
43
|
+
export const MAX_STROKE_POINTS = 8000;
|
|
44
|
+
/** The most points one `points` op (or the first batch of an `add`) may carry. */
|
|
45
|
+
export const MAX_POINTS_PER_OP = 2000;
|
|
46
|
+
/** The most elements a board holds; drawing past it is refused until something is erased. */
|
|
47
|
+
export const MAX_BOARD_ELEMENTS = 5000;
|
|
48
|
+
/** The most chat messages kept; older ones are dropped. */
|
|
49
|
+
export const MAX_CHAT_HISTORY = 500;
|
|
50
|
+
/** The most ids one `remove` op may name. */
|
|
51
|
+
export const MAX_REMOVE_IDS = 500;
|
|
52
|
+
/** The longest element or chat id. */
|
|
53
|
+
export const MAX_ID_LENGTH = 200;
|
|
54
|
+
/** The longest display name a catch-up may carry for a chat message. */
|
|
55
|
+
export const MAX_NAME_LENGTH = 200;
|
|
56
|
+
const COLOR_PATTERN = /^#[0-9a-f]{6}$/;
|
|
57
|
+
function isRecord(value) {
|
|
58
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
function isId(value) {
|
|
61
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_ID_LENGTH;
|
|
62
|
+
}
|
|
63
|
+
function isCoordinate(value, max) {
|
|
64
|
+
return Number.isInteger(value) && value >= -max && value <= 2 * max;
|
|
65
|
+
}
|
|
66
|
+
function isEpoch(value) {
|
|
67
|
+
return Number.isInteger(value) && value >= 0;
|
|
68
|
+
}
|
|
69
|
+
function isPoints(value, max) {
|
|
70
|
+
if (!Array.isArray(value) || value.length % 2 !== 0 || value.length / 2 > max) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
for (let i = 0; i < value.length; i += 2) {
|
|
74
|
+
if (!isCoordinate(value[i], BOARD_WIDTH) || !isCoordinate(value[i + 1], BOARD_HEIGHT)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
/** `color` as a lowercase `#rrggbb`, or `undefined` when it is not one. */
|
|
81
|
+
export function normalizeColor(color) {
|
|
82
|
+
if (typeof color !== "string") {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const lower = color.toLowerCase();
|
|
86
|
+
return COLOR_PATTERN.test(lower) ? lower : undefined;
|
|
87
|
+
}
|
|
88
|
+
/** Whether `id` is one `peer` may author - see this module's doc comment. */
|
|
89
|
+
export function isOwnId(id, peer) {
|
|
90
|
+
return id.startsWith(`${peer}:`);
|
|
91
|
+
}
|
|
92
|
+
function parseElement(value) {
|
|
93
|
+
if (!isRecord(value) || !isId(value.id) || !isEpoch(value.epoch)) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const { id, epoch } = value;
|
|
97
|
+
switch (value.kind) {
|
|
98
|
+
case "stroke": {
|
|
99
|
+
const color = normalizeColor(value.color);
|
|
100
|
+
if (!PEN_TYPES.includes(value.pen) || !color || !SIZES.includes(value.size) || !isPoints(value.points, MAX_POINTS_PER_OP) || value.points.length === 0) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
return { kind: "stroke", id, epoch, pen: value.pen, color, size: value.size, points: [...value.points] };
|
|
104
|
+
}
|
|
105
|
+
case "shape": {
|
|
106
|
+
const color = normalizeColor(value.color);
|
|
107
|
+
if (!STENCIL_SHAPES.includes(value.shape) ||
|
|
108
|
+
!color ||
|
|
109
|
+
!SIZES.includes(value.size) ||
|
|
110
|
+
!isCoordinate(value.x1, BOARD_WIDTH) ||
|
|
111
|
+
!isCoordinate(value.y1, BOARD_HEIGHT) ||
|
|
112
|
+
!isCoordinate(value.x2, BOARD_WIDTH) ||
|
|
113
|
+
!isCoordinate(value.y2, BOARD_HEIGHT)) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
const { x1, y1, x2, y2 } = value;
|
|
117
|
+
return { kind: "shape", id, epoch, shape: value.shape, color, size: value.size, x1, y1, x2, y2 };
|
|
118
|
+
}
|
|
119
|
+
case "stamp": {
|
|
120
|
+
if (!STAMPS.includes(value.stamp) || !STAMP_SIZES.includes(value.size) || !isCoordinate(value.x, BOARD_WIDTH) || !isCoordinate(value.y, BOARD_HEIGHT)) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
return { kind: "stamp", id, epoch, stamp: value.stamp, size: value.size, x: value.x, y: value.y };
|
|
124
|
+
}
|
|
125
|
+
default:
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** A received whiteboard op, validated and copied, or `undefined` to drop it. */
|
|
130
|
+
export function parseWhiteboardOp(value) {
|
|
131
|
+
if (!isRecord(value)) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
switch (value.op) {
|
|
135
|
+
case "add": {
|
|
136
|
+
const element = parseElement(value.element);
|
|
137
|
+
return element ? { op: "add", element } : undefined;
|
|
138
|
+
}
|
|
139
|
+
case "points":
|
|
140
|
+
if (!isId(value.id) || !Number.isInteger(value.start) || value.start < 0 || value.start >= MAX_STROKE_POINTS) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
if (!isPoints(value.points, MAX_POINTS_PER_OP) || value.points.length === 0) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
return { op: "points", id: value.id, start: value.start, points: [...value.points] };
|
|
147
|
+
case "remove":
|
|
148
|
+
if (!Array.isArray(value.ids) || value.ids.length === 0 || value.ids.length > MAX_REMOVE_IDS || !value.ids.every(isId)) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
return { op: "remove", ids: [...(value.ids)] };
|
|
152
|
+
case "clear":
|
|
153
|
+
return isEpoch(value.epoch) ? { op: "clear", epoch: value.epoch } : undefined;
|
|
154
|
+
default:
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** A received chat message, validated and copied, or `undefined` to drop it. Blank text is dropped too. */
|
|
159
|
+
export function parseChat(value) {
|
|
160
|
+
if (!isRecord(value) || !isId(value.id) || typeof value.text !== "string" || typeof value.sentAt !== "number" || !Number.isFinite(value.sentAt)) {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
if (value.text.length > MAX_CHAT_LENGTH || !value.text.trim()) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
return { id: value.id, text: value.text, sentAt: value.sentAt };
|
|
167
|
+
}
|
|
168
|
+
/** A received catch-up part, validated and copied; entries that fail validation are left out rather than dropping
|
|
169
|
+
* the whole part, and `undefined` only when the part itself is malformed. */
|
|
170
|
+
export function parseSync(value) {
|
|
171
|
+
if (!isRecord(value) || !Number.isInteger(value.part) || !Number.isInteger(value.of) || !isEpoch(value.epoch)) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
const part = value.part;
|
|
175
|
+
const of = value.of;
|
|
176
|
+
if (of < 1 || part < 0 || part >= of || !Array.isArray(value.chat) || !Array.isArray(value.ops)) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
const chat = [];
|
|
180
|
+
for (const entry of value.chat) {
|
|
181
|
+
const parsed = parseChat(entry);
|
|
182
|
+
if (parsed && isRecord(entry) && isId(entry.peer) && typeof entry.name === "string" && entry.name.length <= MAX_NAME_LENGTH) {
|
|
183
|
+
chat.push({ ...parsed, peer: entry.peer, name: entry.name });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const ops = [];
|
|
187
|
+
for (const op of value.ops) {
|
|
188
|
+
const parsed = parseWhiteboardOp(op);
|
|
189
|
+
if (parsed) {
|
|
190
|
+
ops.push(parsed);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { part, of, epoch: value.epoch, chat, ops };
|
|
194
|
+
}
|
|
195
|
+
/** What a participant's pointer on the board shows: the pen, or the stencil, stamp or eraser, they have picked. */
|
|
196
|
+
export const CURSOR_TOOLS = [...PEN_TYPES, "stencil", "stamp", "eraser"];
|
|
197
|
+
/** A received pointer update, validated and copied, or `undefined` to drop it. */
|
|
198
|
+
export function parseCursor(value) {
|
|
199
|
+
if (!isRecord(value)) {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
if (value.hidden === true) {
|
|
203
|
+
return { hidden: true };
|
|
204
|
+
}
|
|
205
|
+
const color = normalizeColor(value.color);
|
|
206
|
+
if (!CURSOR_TOOLS.includes(value.tool) || !color || !isCoordinate(value.x, BOARD_WIDTH) || !isCoordinate(value.y, BOARD_HEIGHT)) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
if (value.stamp !== undefined && !STAMPS.includes(value.stamp)) {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
const cursor = { x: value.x, y: value.y, tool: value.tool, color };
|
|
213
|
+
if (value.stamp !== undefined) {
|
|
214
|
+
cursor.stamp = value.stamp;
|
|
215
|
+
}
|
|
216
|
+
return cursor;
|
|
217
|
+
}
|
|
218
|
+
/** `value` rounded and kept within `[0, max]` - every coordinate this participant sends is, so no receiver's
|
|
219
|
+
* validation (which allows some slack beyond the board) ever drops it. */
|
|
220
|
+
export function clampCoordinate(value, max) {
|
|
221
|
+
return Math.min(max, Math.max(0, Math.round(Number.isFinite(value) ? value : 0)));
|
|
222
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draws the whiteboard's elements on a 2D canvas, in board units (`protocol.ts`'s `BOARD_WIDTH` x `BOARD_HEIGHT`)
|
|
3
|
+
* scaled to the canvas. Each pen has its own look:
|
|
4
|
+
*
|
|
5
|
+
* - **ballpoint** - a thin, even, opaque line.
|
|
6
|
+
* - **sharpie** - a bolder opaque line with round ends.
|
|
7
|
+
* - **marker** - a broad, square-tipped, translucent stroke laid down in `multiply`, so it highlights what is under
|
|
8
|
+
* it instead of covering it, and doesn't darken where it overlaps itself (the stroke is one path).
|
|
9
|
+
* - **crayon** - a waxy translucent base speckled with grain, like wax catching on paper.
|
|
10
|
+
* - **pencil** - a coloring pencil: several fine, slightly offset, faint passes that build up a grainy line.
|
|
11
|
+
*
|
|
12
|
+
* The grain of a crayon or pencil comes from a random generator seeded by the element's id, so every participant's
|
|
13
|
+
* board draws the very same texture for the same stroke - it is not re-randomized per screen or per redraw.
|
|
14
|
+
*/
|
|
15
|
+
import { type BoardElement } from "./protocol.js";
|
|
16
|
+
/** The subset of `CanvasRenderingContext2D` the renderer uses, so a test can record calls on a plain object. */
|
|
17
|
+
export type BoardContext = Pick<CanvasRenderingContext2D, "save" | "restore" | "setTransform" | "clearRect" | "fillRect" | "beginPath" | "closePath" | "moveTo" | "lineTo" | "quadraticCurveTo" | "ellipse" | "stroke" | "fill" | "fillText" | "lineWidth" | "lineCap" | "lineJoin" | "strokeStyle" | "fillStyle" | "globalAlpha" | "globalCompositeOperation" | "font" | "textAlign" | "textBaseline">;
|
|
18
|
+
/** The board's paper color. */
|
|
19
|
+
export declare const BOARD_BACKGROUND = "#ffffff";
|
|
20
|
+
/** The font stack stamps are drawn in - each platform's color emoji font first. */
|
|
21
|
+
export declare const STAMP_FONT_FAMILY = "\"Apple Color Emoji\", \"Segoe UI Emoji\", \"Noto Color Emoji\", sans-serif";
|
|
22
|
+
/** A small, fast, seedable PRNG (mulberry32) - returns numbers in [0, 1). */
|
|
23
|
+
export declare function seededRandom(seed: string): () => number;
|
|
24
|
+
/** Clears the canvas to paper and draws `elements` in order, `scale` canvas pixels per board unit. */
|
|
25
|
+
export declare function renderBoard(ctx: BoardContext, elements: readonly BoardElement[], scale: number): void;
|
|
26
|
+
export declare function drawElement(ctx: BoardContext, element: BoardElement): void;
|
|
27
|
+
/** The ten corners of a five-pointed star filling the given box, starting at its top point. */
|
|
28
|
+
export declare function starPoints(left: number, top: number, width: number, height: number): [number, number][];
|
|
29
|
+
/** Whether the eraser at (`x`, `y`) with `radius`, in board units, touches `element`. A stroke or line counts its own
|
|
30
|
+
* width; a closed shape is touched on its outline's bounding box, a stamp anywhere on its square. */
|
|
31
|
+
export declare function hitTest(element: BoardElement, x: number, y: number, radius: number): boolean;
|