@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,82 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ /**
7
+ * The whiteboard's custom color picker: a hue/saturation wheel (hue around it, saturation out from its center) and a
8
+ * brightness slider below it, for any color the swatches don't offer. Dragging on the wheel, or using the arrow keys
9
+ * on it (left/right turn the hue, up/down change the saturation), picks a color; `onChange` gets every pick as
10
+ * `#rrggbb`.
11
+ */
12
+ import { useEffect, useRef, useState } from "react";
13
+ import { hexToHsv, hsvToHex, hsvToRgb, wheelPoint } from "../shared/collab/color.js";
14
+ /** How far one arrow-key press turns the hue (degrees) or moves the saturation. */
15
+ const HUE_STEP = 5;
16
+ const SATURATION_STEP = 0.05;
17
+ export default function ColorWheel({ color, onChange, size = 168 }) {
18
+ const canvasRef = useRef(null);
19
+ const [hsv, setHsv] = useState(() => hexToHsv(color));
20
+ const radius = size / 2;
21
+ // Follows a color chosen elsewhere (a swatch) - unless it is the one this wheel just produced, which would
22
+ // otherwise snap the hue of a gray (where hue is meaningless) back to red.
23
+ useEffect(() => {
24
+ setHsv((prev) => (hsvToHex(prev) === color ? prev : hexToHsv(color)));
25
+ }, [color]);
26
+ useEffect(() => {
27
+ const ctx = canvasRef.current?.getContext("2d");
28
+ if (!ctx) {
29
+ return;
30
+ }
31
+ const image = ctx.createImageData(size, size);
32
+ for (let y = 0; y < size; y++) {
33
+ for (let x = 0; x < size; x++) {
34
+ const offset = (y * size + x) * 4;
35
+ if (Math.hypot(x - radius, y - radius) > radius) {
36
+ continue;
37
+ }
38
+ const { h, s } = wheelPoint(x, y, radius);
39
+ const [r, g, b] = hsvToRgb({ h, s, v: hsv.v });
40
+ image.data[offset] = r;
41
+ image.data[offset + 1] = g;
42
+ image.data[offset + 2] = b;
43
+ image.data[offset + 3] = 255;
44
+ }
45
+ }
46
+ ctx.putImageData(image, 0, 0);
47
+ }, [hsv.v, size, radius]);
48
+ function pick(next) {
49
+ setHsv(next);
50
+ onChange(hsvToHex(next));
51
+ }
52
+ function pickAt(event) {
53
+ const rect = event.currentTarget.getBoundingClientRect();
54
+ const scale = rect.width > 0 ? size / rect.width : 1;
55
+ const { h, s } = wheelPoint((event.clientX - rect.left) * scale, (event.clientY - rect.top) * scale, radius);
56
+ pick({ ...hsv, h, s });
57
+ }
58
+ function handleKeyDown(event) {
59
+ const moves = {
60
+ ArrowLeft: { h: (hsv.h - HUE_STEP + 360) % 360 },
61
+ ArrowRight: { h: (hsv.h + HUE_STEP) % 360 },
62
+ ArrowUp: { s: Math.min(1, hsv.s + SATURATION_STEP) },
63
+ ArrowDown: { s: Math.max(0, hsv.s - SATURATION_STEP) },
64
+ };
65
+ const move = moves[event.key];
66
+ if (move) {
67
+ event.preventDefault();
68
+ pick({ ...hsv, ...move });
69
+ }
70
+ }
71
+ const angle = (hsv.h * Math.PI) / 180;
72
+ const markerX = radius + Math.cos(angle) * hsv.s * radius;
73
+ const markerY = radius + Math.sin(angle) * hsv.s * radius;
74
+ return (_jsxs("div", { className: "flex flex-col items-center gap-3", "data-testid": "color-wheel", children: [_jsxs("div", { className: "relative", style: { width: size, height: size }, children: [_jsx("canvas", { ref: canvasRef, width: size, height: size, tabIndex: 0, role: "slider", "aria-label": "Hue and saturation", "aria-valuemin": 0, "aria-valuemax": 359, "aria-valuenow": Math.round(hsv.h), "aria-valuetext": `Hue ${Math.round(hsv.h)} degrees, saturation ${Math.round(hsv.s * 100)}%`, className: "rounded-full cursor-crosshair touch-none focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onPointerDown: (event) => {
75
+ event.currentTarget.setPointerCapture?.(event.pointerId);
76
+ pickAt(event);
77
+ }, onPointerMove: (event) => {
78
+ if (event.buttons & 1) {
79
+ pickAt(event);
80
+ }
81
+ }, onKeyDown: handleKeyDown }), _jsx("span", { className: "pointer-events-none absolute w-4 h-4 -ml-2 -mt-2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.5)]", style: { left: markerX, top: markerY, backgroundColor: hsvToHex(hsv) }, "aria-hidden": "true" })] }), _jsxs("label", { className: "w-full flex items-center gap-2 text-xs text-white/70", children: [_jsx("span", { className: "shrink-0", children: "Brightness" }), _jsx("input", { type: "range", min: 0, max: 100, value: Math.round(hsv.v * 100), "aria-label": "Brightness", className: "flex-1", style: { accentColor: hsvToHex({ ...hsv, v: Math.max(hsv.v, 0.3) }) }, onChange: (event) => pick({ ...hsv, v: Number(event.target.value) / 100 }) })] })] }));
82
+ }
@@ -39,7 +39,8 @@ export interface ParticipantTileProps {
39
39
  status?: string;
40
40
  /** Highlights this tile as the current presenter/focus. */
41
41
  isFocused?: boolean;
42
- /** Fits the whole picture inside the tile instead of filling it - a shared screen must not be cropped. */
42
+ /** Marks this tile as showing a screen share rather than a camera - a screen share is never mirrored
43
+ * (regardless of `mirrored`/`isLocal`), since mirroring someone else's shared content would be disorienting. */
43
44
  contain?: boolean;
44
45
  /** Whether the local tile is shown mirrored, like a real mirror - ignored for a remote tile, which is never
45
46
  * mirrored. Default `true`; see this module's doc comment. */
@@ -82,7 +82,7 @@ export default function ParticipantTile({ name, stream, isLocal, cameraOff, micM
82
82
  document.removeEventListener("keydown", onKeyDown);
83
83
  };
84
84
  }, [hideMenuOpen]);
85
- return (_jsxs("div", { className: `relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${isFocused ? "ring-2 ring-[#8ab4f8]" : ""} ${className ?? ""}`, onClick: onClick, role: onClick ? "button" : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined, children: [showVideo ? (_jsxs(_Fragment, { children: [_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: `w-full h-full ${contain ? "object-contain bg-black" : "object-cover"} ${isLocal && !contain && mirrored ? "[transform:scaleX(-1)]" : ""}` }), status && _jsx(StatusPill, { status: status, className: "absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" })] })) : (_jsxs("div", { className: "flex flex-col items-center gap-2 max-w-full px-1", children: [_jsx("div", { className: "w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold", "aria-hidden": "true", children: initialOf(name) }), status && _jsx(StatusPill, { status: status })] })), handRaised && (_jsx("span", { className: "absolute top-2 left-2 flex items-center justify-center w-8 h-8 rounded-full bg-[#a8c7fa] text-base", role: "img", "aria-label": "Hand raised", children: "\u270B" })), (micMuted || onHideSelf) && (_jsxs("div", { className: "absolute top-2 right-2 flex items-center gap-1", children: [micMuted && (_jsx("span", { className: "flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white", role: "img", "aria-label": "Muted", children: _jsx("span", { className: "w-4 h-4 [&>svg]:w-4 [&>svg]:h-4", children: _jsx(MicOffIcon, {}) }) })), onHideSelf && (_jsxs("div", { className: "relative", ref: hideMenuRef, children: [_jsx("button", { type: "button", className: "flex items-center justify-center w-7 h-7 rounded-full bg-black/60 hover:bg-black/80 text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": "Local video options", "aria-haspopup": "menu", "aria-expanded": hideMenuOpen, onClick: () => setHideMenuOpen((prev) => !prev), children: _jsx("span", { className: "w-4 h-4 [&>svg]:w-4 [&>svg]:h-4", children: _jsx(OverflowIcon, {}) }) }), hideMenuOpen && (_jsx("div", { role: "menu", "aria-label": "Local video options", className: "absolute top-full right-0 mt-1 w-32 rounded-xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm", children: _jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-1.5 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
85
+ return (_jsxs("div", { className: `relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${isFocused ? "ring-2 ring-[#8ab4f8]" : ""} ${className ?? ""}`, onClick: onClick, role: onClick ? "button" : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined, children: [showVideo ? (_jsxs(_Fragment, { children: [_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: `w-full h-full object-contain bg-black ${isLocal && !contain && mirrored ? "[transform:scaleX(-1)]" : ""}` }), status && _jsx(StatusPill, { status: status, className: "absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" })] })) : (_jsxs("div", { className: "flex flex-col items-center gap-2 max-w-full px-1", children: [_jsx("div", { className: "w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold", "aria-hidden": "true", children: initialOf(name) }), status && _jsx(StatusPill, { status: status })] })), handRaised && (_jsx("span", { className: "absolute top-2 left-2 flex items-center justify-center w-8 h-8 rounded-full bg-[#a8c7fa] text-base", role: "img", "aria-label": "Hand raised", children: "\u270B" })), (micMuted || onHideSelf) && (_jsxs("div", { className: "absolute top-2 right-2 flex items-center gap-1", children: [micMuted && (_jsx("span", { className: "flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white", role: "img", "aria-label": "Muted", children: _jsx("span", { className: "w-4 h-4 [&>svg]:w-4 [&>svg]:h-4", children: _jsx(MicOffIcon, {}) }) })), onHideSelf && (_jsxs("div", { className: "relative", ref: hideMenuRef, children: [_jsx("button", { type: "button", className: "flex items-center justify-center w-7 h-7 rounded-full bg-black/60 hover:bg-black/80 text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": "Local video options", "aria-haspopup": "menu", "aria-expanded": hideMenuOpen, onClick: () => setHideMenuOpen((prev) => !prev), children: _jsx("span", { className: "w-4 h-4 [&>svg]:w-4 [&>svg]:h-4", children: _jsx(OverflowIcon, {}) }) }), hideMenuOpen && (_jsx("div", { role: "menu", "aria-label": "Local video options", className: "absolute top-full right-0 mt-1 w-32 rounded-xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm", children: _jsx("button", { type: "button", role: "menuitem", className: "w-full px-3 py-1.5 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
86
86
  setHideMenuOpen(false);
87
87
  onHideSelf();
88
88
  }, children: "Hide self" }) }))] }))] })), badge && (_jsx("span", { className: `absolute bottom-2 right-2 px-2 py-0.5 rounded text-xs ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/60 text-white"}`, title: badge.title, "data-testid": "transport-badge", children: badge.label })), _jsxs("div", { className: "absolute bottom-2 left-2 max-w-[calc(100%-1rem)] truncate px-2 py-0.5 rounded bg-black/60 text-white text-sm", children: [name, isLocal ? " (you)" : ""] })] }));
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The shared whiteboard, shown in place of the video stage while a participant has it open (`_CallView.tsx`). Every
3
+ * participant draws on the same board - `CollabSession` (`apps/shared/collab/CollabSession.ts`) carries the drawing
4
+ * to everyone and `whiteboardRender.ts` draws it - but each one opens and closes their own view of it.
5
+ *
6
+ * ## Tools
7
+ *
8
+ * - **Pens** - ballpoint, sharpie, marker, crayon and coloring pencil (`PEN_TYPES`), each drawn its own way.
9
+ * - **Stencil** - drags out a line, arrow, rectangle, ellipse, triangle or star, previewed while dragging.
10
+ * - **Stamps** - places one of `STAMPS` where clicked.
11
+ * - **Eraser** - erases whatever it passes over, anyone's drawing.
12
+ *
13
+ * Plus a row of sizes, color swatches and a color wheel (`_ColorWheel.tsx`) for any other color, undo (also
14
+ * Ctrl/Cmd+Z: takes back this participant's own last drawing) and clear (for everyone, after a confirmation).
15
+ *
16
+ * ## Everyone's pointers
17
+ *
18
+ * While this participant's pointer is over the board it is shared (`CollabSession.moveCursor()`), as the tool they
19
+ * have picked in the color they have picked; everyone else's (`cursors`) is drawn over the canvas the same way, with
20
+ * their name beside it, so each can see who is pointing at - or drawing - what. They are plain elements over the
21
+ * canvas rather than part of the drawing, so a pointer moving never redraws the board.
22
+ *
23
+ * ## Talking stick
24
+ *
25
+ * While `canDraw` is off (talking-stick mode, and someone else holds the stick - see `_CallView.tsx`), drawing,
26
+ * undo and clear do nothing and a note names who may draw; picking a tool or color still works, ready for when the
27
+ * stick comes round. The pointer is still shared.
28
+ *
29
+ * ## The canvas
30
+ *
31
+ * The board is a fixed 16:9 space (`BOARD_WIDTH` x `BOARD_HEIGHT`) fitted into whatever room the stage has, so a
32
+ * stroke lands in the same place on every participant's screen whatever their window size. The canvas is backed at
33
+ * the display's pixel ratio so lines stay sharp, and redrawn at most once per animation frame whenever the board
34
+ * changes (`revision`).
35
+ */
36
+ import React from "react";
37
+ import type { CollabSession, RemoteCursor } from "../shared/collab/CollabSession.js";
38
+ import { type PenType, type StencilShape } from "../shared/collab/protocol.js";
39
+ export interface WhiteboardProps {
40
+ session: CollabSession;
41
+ /** `session.board.revision` - a new value redraws the board. */
42
+ revision: number;
43
+ /** Everyone else's pointers on the board - `CollabSession.cursors`. */
44
+ cursors?: readonly RemoteCursor[];
45
+ /** Whether this participant may draw now - see this module's doc comment on talking stick. Defaults to `true`. */
46
+ canDraw?: boolean;
47
+ /** Who may draw while `canDraw` is off, for the note saying so; absent while nobody holds the stick. */
48
+ drawLockedTo?: string;
49
+ onClose: () => void;
50
+ }
51
+ export declare const PEN_LABELS: Record<PenType, string>;
52
+ export declare const SHAPE_LABELS: Record<StencilShape, string>;
53
+ /** The color swatches, in display order. */
54
+ export declare const SWATCHES: readonly ["#202124", "#ffffff", "#9aa0a6", "#d93025", "#f29900", "#fbbc04", "#188038", "#1a73e8", "#9334e6", "#e52592", "#795548", "#00bcd4"];
55
+ /** Black or white, whichever reads better on `color` (`#rrggbb`). */
56
+ export declare function labelTextColor(color: string): string;
57
+ /** A participant's pointer: their tool's own shape in their color, its tip (or a stamp's center) at the point, with
58
+ * their name beside it. */
59
+ export declare function CursorMarker({ cursor, left, top }: {
60
+ cursor: Pick<RemoteCursor, "name" | "tool" | "color" | "stamp">;
61
+ left: number;
62
+ top: number;
63
+ }): React.JSX.Element;
64
+ export default function Whiteboard({ session, revision, cursors, canDraw, drawLockedTo, onClose }: WhiteboardProps): React.JSX.Element;
@@ -0,0 +1,312 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ /**
7
+ * The shared whiteboard, shown in place of the video stage while a participant has it open (`_CallView.tsx`). Every
8
+ * participant draws on the same board - `CollabSession` (`apps/shared/collab/CollabSession.ts`) carries the drawing
9
+ * to everyone and `whiteboardRender.ts` draws it - but each one opens and closes their own view of it.
10
+ *
11
+ * ## Tools
12
+ *
13
+ * - **Pens** - ballpoint, sharpie, marker, crayon and coloring pencil (`PEN_TYPES`), each drawn its own way.
14
+ * - **Stencil** - drags out a line, arrow, rectangle, ellipse, triangle or star, previewed while dragging.
15
+ * - **Stamps** - places one of `STAMPS` where clicked.
16
+ * - **Eraser** - erases whatever it passes over, anyone's drawing.
17
+ *
18
+ * Plus a row of sizes, color swatches and a color wheel (`_ColorWheel.tsx`) for any other color, undo (also
19
+ * Ctrl/Cmd+Z: takes back this participant's own last drawing) and clear (for everyone, after a confirmation).
20
+ *
21
+ * ## Everyone's pointers
22
+ *
23
+ * While this participant's pointer is over the board it is shared (`CollabSession.moveCursor()`), as the tool they
24
+ * have picked in the color they have picked; everyone else's (`cursors`) is drawn over the canvas the same way, with
25
+ * their name beside it, so each can see who is pointing at - or drawing - what. They are plain elements over the
26
+ * canvas rather than part of the drawing, so a pointer moving never redraws the board.
27
+ *
28
+ * ## Talking stick
29
+ *
30
+ * While `canDraw` is off (talking-stick mode, and someone else holds the stick - see `_CallView.tsx`), drawing,
31
+ * undo and clear do nothing and a note names who may draw; picking a tool or color still works, ready for when the
32
+ * stick comes round. The pointer is still shared.
33
+ *
34
+ * ## The canvas
35
+ *
36
+ * The board is a fixed 16:9 space (`BOARD_WIDTH` x `BOARD_HEIGHT`) fitted into whatever room the stage has, so a
37
+ * stroke lands in the same place on every participant's screen whatever their window size. The canvas is backed at
38
+ * the display's pixel ratio so lines stay sharp, and redrawn at most once per animation frame whenever the board
39
+ * changes (`revision`).
40
+ */
41
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
42
+ import { BOARD_HEIGHT, BOARD_WIDTH, PEN_TYPES, SIZES, STAMPS, STAMP_SIZES, STENCIL_SHAPES, } from "../shared/collab/protocol.js";
43
+ import { drawElement, hitTest, renderBoard } from "../shared/collab/whiteboardRender.js";
44
+ import ColorWheel from "./_ColorWheel.js";
45
+ import { ClearIcon, EraserIcon, UndoIcon } from "./_icons.js";
46
+ export const PEN_LABELS = {
47
+ ballpoint: "Ballpoint pen",
48
+ sharpie: "Sharpie",
49
+ marker: "Marker",
50
+ crayon: "Crayon",
51
+ pencil: "Coloring pencil",
52
+ };
53
+ export const SHAPE_LABELS = {
54
+ line: "Line",
55
+ arrow: "Arrow",
56
+ rect: "Rectangle",
57
+ ellipse: "Ellipse",
58
+ triangle: "Triangle",
59
+ star: "Star",
60
+ };
61
+ /** The color swatches, in display order. */
62
+ export const SWATCHES = ["#202124", "#ffffff", "#9aa0a6", "#d93025", "#f29900", "#fbbc04", "#188038", "#1a73e8", "#9334e6", "#e52592", "#795548", "#00bcd4"];
63
+ /** A freehand point closer than this (board units) to the previous one is skipped - it adds nothing visible. */
64
+ const MIN_POINT_DISTANCE = 2;
65
+ /** The eraser's reach around the pointer, in board units. */
66
+ const ERASER_RADIUS = 14;
67
+ /** A stencil drag shorter than this (board units) is a mis-click, not a shape. */
68
+ const MIN_SHAPE_SIZE = 4;
69
+ const TOOL_BUTTON = "w-9 h-9 shrink-0 flex items-center justify-center rounded-lg hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 aria-pressed:bg-[#a8c7fa] aria-pressed:text-[#062e6f]";
70
+ /** A short sample of each pen's line, for its toolbar button. */
71
+ function PenSample({ pen }) {
72
+ const look = {
73
+ ballpoint: { width: 1.5, opacity: 1, cap: "round" },
74
+ sharpie: { width: 3, opacity: 1, cap: "round" },
75
+ marker: { width: 6, opacity: 0.5, cap: "square" },
76
+ crayon: { width: 4, opacity: 0.8, dash: "2 1", cap: "round" },
77
+ pencil: { width: 1.5, opacity: 0.6, dash: "3 1", cap: "round" },
78
+ };
79
+ const { width, opacity, dash, cap } = look[pen];
80
+ return (_jsx("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M3 16c3-6 6-6 9 0s6 6 9 0", fill: "none", stroke: "currentColor", strokeWidth: width, strokeOpacity: opacity, strokeDasharray: dash, strokeLinecap: cap }) }));
81
+ }
82
+ /** Black or white, whichever reads better on `color` (`#rrggbb`). */
83
+ export function labelTextColor(color) {
84
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(color.slice(i, i + 2), 16));
85
+ return 0.299 * r + 0.587 * g + 0.114 * b > 150 ? "#000000" : "#ffffff";
86
+ }
87
+ /** A participant's pointer: their tool's own shape in their color, its tip (or a stamp's center) at the point, with
88
+ * their name beside it. */
89
+ export function CursorMarker({ cursor, left, top }) {
90
+ const { tool, color } = cursor;
91
+ let glyph;
92
+ if (tool === "stamp") {
93
+ glyph = _jsx("span", { className: "block -translate-x-1/2 -translate-y-1/2 text-2xl leading-none drop-shadow", children: cursor.stamp ?? "⭐" });
94
+ }
95
+ else if (tool === "eraser") {
96
+ glyph = (_jsx("svg", { viewBox: "0 0 24 24", className: "w-6 h-6 -translate-x-1/2 -translate-y-1/2", "aria-hidden": "true", children: _jsx("circle", { cx: "12", cy: "12", r: "9", fill: "#ffffff", fillOpacity: "0.6", stroke: color, strokeWidth: "2" }) }));
97
+ }
98
+ else if (tool === "stencil") {
99
+ glyph = (_jsxs("svg", { viewBox: "0 0 24 24", className: "w-6 h-6 -translate-x-1/2 -translate-y-1/2", fill: "none", stroke: color, strokeWidth: "2", "aria-hidden": "true", children: [_jsx("line", { x1: "12", y1: "2", x2: "12", y2: "22" }), _jsx("line", { x1: "2", y1: "12", x2: "22", y2: "12" }), _jsx("rect", { x: "15", y: "15", width: "7", height: "7" })] }));
100
+ }
101
+ else {
102
+ // A pen held at an angle, tip at the top-left corner: its body width and tip say which pen it is.
103
+ const body = {
104
+ ballpoint: { width: 4, tip: "M0 0l5 2-3 3z" },
105
+ sharpie: { width: 6, tip: "M0 0l7 3-4 4z" },
106
+ marker: { width: 8, tip: "M0 0h6l2 2v6H2L0 6z" },
107
+ crayon: { width: 6, tip: "M0 0l8 4-4 4z" },
108
+ pencil: { width: 5, tip: "M0 0l6 2-4 4z" },
109
+ };
110
+ const { width, tip } = body[tool];
111
+ glyph = (_jsxs("svg", { viewBox: "0 0 28 28", className: "w-7 h-7", "aria-hidden": "true", children: [_jsx("g", { transform: "rotate(45 0 0)", children: _jsx("rect", { x: "6", y: -width / 2, width: "20", height: width, rx: "1", fill: color, stroke: "#000000", strokeOpacity: "0.5", strokeWidth: "1" }) }), _jsx("path", { d: tip, fill: color, stroke: "#000000", strokeOpacity: "0.5", strokeWidth: "0.8" })] }));
112
+ }
113
+ return (_jsxs("div", { className: "pointer-events-none absolute transition-[left,top] duration-75 ease-linear", style: { left, top }, "data-testid": "remote-cursor", children: [glyph, _jsx("span", { className: "absolute left-4 top-4 max-w-40 truncate whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium shadow", style: { backgroundColor: color, color: labelTextColor(color) }, children: cursor.name })] }));
114
+ }
115
+ /** A small outline of each stencil shape, for its button. */
116
+ function ShapeSample({ shape }) {
117
+ const paths = {
118
+ line: _jsx("line", { x1: "4", y1: "20", x2: "20", y2: "4" }),
119
+ arrow: _jsx("path", { d: "M4 20L20 4m0 0h-7m7 0v7" }),
120
+ rect: _jsx("rect", { x: "4", y: "6", width: "16", height: "12" }),
121
+ ellipse: _jsx("ellipse", { cx: "12", cy: "12", rx: "9", ry: "6.5" }),
122
+ triangle: _jsx("path", { d: "M12 4l9 16H3z" }),
123
+ star: _jsx("path", { d: "M12 3l2.6 5.9 6.4.6-4.8 4.3 1.4 6.3L12 16.8 6.4 20.1l1.4-6.3L3 9.5l6.4-.6z" }),
124
+ };
125
+ return (_jsx("svg", { viewBox: "0 0 24 24", className: "w-5 h-5", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinejoin: "round", strokeLinecap: "round", "aria-hidden": "true", focusable: "false", children: paths[shape] }));
126
+ }
127
+ export default function Whiteboard({ session, revision, cursors = [], canDraw = true, drawLockedTo, onClose }) {
128
+ const [tool, setTool] = useState({ kind: "pen", pen: "ballpoint" });
129
+ const [shape, setShape] = useState("rect");
130
+ const [stamp, setStamp] = useState(STAMPS[0]);
131
+ const [color, setColor] = useState(SWATCHES[0]);
132
+ const [sizeIndex, setSizeIndex] = useState(1);
133
+ const [stampSizeIndex, setStampSizeIndex] = useState(1);
134
+ const [popover, setPopover] = useState(null);
135
+ const [confirmClear, setConfirmClear] = useState(false);
136
+ const [box, setBox] = useState({ width: 0, height: 0 });
137
+ const stageRef = useRef(null);
138
+ const canvasRef = useRef(null);
139
+ const drawingRef = useRef(null);
140
+ const previewRef = useRef(null);
141
+ const frameRef = useRef(undefined);
142
+ const size = SIZES[sizeIndex];
143
+ const stampSize = STAMP_SIZES[stampSizeIndex];
144
+ // The largest 16:9 box that fits the stage.
145
+ useLayoutEffect(() => {
146
+ const stage = stageRef.current;
147
+ const fit = () => {
148
+ const { width, height } = stage.getBoundingClientRect();
149
+ const fitted = Math.min(width, (height * BOARD_WIDTH) / BOARD_HEIGHT);
150
+ setBox({ width: Math.floor(fitted), height: Math.floor((fitted * BOARD_HEIGHT) / BOARD_WIDTH) });
151
+ };
152
+ fit();
153
+ if (typeof ResizeObserver === "undefined") {
154
+ return;
155
+ }
156
+ const observer = new ResizeObserver(fit);
157
+ observer.observe(stage);
158
+ return () => observer.disconnect();
159
+ }, []);
160
+ const draw = useCallback(() => {
161
+ frameRef.current = undefined;
162
+ const canvas = canvasRef.current;
163
+ const ctx = canvas?.getContext("2d");
164
+ if (!canvas || !ctx) {
165
+ return;
166
+ }
167
+ const scale = canvas.width / BOARD_WIDTH;
168
+ renderBoard(ctx, session.board.elements, scale);
169
+ if (previewRef.current) {
170
+ ctx.save();
171
+ ctx.setTransform(scale, 0, 0, scale, 0, 0);
172
+ drawElement(ctx, previewRef.current);
173
+ ctx.restore();
174
+ }
175
+ }, [session]);
176
+ const scheduleDraw = useCallback(() => {
177
+ if (frameRef.current === undefined) {
178
+ frameRef.current = requestAnimationFrame(draw);
179
+ }
180
+ }, [draw]);
181
+ const ratio = typeof window !== "undefined" && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
182
+ // A new size or pixel ratio resizes the canvas, which wipes it - so either redraws, as a change to the board does.
183
+ useEffect(() => {
184
+ scheduleDraw();
185
+ }, [revision, box, ratio, scheduleDraw]);
186
+ // This participant's pointer leaves everyone else's board with the board itself.
187
+ useEffect(() => () => session.hideCursor(), [session]);
188
+ useEffect(() => () => {
189
+ if (frameRef.current !== undefined) {
190
+ cancelAnimationFrame(frameRef.current);
191
+ }
192
+ }, []);
193
+ // Ctrl/Cmd+Z takes back this participant's own last drawing - unless they are typing somewhere (the chat).
194
+ useEffect(() => {
195
+ const onKeyDown = (event) => {
196
+ const target = event.target;
197
+ if (target?.closest?.("input, textarea, [contenteditable='true']")) {
198
+ return;
199
+ }
200
+ if ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === "z" && session.canDraw) {
201
+ event.preventDefault();
202
+ session.undo();
203
+ }
204
+ else if (event.key === "Escape") {
205
+ setPopover(null);
206
+ setConfirmClear(false);
207
+ }
208
+ };
209
+ document.addEventListener("keydown", onKeyDown);
210
+ return () => document.removeEventListener("keydown", onKeyDown);
211
+ }, [session]);
212
+ function toBoard(event) {
213
+ const rect = canvasRef.current.getBoundingClientRect();
214
+ const width = rect.width || BOARD_WIDTH;
215
+ const height = rect.height || BOARD_HEIGHT;
216
+ return { x: ((event.clientX - rect.left) / width) * BOARD_WIDTH, y: ((event.clientY - rect.top) / height) * BOARD_HEIGHT };
217
+ }
218
+ function erase(x, y) {
219
+ const hits = session.board.elements.filter((element) => hitTest(element, x, y, ERASER_RADIUS)).map((element) => element.id);
220
+ session.erase(hits);
221
+ }
222
+ function cursorTool() {
223
+ return tool.kind === "pen" ? tool.pen : tool.kind;
224
+ }
225
+ function shareCursor(x, y) {
226
+ session.moveCursor(x, y, cursorTool(), color, tool.kind === "stamp" ? stamp : undefined);
227
+ }
228
+ function handlePointerDown(event) {
229
+ if (event.button !== 0 || !canDraw) {
230
+ return;
231
+ }
232
+ setPopover(null);
233
+ event.currentTarget.setPointerCapture?.(event.pointerId);
234
+ const { x, y } = toBoard(event);
235
+ switch (tool.kind) {
236
+ case "pen":
237
+ drawingRef.current = { strokeId: session.beginStroke(tool.pen, color, size, x, y), lastX: x, lastY: y, startX: x, startY: y };
238
+ return;
239
+ case "stencil":
240
+ drawingRef.current = { lastX: x, lastY: y, startX: x, startY: y };
241
+ return;
242
+ case "stamp":
243
+ session.addStamp(stamp, stampSize, x, y);
244
+ return;
245
+ case "eraser":
246
+ drawingRef.current = { lastX: x, lastY: y, startX: x, startY: y };
247
+ erase(x, y);
248
+ return;
249
+ }
250
+ }
251
+ function handlePointerMove(event) {
252
+ const drawing = drawingRef.current;
253
+ if (!drawing) {
254
+ const { x, y } = toBoard(event);
255
+ shareCursor(x, y);
256
+ return;
257
+ }
258
+ const events = typeof event.nativeEvent.getCoalescedEvents === "function" ? event.nativeEvent.getCoalescedEvents() : [];
259
+ for (const sample of events.length > 0 ? events : [event]) {
260
+ const { x, y } = toBoard(sample);
261
+ if (tool.kind === "pen" && drawing.strokeId) {
262
+ if (Math.hypot(x - drawing.lastX, y - drawing.lastY) >= MIN_POINT_DISTANCE) {
263
+ session.extendStroke(drawing.strokeId, x, y);
264
+ drawing.lastX = x;
265
+ drawing.lastY = y;
266
+ }
267
+ }
268
+ else if (tool.kind === "eraser") {
269
+ erase(x, y);
270
+ }
271
+ else if (tool.kind === "stencil") {
272
+ previewRef.current = { kind: "shape", id: "preview", epoch: 0, shape, color, size, x1: drawing.startX, y1: drawing.startY, x2: x, y2: y };
273
+ drawing.lastX = x;
274
+ drawing.lastY = y;
275
+ scheduleDraw();
276
+ }
277
+ }
278
+ // Mid-stroke the stroke itself shows everyone where the pen is; anything else moves the pointer.
279
+ if (tool.kind !== "pen") {
280
+ const { x, y } = toBoard(event);
281
+ shareCursor(x, y);
282
+ }
283
+ }
284
+ function handlePointerUp() {
285
+ const drawing = drawingRef.current;
286
+ drawingRef.current = null;
287
+ if (!drawing) {
288
+ return;
289
+ }
290
+ if (tool.kind === "pen") {
291
+ session.endStroke();
292
+ }
293
+ else if (tool.kind === "stencil") {
294
+ previewRef.current = null;
295
+ if (Math.hypot(drawing.lastX - drawing.startX, drawing.lastY - drawing.startY) >= MIN_SHAPE_SIZE) {
296
+ session.addShape(shape, color, size, drawing.startX, drawing.startY, drawing.lastX, drawing.lastY);
297
+ }
298
+ scheduleDraw();
299
+ }
300
+ }
301
+ const cursor = tool.kind === "eraser" ? "cursor-cell" : tool.kind === "stamp" ? "cursor-copy" : "cursor-crosshair";
302
+ return (_jsxs("section", { "aria-label": "Whiteboard", className: "h-full flex flex-col gap-2", "data-testid": "whiteboard", children: [_jsxs("div", { role: "toolbar", "aria-label": "Whiteboard tools", className: "relative shrink-0 flex flex-wrap items-center gap-1 rounded-2xl bg-[#2b2d30] px-2 py-1.5 text-white", children: [PEN_TYPES.map((pen) => (_jsx("button", { type: "button", className: TOOL_BUTTON, "aria-label": PEN_LABELS[pen], title: PEN_LABELS[pen], "aria-pressed": tool.kind === "pen" && tool.pen === pen, onClick: () => setTool({ kind: "pen", pen }), children: _jsx(PenSample, { pen: pen }) }, pen))), _jsx("button", { type: "button", className: TOOL_BUTTON, "aria-label": "Stencil", title: "Stencil", "aria-pressed": tool.kind === "stencil", onClick: () => setTool({ kind: "stencil" }), children: _jsx(ShapeSample, { shape: shape }) }), _jsx("button", { type: "button", className: TOOL_BUTTON, "aria-label": "Stamp", title: "Stamp", "aria-pressed": tool.kind === "stamp", onClick: () => {
303
+ setTool({ kind: "stamp" });
304
+ setPopover((prev) => (prev === "stamps" ? null : "stamps"));
305
+ }, children: _jsx("span", { className: "text-lg leading-none", children: stamp }) }), _jsx("button", { type: "button", className: TOOL_BUTTON, "aria-label": "Eraser", title: "Eraser", "aria-pressed": tool.kind === "eraser", onClick: () => setTool({ kind: "eraser" }), children: _jsx(EraserIcon, {}) }), _jsx("span", { className: "mx-1 h-6 w-px bg-white/15", "aria-hidden": "true" }), tool.kind === "stencil" && (_jsx("div", { role: "group", "aria-label": "Stencil shape", className: "flex items-center gap-0.5", children: STENCIL_SHAPES.map((candidate) => (_jsx("button", { type: "button", className: TOOL_BUTTON, "aria-label": SHAPE_LABELS[candidate], title: SHAPE_LABELS[candidate], "aria-pressed": shape === candidate, onClick: () => setShape(candidate), children: _jsx(ShapeSample, { shape: candidate }) }, candidate))) })), (tool.kind === "pen" || tool.kind === "stencil") && (_jsx("div", { role: "group", "aria-label": "Size", className: "flex items-center gap-0.5", children: SIZES.map((candidate, index) => (_jsx("button", { type: "button", className: TOOL_BUTTON, "aria-label": `Size ${index + 1}`, "aria-pressed": sizeIndex === index, onClick: () => setSizeIndex(index), children: _jsx("span", { className: "rounded-full bg-current", style: { width: 3 + index * 3, height: 3 + index * 3 } }) }, candidate))) })), tool.kind === "stamp" && (_jsx("div", { role: "group", "aria-label": "Stamp size", className: "flex items-center gap-0.5", children: STAMP_SIZES.map((candidate, index) => (_jsx("button", { type: "button", className: TOOL_BUTTON, "aria-label": `Stamp size ${index + 1}`, "aria-pressed": stampSizeIndex === index, onClick: () => setStampSizeIndex(index), children: _jsx("span", { style: { fontSize: 9 + index * 4 }, children: stamp }) }, candidate))) })), tool.kind !== "eraser" && tool.kind !== "stamp" && (_jsxs(_Fragment, { children: [_jsx("span", { className: "mx-1 h-6 w-px bg-white/15", "aria-hidden": "true" }), _jsxs("div", { role: "radiogroup", "aria-label": "Color", className: "flex flex-wrap items-center gap-1", children: [SWATCHES.map((swatch) => (_jsx("button", { type: "button", role: "radio", "aria-checked": color === swatch, "aria-label": `Color ${swatch}`, className: `w-6 h-6 rounded-full border border-white/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${color === swatch ? "ring-2 ring-[#a8c7fa] ring-offset-2 ring-offset-[#2b2d30]" : ""}`, style: { backgroundColor: swatch }, onClick: () => setColor(swatch) }, swatch))), _jsx("button", { type: "button", "aria-label": "Custom color", "aria-expanded": popover === "wheel", title: "Custom color", className: `w-6 h-6 rounded-full p-[3px] bg-[conic-gradient(red,yellow,lime,cyan,blue,magenta,red)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 ${SWATCHES.includes(color) ? "" : "ring-2 ring-[#a8c7fa] ring-offset-2 ring-offset-[#2b2d30]"}`, onClick: () => setPopover((prev) => (prev === "wheel" ? null : "wheel")), children: _jsx("span", { className: "block w-full h-full rounded-full border border-black/30", style: { backgroundColor: color } }) })] })] })), _jsx("span", { className: "flex-1" }), _jsx("button", { type: "button", className: `${TOOL_BUTTON} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": "Undo", title: "Undo (Ctrl+Z)", disabled: !canDraw, onClick: () => session.undo(), children: _jsx(UndoIcon, {}) }), confirmClear ? (_jsxs("span", { className: "flex items-center gap-1 text-sm", role: "group", "aria-label": "Clear the whiteboard?", children: [_jsx("span", { children: "Clear for everyone?" }), _jsx("button", { type: "button", className: "px-2 py-1 rounded-lg bg-[#d93025] hover:bg-[#b3261e] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
306
+ session.clear();
307
+ setConfirmClear(false);
308
+ }, children: "Clear" }), _jsx("button", { type: "button", className: "px-2 py-1 rounded-lg hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => setConfirmClear(false), children: "Cancel" })] })) : (_jsx("button", { type: "button", className: `${TOOL_BUTTON} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": "Clear whiteboard", title: "Clear whiteboard", disabled: !canDraw, onClick: () => setConfirmClear(true), children: _jsx(ClearIcon, {}) })), _jsx("button", { type: "button", className: "ml-1 px-3 h-9 rounded-lg bg-[#3c4043] hover:bg-[#4b4f53] text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: onClose, children: "Back to video" }), popover === "wheel" && (_jsx("div", { role: "dialog", "aria-label": "Custom color", className: "absolute z-20 top-full mt-2 right-2 p-3 rounded-2xl bg-[#2b2d30] shadow-xl", children: _jsx(ColorWheel, { color: color, onChange: setColor }) })), popover === "stamps" && (_jsx("div", { role: "dialog", "aria-label": "Stamps", className: "absolute z-20 top-full mt-2 left-2 grid grid-cols-8 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: STAMPS.map((candidate) => (_jsx("button", { type: "button", "aria-label": `Stamp ${candidate}`, "aria-pressed": stamp === candidate, className: "w-10 h-10 rounded-lg text-2xl hover:bg-white/10 aria-pressed:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", onClick: () => {
309
+ setStamp(candidate);
310
+ setPopover(null);
311
+ }, children: candidate }, candidate))) }))] }), _jsxs("div", { ref: stageRef, className: "relative flex-1 min-h-0 flex items-center justify-center", children: [_jsx("canvas", { ref: canvasRef, width: Math.max(1, Math.round(box.width * ratio)), height: Math.max(1, Math.round(box.height * ratio)), style: { width: box.width, height: box.height }, className: `rounded-lg shadow-lg touch-none bg-white ${canDraw ? cursor : "cursor-not-allowed"}`, "aria-label": "Whiteboard canvas", role: "img", "data-testid": "whiteboard-canvas", onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerUp, onPointerLeave: () => session.hideCursor() }), _jsx("div", { className: "pointer-events-none absolute overflow-hidden", style: { width: box.width, height: box.height }, "aria-hidden": "true", children: cursors.map((remote) => (_jsx(CursorMarker, { cursor: remote, left: (remote.x / BOARD_WIDTH) * box.width, top: (remote.y / BOARD_HEIGHT) * box.height }, remote.peer))) }), !canDraw && (_jsxs("p", { role: "status", className: "absolute top-2 left-1/2 -translate-x-1/2 rounded-full bg-[#a8c7fa] px-3 py-1 text-sm font-medium text-[#062e6f] shadow", children: ["\uD83C\uDF99\uFE0F ", drawLockedTo ? `Only ${drawLockedTo} can draw while the talking stick is on` : "Nobody can draw until the host hands out the talking stick"] }))] })] }));
312
+ }
@@ -25,3 +25,15 @@ export declare const OverflowIcon: () => React.JSX.Element;
25
25
  * strokes rather than a filled Material path, same "simple primitives, not a recalled path" reasoning as
26
26
  * `BatonIcon`. */
27
27
  export declare const RemoveIcon: () => React.JSX.Element;
28
+ /** A speech bubble - the in-call chat. Drawn from primitives, same reasoning as `BatonIcon`. */
29
+ export declare const ChatIcon: () => React.JSX.Element;
30
+ /** An easel board with a squiggle on it - the shared whiteboard. */
31
+ export declare const WhiteboardIcon: () => React.JSX.Element;
32
+ /** The letters "GIF" in a rounded box - the chat's GIF picker. */
33
+ export declare const GifIcon: () => React.JSX.Element;
34
+ export declare const SendIcon: () => React.JSX.Element;
35
+ export declare const UndoIcon: () => React.JSX.Element;
36
+ /** A slanted eraser block. */
37
+ export declare const EraserIcon: () => React.JSX.Element;
38
+ /** A trash can - clearing the whole whiteboard. */
39
+ export declare const ClearIcon: () => React.JSX.Element;
@@ -26,3 +26,15 @@ export const OverflowIcon = () => _jsx(Icon, { path: "M6 10c-1.1 0-2 .9-2 2s.9 2
26
26
  * strokes rather than a filled Material path, same "simple primitives, not a recalled path" reasoning as
27
27
  * `BatonIcon`. */
28
28
  export const RemoveIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }), _jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })] }));
29
+ /** A speech bubble - the in-call chat. Drawn from primitives, same reasoning as `BatonIcon`. */
30
+ export const ChatIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M4 5h16v11H9l-5 4V5z" }), _jsx("line", { x1: "8", y1: "9.5", x2: "16", y2: "9.5", strokeLinecap: "round" }), _jsx("line", { x1: "8", y1: "12.5", x2: "13", y2: "12.5", strokeLinecap: "round" })] }));
31
+ /** An easel board with a squiggle on it - the shared whiteboard. */
32
+ export const WhiteboardIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("rect", { x: "3", y: "4", width: "18", height: "12", rx: "1" }), _jsx("path", { d: "M7 12c1.5-3 3-3 4 0s2.5 3 4-1" }), _jsx("line", { x1: "12", y1: "16", x2: "9", y2: "21" }), _jsx("line", { x1: "12", y1: "16", x2: "15", y2: "21" })] }));
33
+ /** The letters "GIF" in a rounded box - the chat's GIF picker. */
34
+ export const GifIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", className: "w-6 h-6", "aria-hidden": "true", focusable: "false", children: [_jsx("rect", { x: "2.5", y: "5.5", width: "19", height: "13", rx: "2.5", fill: "none", stroke: "currentColor", strokeWidth: "1.8" }), _jsx("text", { x: "12", y: "15.2", textAnchor: "middle", fontSize: "7.5", fontWeight: "700", fontFamily: "system-ui, sans-serif", fill: "currentColor", children: "GIF" })] }));
35
+ export const SendIcon = () => _jsx(Icon, { path: "M2.01 21L23 12 2.01 3 2 10l15 2-15 2z", className: "w-5 h-5" });
36
+ export const UndoIcon = () => _jsx(Icon, { path: "M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z", className: "w-5 h-5" });
37
+ /** A slanted eraser block. */
38
+ export const EraserIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", className: "w-5 h-5", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinejoin: "round", strokeLinecap: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M14 4l6 6-9 9H6l-3-3z" }), _jsx("line", { x1: "9", y1: "9", x2: "15", y2: "15" }), _jsx("line", { x1: "11", y1: "19", x2: "21", y2: "19" })] }));
39
+ /** A trash can - clearing the whole whiteboard. */
40
+ export const ClearIcon = () => _jsx(Icon, { path: "M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z", className: "w-5 h-5" });
@@ -32,10 +32,10 @@ export interface PublicVideoMeeting {
32
32
  * Mirrors `BaseVideoMeetingRoute.ts`'s `VideoMeetingJoinResult`. `authenticated: true` means the visiting browser
33
33
  * already held a valid session for a real RapidMX identity when it called `join()` - `token`/`expiresAt` are then
34
34
  * omitted entirely (there is no guest token to hand over), and the caller (`[token].tsx`) must not write any
35
- * cookie of its own before connecting to `/push`: the browser's own already-existing `jwt` session cookie already
36
- * authenticates it there, with zero new client-side auth handling. `authenticated: false` (the common anonymous
37
- * case) is unchanged from Phase 1: `token`/`expiresAt` are present, and `GuestSignalingClient` applies `token` as a
38
- * cookie before connecting.
35
+ * cookie of its own before opening the signaling socket: the browser's own already-existing `jwt` session cookie
36
+ * already authenticates it there, with zero new client-side auth handling. `authenticated: false` (the common
37
+ * anonymous case) is unchanged from Phase 1: `token`/`expiresAt` are present, and `SignalingClient` applies `token` as
38
+ * a cookie before connecting.
39
39
  */
40
40
  export interface VideoMeetingJoinResult {
41
41
  meeting: PublicVideoMeeting;
@@ -44,7 +44,8 @@ export interface VideoMeetingJoinResult {
44
44
  /** The identity to identify as on the signaling channel/mesh - the caller's own real uid when `authenticated`
45
45
  * is `true`, otherwise a freshly minted synthetic `guest:<random>` uid. Always present. */
46
46
  selfUid: string;
47
- /** A short-lived guest JWT, usable against `/push`. Present only when `authenticated` is `false`. */
47
+ /** A short-lived guest JWT, usable against the meeting's signaling and relay sockets. Present only when
48
+ * `authenticated` is `false`. */
48
49
  token?: string;
49
50
  /** ISO 8601 instant `token` expires at. Present only when `authenticated` is `false`. */
50
51
  expiresAt?: string;
@@ -133,8 +134,8 @@ export declare function denyParticipant(meetingUid: string, uid: string): Promis
133
134
  export declare function setWaitingRoomEnabled(meetingUid: string, waitingRoomEnabled: boolean): Promise<void>;
134
135
  /** Removes a participant from the meeting - host only, enforced server-side with the caller's own authenticated
135
136
  * session (the same mailbox-ACL check every other owner-side video-meeting route uses), not by anything the
136
- * client claims. Revokes `uid`'s grant on the meeting's own push channel, so they can no longer signal or relay
137
- * media even if their client ignores the cooperative "kicked" signal a host's UI sends alongside this
137
+ * client claims. Revokes `uid`'s grant on the meeting and closes every signaling socket they hold in it, so they can
138
+ * no longer signal or relay media even if their client ignores the cooperative "kicked" signal a host's UI sends alongside this
138
139
  * (`MeshConnectionManager.sendKick()`). `uid` is the participant's real account uid (`MeshParticipant.uid` minus
139
140
  * its tab-scoped suffix - see `_CallView.tsx`'s `accountUidOf()`), not their tab-scoped peer id. */
140
141
  export declare function kickParticipant(meetingUid: string, uid: string): Promise<void>;
@@ -86,8 +86,8 @@ export function setWaitingRoomEnabled(meetingUid, waitingRoomEnabled) {
86
86
  }
87
87
  /** Removes a participant from the meeting - host only, enforced server-side with the caller's own authenticated
88
88
  * session (the same mailbox-ACL check every other owner-side video-meeting route uses), not by anything the
89
- * client claims. Revokes `uid`'s grant on the meeting's own push channel, so they can no longer signal or relay
90
- * media even if their client ignores the cooperative "kicked" signal a host's UI sends alongside this
89
+ * client claims. Revokes `uid`'s grant on the meeting and closes every signaling socket they hold in it, so they can
90
+ * no longer signal or relay media even if their client ignores the cooperative "kicked" signal a host's UI sends alongside this
91
91
  * (`MeshConnectionManager.sendKick()`). `uid` is the participant's real account uid (`MeshParticipant.uid` minus
92
92
  * its tab-scoped suffix - see `_CallView.tsx`'s `accountUidOf()`), not their tab-scoped peer id. */
93
93
  export function kickParticipant(meetingUid, uid) {