@rapidmx/meet-plugin 0.14.1 → 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 -463
- package/apps/meet/_CallView.tsx +1060 -891
- package/apps/meet/_ChatPanel.tsx +194 -0
- package/apps/meet/_ColorWheel.tsx +136 -0
- package/apps/meet/_DiagnosticsPanel.tsx +65 -7
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +243 -169
- package/apps/meet/_ParticipantsDrawer.tsx +10 -7
- package/apps/meet/_Whiteboard.tsx +577 -0
- package/apps/meet/_icons.tsx +135 -80
- 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/AudioPlayer.ts +29 -0
- package/apps/shared/relay/AudioSender.ts +23 -0
- package/apps/shared/relay/RelayClient.ts +54 -2
- package/apps/shared/relay/RelayReceiver.ts +43 -2
- package/apps/shared/relay/RelaySender.ts +23 -1
- package/apps/shared/relay/RelayTransport.ts +21 -1
- package/apps/shared/relay/SilenceMeter.ts +59 -0
- package/apps/shared/relay/relayEnv.ts +1 -1
- package/apps/shared/signaling/SignalingClient.ts +335 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +971 -949
- package/apps/shared/webrtc/types.ts +335 -247
- package/dist/apps/meet/[token].js +1 -1
- package/dist/apps/meet/_CallControls.d.ts +21 -6
- package/dist/apps/meet/_CallControls.js +14 -8
- package/dist/apps/meet/_CallView.d.ts +36 -7
- package/dist/apps/meet/_CallView.js +132 -16
- 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/_DiagnosticsPanel.d.ts +5 -3
- package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +11 -3
- package/dist/apps/meet/_ParticipantTile.js +36 -5
- package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
- package/dist/apps/meet/_Whiteboard.d.ts +64 -0
- package/dist/apps/meet/_Whiteboard.js +312 -0
- package/dist/apps/meet/_icons.d.ts +16 -0
- package/dist/apps/meet/_icons.js +16 -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/AudioPlayer.d.ts +9 -0
- package/dist/apps/shared/relay/AudioPlayer.js +24 -4
- package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
- package/dist/apps/shared/relay/AudioSender.js +20 -4
- package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
- package/dist/apps/shared/relay/RelayClient.js +46 -2
- package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
- package/dist/apps/shared/relay/RelayReceiver.js +38 -6
- package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
- package/dist/apps/shared/relay/RelaySender.js +20 -5
- package/dist/apps/shared/relay/RelayTransport.d.ts +6 -1
- package/dist/apps/shared/relay/RelayTransport.js +16 -1
- package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
- package/dist/apps/shared/relay/SilenceMeter.js +55 -0
- 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 +28 -9
- package/dist/apps/shared/webrtc/types.d.ts +104 -26
- 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/RelayHub.js +15 -2
- package/dist/lib/util/RelayHub.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/RelayHub.d.ts +5 -2
- 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,577 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The shared whiteboard, shown in place of the video stage while a participant has it open (`_CallView.tsx`). Every
|
|
7
|
+
* participant draws on the same board - `CollabSession` (`apps/shared/collab/CollabSession.ts`) carries the drawing
|
|
8
|
+
* to everyone and `whiteboardRender.ts` draws it - but each one opens and closes their own view of it.
|
|
9
|
+
*
|
|
10
|
+
* ## Tools
|
|
11
|
+
*
|
|
12
|
+
* - **Pens** - ballpoint, sharpie, marker, crayon and coloring pencil (`PEN_TYPES`), each drawn its own way.
|
|
13
|
+
* - **Stencil** - drags out a line, arrow, rectangle, ellipse, triangle or star, previewed while dragging.
|
|
14
|
+
* - **Stamps** - places one of `STAMPS` where clicked.
|
|
15
|
+
* - **Eraser** - erases whatever it passes over, anyone's drawing.
|
|
16
|
+
*
|
|
17
|
+
* Plus a row of sizes, color swatches and a color wheel (`_ColorWheel.tsx`) for any other color, undo (also
|
|
18
|
+
* Ctrl/Cmd+Z: takes back this participant's own last drawing) and clear (for everyone, after a confirmation).
|
|
19
|
+
*
|
|
20
|
+
* ## Everyone's pointers
|
|
21
|
+
*
|
|
22
|
+
* While this participant's pointer is over the board it is shared (`CollabSession.moveCursor()`), as the tool they
|
|
23
|
+
* have picked in the color they have picked; everyone else's (`cursors`) is drawn over the canvas the same way, with
|
|
24
|
+
* their name beside it, so each can see who is pointing at - or drawing - what. They are plain elements over the
|
|
25
|
+
* canvas rather than part of the drawing, so a pointer moving never redraws the board.
|
|
26
|
+
*
|
|
27
|
+
* ## Talking stick
|
|
28
|
+
*
|
|
29
|
+
* While `canDraw` is off (talking-stick mode, and someone else holds the stick - see `_CallView.tsx`), drawing,
|
|
30
|
+
* undo and clear do nothing and a note names who may draw; picking a tool or color still works, ready for when the
|
|
31
|
+
* stick comes round. The pointer is still shared.
|
|
32
|
+
*
|
|
33
|
+
* ## The canvas
|
|
34
|
+
*
|
|
35
|
+
* The board is a fixed 16:9 space (`BOARD_WIDTH` x `BOARD_HEIGHT`) fitted into whatever room the stage has, so a
|
|
36
|
+
* stroke lands in the same place on every participant's screen whatever their window size. The canvas is backed at
|
|
37
|
+
* the display's pixel ratio so lines stay sharp, and redrawn at most once per animation frame whenever the board
|
|
38
|
+
* changes (`revision`).
|
|
39
|
+
*/
|
|
40
|
+
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
41
|
+
import type { CollabSession, RemoteCursor } from "../shared/collab/CollabSession.js";
|
|
42
|
+
import {
|
|
43
|
+
BOARD_HEIGHT,
|
|
44
|
+
BOARD_WIDTH,
|
|
45
|
+
PEN_TYPES,
|
|
46
|
+
SIZES,
|
|
47
|
+
STAMPS,
|
|
48
|
+
STAMP_SIZES,
|
|
49
|
+
STENCIL_SHAPES,
|
|
50
|
+
type PenType,
|
|
51
|
+
type CursorTool,
|
|
52
|
+
type ShapeElement,
|
|
53
|
+
type Stamp,
|
|
54
|
+
type StencilShape,
|
|
55
|
+
} from "../shared/collab/protocol.js";
|
|
56
|
+
import { drawElement, hitTest, renderBoard, type BoardContext } from "../shared/collab/whiteboardRender.js";
|
|
57
|
+
import ColorWheel from "./_ColorWheel.js";
|
|
58
|
+
import { ClearIcon, EraserIcon, UndoIcon } from "./_icons.js";
|
|
59
|
+
|
|
60
|
+
export interface WhiteboardProps {
|
|
61
|
+
session: CollabSession;
|
|
62
|
+
/** `session.board.revision` - a new value redraws the board. */
|
|
63
|
+
revision: number;
|
|
64
|
+
/** Everyone else's pointers on the board - `CollabSession.cursors`. */
|
|
65
|
+
cursors?: readonly RemoteCursor[];
|
|
66
|
+
/** Whether this participant may draw now - see this module's doc comment on talking stick. Defaults to `true`. */
|
|
67
|
+
canDraw?: boolean;
|
|
68
|
+
/** Who may draw while `canDraw` is off, for the note saying so; absent while nobody holds the stick. */
|
|
69
|
+
drawLockedTo?: string;
|
|
70
|
+
onClose: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Tool = { kind: "pen"; pen: PenType } | { kind: "stencil" } | { kind: "stamp" } | { kind: "eraser" };
|
|
74
|
+
|
|
75
|
+
export const PEN_LABELS: Record<PenType, string> = {
|
|
76
|
+
ballpoint: "Ballpoint pen",
|
|
77
|
+
sharpie: "Sharpie",
|
|
78
|
+
marker: "Marker",
|
|
79
|
+
crayon: "Crayon",
|
|
80
|
+
pencil: "Coloring pencil",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const SHAPE_LABELS: Record<StencilShape, string> = {
|
|
84
|
+
line: "Line",
|
|
85
|
+
arrow: "Arrow",
|
|
86
|
+
rect: "Rectangle",
|
|
87
|
+
ellipse: "Ellipse",
|
|
88
|
+
triangle: "Triangle",
|
|
89
|
+
star: "Star",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** The color swatches, in display order. */
|
|
93
|
+
export const SWATCHES = ["#202124", "#ffffff", "#9aa0a6", "#d93025", "#f29900", "#fbbc04", "#188038", "#1a73e8", "#9334e6", "#e52592", "#795548", "#00bcd4"] as const;
|
|
94
|
+
|
|
95
|
+
/** A freehand point closer than this (board units) to the previous one is skipped - it adds nothing visible. */
|
|
96
|
+
const MIN_POINT_DISTANCE = 2;
|
|
97
|
+
/** The eraser's reach around the pointer, in board units. */
|
|
98
|
+
const ERASER_RADIUS = 14;
|
|
99
|
+
/** A stencil drag shorter than this (board units) is a mis-click, not a shape. */
|
|
100
|
+
const MIN_SHAPE_SIZE = 4;
|
|
101
|
+
|
|
102
|
+
const TOOL_BUTTON =
|
|
103
|
+
"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]";
|
|
104
|
+
|
|
105
|
+
/** A short sample of each pen's line, for its toolbar button. */
|
|
106
|
+
function PenSample({ pen }: { pen: PenType }) {
|
|
107
|
+
const look: Record<PenType, { width: number; opacity: number; dash?: string; cap: "round" | "square" }> = {
|
|
108
|
+
ballpoint: { width: 1.5, opacity: 1, cap: "round" },
|
|
109
|
+
sharpie: { width: 3, opacity: 1, cap: "round" },
|
|
110
|
+
marker: { width: 6, opacity: 0.5, cap: "square" },
|
|
111
|
+
crayon: { width: 4, opacity: 0.8, dash: "2 1", cap: "round" },
|
|
112
|
+
pencil: { width: 1.5, opacity: 0.6, dash: "3 1", cap: "round" },
|
|
113
|
+
};
|
|
114
|
+
const { width, opacity, dash, cap } = look[pen];
|
|
115
|
+
return (
|
|
116
|
+
<svg viewBox="0 0 24 24" className="w-6 h-6" aria-hidden="true" focusable="false">
|
|
117
|
+
<path d="M3 16c3-6 6-6 9 0s6 6 9 0" fill="none" stroke="currentColor" strokeWidth={width} strokeOpacity={opacity} strokeDasharray={dash} strokeLinecap={cap} />
|
|
118
|
+
</svg>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Black or white, whichever reads better on `color` (`#rrggbb`). */
|
|
123
|
+
export function labelTextColor(color: string): string {
|
|
124
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(color.slice(i, i + 2), 16));
|
|
125
|
+
return 0.299 * r + 0.587 * g + 0.114 * b > 150 ? "#000000" : "#ffffff";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A participant's pointer: their tool's own shape in their color, its tip (or a stamp's center) at the point, with
|
|
129
|
+
* their name beside it. */
|
|
130
|
+
export function CursorMarker({ cursor, left, top }: { cursor: Pick<RemoteCursor, "name" | "tool" | "color" | "stamp">; left: number; top: number }) {
|
|
131
|
+
const { tool, color } = cursor;
|
|
132
|
+
let glyph: React.ReactNode;
|
|
133
|
+
if (tool === "stamp") {
|
|
134
|
+
glyph = <span className="block -translate-x-1/2 -translate-y-1/2 text-2xl leading-none drop-shadow">{cursor.stamp ?? "⭐"}</span>;
|
|
135
|
+
} else if (tool === "eraser") {
|
|
136
|
+
glyph = (
|
|
137
|
+
<svg viewBox="0 0 24 24" className="w-6 h-6 -translate-x-1/2 -translate-y-1/2" aria-hidden="true">
|
|
138
|
+
<circle cx="12" cy="12" r="9" fill="#ffffff" fillOpacity="0.6" stroke={color} strokeWidth="2" />
|
|
139
|
+
</svg>
|
|
140
|
+
);
|
|
141
|
+
} else if (tool === "stencil") {
|
|
142
|
+
glyph = (
|
|
143
|
+
<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">
|
|
144
|
+
<line x1="12" y1="2" x2="12" y2="22" />
|
|
145
|
+
<line x1="2" y1="12" x2="22" y2="12" />
|
|
146
|
+
<rect x="15" y="15" width="7" height="7" />
|
|
147
|
+
</svg>
|
|
148
|
+
);
|
|
149
|
+
} else {
|
|
150
|
+
// A pen held at an angle, tip at the top-left corner: its body width and tip say which pen it is.
|
|
151
|
+
const body: Record<PenType, { width: number; tip: string }> = {
|
|
152
|
+
ballpoint: { width: 4, tip: "M0 0l5 2-3 3z" },
|
|
153
|
+
sharpie: { width: 6, tip: "M0 0l7 3-4 4z" },
|
|
154
|
+
marker: { width: 8, tip: "M0 0h6l2 2v6H2L0 6z" },
|
|
155
|
+
crayon: { width: 6, tip: "M0 0l8 4-4 4z" },
|
|
156
|
+
pencil: { width: 5, tip: "M0 0l6 2-4 4z" },
|
|
157
|
+
};
|
|
158
|
+
const { width, tip } = body[tool];
|
|
159
|
+
glyph = (
|
|
160
|
+
<svg viewBox="0 0 28 28" className="w-7 h-7" aria-hidden="true">
|
|
161
|
+
<g transform="rotate(45 0 0)">
|
|
162
|
+
<rect x="6" y={-width / 2} width="20" height={width} rx="1" fill={color} stroke="#000000" strokeOpacity="0.5" strokeWidth="1" />
|
|
163
|
+
</g>
|
|
164
|
+
<path d={tip} fill={color} stroke="#000000" strokeOpacity="0.5" strokeWidth="0.8" />
|
|
165
|
+
</svg>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return (
|
|
169
|
+
<div className="pointer-events-none absolute transition-[left,top] duration-75 ease-linear" style={{ left, top }} data-testid="remote-cursor">
|
|
170
|
+
{glyph}
|
|
171
|
+
<span
|
|
172
|
+
className="absolute left-4 top-4 max-w-40 truncate whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium shadow"
|
|
173
|
+
style={{ backgroundColor: color, color: labelTextColor(color) }}
|
|
174
|
+
>
|
|
175
|
+
{cursor.name}
|
|
176
|
+
</span>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A small outline of each stencil shape, for its button. */
|
|
182
|
+
function ShapeSample({ shape }: { shape: StencilShape }) {
|
|
183
|
+
const paths: Record<StencilShape, React.ReactNode> = {
|
|
184
|
+
line: <line x1="4" y1="20" x2="20" y2="4" />,
|
|
185
|
+
arrow: <path d="M4 20L20 4m0 0h-7m7 0v7" />,
|
|
186
|
+
rect: <rect x="4" y="6" width="16" height="12" />,
|
|
187
|
+
ellipse: <ellipse cx="12" cy="12" rx="9" ry="6.5" />,
|
|
188
|
+
triangle: <path d="M12 4l9 16H3z" />,
|
|
189
|
+
star: <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" />,
|
|
190
|
+
};
|
|
191
|
+
return (
|
|
192
|
+
<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">
|
|
193
|
+
{paths[shape]}
|
|
194
|
+
</svg>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export default function Whiteboard({ session, revision, cursors = [], canDraw = true, drawLockedTo, onClose }: WhiteboardProps) {
|
|
199
|
+
const [tool, setTool] = useState<Tool>({ kind: "pen", pen: "ballpoint" });
|
|
200
|
+
const [shape, setShape] = useState<StencilShape>("rect");
|
|
201
|
+
const [stamp, setStamp] = useState<Stamp>(STAMPS[0]);
|
|
202
|
+
const [color, setColor] = useState<string>(SWATCHES[0]);
|
|
203
|
+
const [sizeIndex, setSizeIndex] = useState(1);
|
|
204
|
+
const [stampSizeIndex, setStampSizeIndex] = useState(1);
|
|
205
|
+
const [popover, setPopover] = useState<"wheel" | "stamps" | null>(null);
|
|
206
|
+
const [confirmClear, setConfirmClear] = useState(false);
|
|
207
|
+
const [box, setBox] = useState({ width: 0, height: 0 });
|
|
208
|
+
|
|
209
|
+
const stageRef = useRef<HTMLDivElement>(null);
|
|
210
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
211
|
+
const drawingRef = useRef<{ strokeId?: string; lastX: number; lastY: number; startX: number; startY: number } | null>(null);
|
|
212
|
+
const previewRef = useRef<ShapeElement | null>(null);
|
|
213
|
+
const frameRef = useRef<number | undefined>(undefined);
|
|
214
|
+
|
|
215
|
+
const size = SIZES[sizeIndex];
|
|
216
|
+
const stampSize = STAMP_SIZES[stampSizeIndex];
|
|
217
|
+
|
|
218
|
+
// The largest 16:9 box that fits the stage.
|
|
219
|
+
useLayoutEffect(() => {
|
|
220
|
+
const stage = stageRef.current!;
|
|
221
|
+
const fit = () => {
|
|
222
|
+
const { width, height } = stage.getBoundingClientRect();
|
|
223
|
+
const fitted = Math.min(width, (height * BOARD_WIDTH) / BOARD_HEIGHT);
|
|
224
|
+
setBox({ width: Math.floor(fitted), height: Math.floor((fitted * BOARD_HEIGHT) / BOARD_WIDTH) });
|
|
225
|
+
};
|
|
226
|
+
fit();
|
|
227
|
+
if (typeof ResizeObserver === "undefined") {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const observer = new ResizeObserver(fit);
|
|
231
|
+
observer.observe(stage);
|
|
232
|
+
return () => observer.disconnect();
|
|
233
|
+
}, []);
|
|
234
|
+
|
|
235
|
+
const draw = useCallback(() => {
|
|
236
|
+
frameRef.current = undefined;
|
|
237
|
+
const canvas = canvasRef.current;
|
|
238
|
+
const ctx = canvas?.getContext("2d") as BoardContext | null | undefined;
|
|
239
|
+
if (!canvas || !ctx) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const scale = canvas.width / BOARD_WIDTH;
|
|
243
|
+
renderBoard(ctx, session.board.elements, scale);
|
|
244
|
+
if (previewRef.current) {
|
|
245
|
+
ctx.save();
|
|
246
|
+
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
247
|
+
drawElement(ctx, previewRef.current);
|
|
248
|
+
ctx.restore();
|
|
249
|
+
}
|
|
250
|
+
}, [session]);
|
|
251
|
+
|
|
252
|
+
const scheduleDraw = useCallback(() => {
|
|
253
|
+
if (frameRef.current === undefined) {
|
|
254
|
+
frameRef.current = requestAnimationFrame(draw);
|
|
255
|
+
}
|
|
256
|
+
}, [draw]);
|
|
257
|
+
|
|
258
|
+
const ratio = typeof window !== "undefined" && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
|
|
259
|
+
|
|
260
|
+
// A new size or pixel ratio resizes the canvas, which wipes it - so either redraws, as a change to the board does.
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
scheduleDraw();
|
|
263
|
+
}, [revision, box, ratio, scheduleDraw]);
|
|
264
|
+
|
|
265
|
+
// This participant's pointer leaves everyone else's board with the board itself.
|
|
266
|
+
useEffect(() => () => session.hideCursor(), [session]);
|
|
267
|
+
|
|
268
|
+
useEffect(() => () => {
|
|
269
|
+
if (frameRef.current !== undefined) {
|
|
270
|
+
cancelAnimationFrame(frameRef.current);
|
|
271
|
+
}
|
|
272
|
+
}, []);
|
|
273
|
+
|
|
274
|
+
// Ctrl/Cmd+Z takes back this participant's own last drawing - unless they are typing somewhere (the chat).
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
277
|
+
const target = event.target as HTMLElement | null;
|
|
278
|
+
if (target?.closest?.("input, textarea, [contenteditable='true']")) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === "z" && session.canDraw) {
|
|
282
|
+
event.preventDefault();
|
|
283
|
+
session.undo();
|
|
284
|
+
} else if (event.key === "Escape") {
|
|
285
|
+
setPopover(null);
|
|
286
|
+
setConfirmClear(false);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
document.addEventListener("keydown", onKeyDown);
|
|
290
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
291
|
+
}, [session]);
|
|
292
|
+
|
|
293
|
+
function toBoard(event: { clientX: number; clientY: number }): { x: number; y: number } {
|
|
294
|
+
const rect = canvasRef.current!.getBoundingClientRect();
|
|
295
|
+
const width = rect.width || BOARD_WIDTH;
|
|
296
|
+
const height = rect.height || BOARD_HEIGHT;
|
|
297
|
+
return { x: ((event.clientX - rect.left) / width) * BOARD_WIDTH, y: ((event.clientY - rect.top) / height) * BOARD_HEIGHT };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function erase(x: number, y: number) {
|
|
301
|
+
const hits = session.board.elements.filter((element) => hitTest(element, x, y, ERASER_RADIUS)).map((element) => element.id);
|
|
302
|
+
session.erase(hits);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function cursorTool(): CursorTool {
|
|
306
|
+
return tool.kind === "pen" ? tool.pen : tool.kind;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function shareCursor(x: number, y: number) {
|
|
310
|
+
session.moveCursor(x, y, cursorTool(), color, tool.kind === "stamp" ? stamp : undefined);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function handlePointerDown(event: React.PointerEvent<HTMLCanvasElement>) {
|
|
314
|
+
if (event.button !== 0 || !canDraw) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
setPopover(null);
|
|
318
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
319
|
+
const { x, y } = toBoard(event);
|
|
320
|
+
switch (tool.kind) {
|
|
321
|
+
case "pen":
|
|
322
|
+
drawingRef.current = { strokeId: session.beginStroke(tool.pen, color, size, x, y), lastX: x, lastY: y, startX: x, startY: y };
|
|
323
|
+
return;
|
|
324
|
+
case "stencil":
|
|
325
|
+
drawingRef.current = { lastX: x, lastY: y, startX: x, startY: y };
|
|
326
|
+
return;
|
|
327
|
+
case "stamp":
|
|
328
|
+
session.addStamp(stamp, stampSize, x, y);
|
|
329
|
+
return;
|
|
330
|
+
case "eraser":
|
|
331
|
+
drawingRef.current = { lastX: x, lastY: y, startX: x, startY: y };
|
|
332
|
+
erase(x, y);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function handlePointerMove(event: React.PointerEvent<HTMLCanvasElement>) {
|
|
338
|
+
const drawing = drawingRef.current;
|
|
339
|
+
if (!drawing) {
|
|
340
|
+
const { x, y } = toBoard(event);
|
|
341
|
+
shareCursor(x, y);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const events = typeof event.nativeEvent.getCoalescedEvents === "function" ? event.nativeEvent.getCoalescedEvents() : [];
|
|
345
|
+
for (const sample of events.length > 0 ? events : [event]) {
|
|
346
|
+
const { x, y } = toBoard(sample);
|
|
347
|
+
if (tool.kind === "pen" && drawing.strokeId) {
|
|
348
|
+
if (Math.hypot(x - drawing.lastX, y - drawing.lastY) >= MIN_POINT_DISTANCE) {
|
|
349
|
+
session.extendStroke(drawing.strokeId, x, y);
|
|
350
|
+
drawing.lastX = x;
|
|
351
|
+
drawing.lastY = y;
|
|
352
|
+
}
|
|
353
|
+
} else if (tool.kind === "eraser") {
|
|
354
|
+
erase(x, y);
|
|
355
|
+
} else if (tool.kind === "stencil") {
|
|
356
|
+
previewRef.current = { kind: "shape", id: "preview", epoch: 0, shape, color, size, x1: drawing.startX, y1: drawing.startY, x2: x, y2: y };
|
|
357
|
+
drawing.lastX = x;
|
|
358
|
+
drawing.lastY = y;
|
|
359
|
+
scheduleDraw();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// Mid-stroke the stroke itself shows everyone where the pen is; anything else moves the pointer.
|
|
363
|
+
if (tool.kind !== "pen") {
|
|
364
|
+
const { x, y } = toBoard(event);
|
|
365
|
+
shareCursor(x, y);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function handlePointerUp() {
|
|
370
|
+
const drawing = drawingRef.current;
|
|
371
|
+
drawingRef.current = null;
|
|
372
|
+
if (!drawing) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (tool.kind === "pen") {
|
|
376
|
+
session.endStroke();
|
|
377
|
+
} else if (tool.kind === "stencil") {
|
|
378
|
+
previewRef.current = null;
|
|
379
|
+
if (Math.hypot(drawing.lastX - drawing.startX, drawing.lastY - drawing.startY) >= MIN_SHAPE_SIZE) {
|
|
380
|
+
session.addShape(shape, color, size, drawing.startX, drawing.startY, drawing.lastX, drawing.lastY);
|
|
381
|
+
}
|
|
382
|
+
scheduleDraw();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const cursor = tool.kind === "eraser" ? "cursor-cell" : tool.kind === "stamp" ? "cursor-copy" : "cursor-crosshair";
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
<section aria-label="Whiteboard" className="h-full flex flex-col gap-2" data-testid="whiteboard">
|
|
390
|
+
<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">
|
|
391
|
+
{PEN_TYPES.map((pen) => (
|
|
392
|
+
<button
|
|
393
|
+
key={pen}
|
|
394
|
+
type="button"
|
|
395
|
+
className={TOOL_BUTTON}
|
|
396
|
+
aria-label={PEN_LABELS[pen]}
|
|
397
|
+
title={PEN_LABELS[pen]}
|
|
398
|
+
aria-pressed={tool.kind === "pen" && tool.pen === pen}
|
|
399
|
+
onClick={() => setTool({ kind: "pen", pen })}
|
|
400
|
+
>
|
|
401
|
+
<PenSample pen={pen} />
|
|
402
|
+
</button>
|
|
403
|
+
))}
|
|
404
|
+
<button type="button" className={TOOL_BUTTON} aria-label="Stencil" title="Stencil" aria-pressed={tool.kind === "stencil"} onClick={() => setTool({ kind: "stencil" })}>
|
|
405
|
+
<ShapeSample shape={shape} />
|
|
406
|
+
</button>
|
|
407
|
+
<button
|
|
408
|
+
type="button"
|
|
409
|
+
className={TOOL_BUTTON}
|
|
410
|
+
aria-label="Stamp"
|
|
411
|
+
title="Stamp"
|
|
412
|
+
aria-pressed={tool.kind === "stamp"}
|
|
413
|
+
onClick={() => {
|
|
414
|
+
setTool({ kind: "stamp" });
|
|
415
|
+
setPopover((prev) => (prev === "stamps" ? null : "stamps"));
|
|
416
|
+
}}
|
|
417
|
+
>
|
|
418
|
+
<span className="text-lg leading-none">{stamp}</span>
|
|
419
|
+
</button>
|
|
420
|
+
<button type="button" className={TOOL_BUTTON} aria-label="Eraser" title="Eraser" aria-pressed={tool.kind === "eraser"} onClick={() => setTool({ kind: "eraser" })}>
|
|
421
|
+
<EraserIcon />
|
|
422
|
+
</button>
|
|
423
|
+
|
|
424
|
+
<span className="mx-1 h-6 w-px bg-white/15" aria-hidden="true" />
|
|
425
|
+
|
|
426
|
+
{tool.kind === "stencil" && (
|
|
427
|
+
<div role="group" aria-label="Stencil shape" className="flex items-center gap-0.5">
|
|
428
|
+
{STENCIL_SHAPES.map((candidate) => (
|
|
429
|
+
<button key={candidate} type="button" className={TOOL_BUTTON} aria-label={SHAPE_LABELS[candidate]} title={SHAPE_LABELS[candidate]} aria-pressed={shape === candidate} onClick={() => setShape(candidate)}>
|
|
430
|
+
<ShapeSample shape={candidate} />
|
|
431
|
+
</button>
|
|
432
|
+
))}
|
|
433
|
+
</div>
|
|
434
|
+
)}
|
|
435
|
+
|
|
436
|
+
{(tool.kind === "pen" || tool.kind === "stencil") && (
|
|
437
|
+
<div role="group" aria-label="Size" className="flex items-center gap-0.5">
|
|
438
|
+
{SIZES.map((candidate, index) => (
|
|
439
|
+
<button key={candidate} type="button" className={TOOL_BUTTON} aria-label={`Size ${index + 1}`} aria-pressed={sizeIndex === index} onClick={() => setSizeIndex(index)}>
|
|
440
|
+
<span className="rounded-full bg-current" style={{ width: 3 + index * 3, height: 3 + index * 3 }} />
|
|
441
|
+
</button>
|
|
442
|
+
))}
|
|
443
|
+
</div>
|
|
444
|
+
)}
|
|
445
|
+
|
|
446
|
+
{tool.kind === "stamp" && (
|
|
447
|
+
<div role="group" aria-label="Stamp size" className="flex items-center gap-0.5">
|
|
448
|
+
{STAMP_SIZES.map((candidate, index) => (
|
|
449
|
+
<button key={candidate} type="button" className={TOOL_BUTTON} aria-label={`Stamp size ${index + 1}`} aria-pressed={stampSizeIndex === index} onClick={() => setStampSizeIndex(index)}>
|
|
450
|
+
<span style={{ fontSize: 9 + index * 4 }}>{stamp}</span>
|
|
451
|
+
</button>
|
|
452
|
+
))}
|
|
453
|
+
</div>
|
|
454
|
+
)}
|
|
455
|
+
|
|
456
|
+
{tool.kind !== "eraser" && tool.kind !== "stamp" && (
|
|
457
|
+
<>
|
|
458
|
+
<span className="mx-1 h-6 w-px bg-white/15" aria-hidden="true" />
|
|
459
|
+
<div role="radiogroup" aria-label="Color" className="flex flex-wrap items-center gap-1">
|
|
460
|
+
{SWATCHES.map((swatch) => (
|
|
461
|
+
<button
|
|
462
|
+
key={swatch}
|
|
463
|
+
type="button"
|
|
464
|
+
role="radio"
|
|
465
|
+
aria-checked={color === swatch}
|
|
466
|
+
aria-label={`Color ${swatch}`}
|
|
467
|
+
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]" : ""}`}
|
|
468
|
+
style={{ backgroundColor: swatch }}
|
|
469
|
+
onClick={() => setColor(swatch)}
|
|
470
|
+
/>
|
|
471
|
+
))}
|
|
472
|
+
<button
|
|
473
|
+
type="button"
|
|
474
|
+
aria-label="Custom color"
|
|
475
|
+
aria-expanded={popover === "wheel"}
|
|
476
|
+
title="Custom color"
|
|
477
|
+
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 as never) ? "" : "ring-2 ring-[#a8c7fa] ring-offset-2 ring-offset-[#2b2d30]"}`}
|
|
478
|
+
onClick={() => setPopover((prev) => (prev === "wheel" ? null : "wheel"))}
|
|
479
|
+
>
|
|
480
|
+
<span className="block w-full h-full rounded-full border border-black/30" style={{ backgroundColor: color }} />
|
|
481
|
+
</button>
|
|
482
|
+
</div>
|
|
483
|
+
</>
|
|
484
|
+
)}
|
|
485
|
+
|
|
486
|
+
<span className="flex-1" />
|
|
487
|
+
<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()}>
|
|
488
|
+
<UndoIcon />
|
|
489
|
+
</button>
|
|
490
|
+
{confirmClear ? (
|
|
491
|
+
<span className="flex items-center gap-1 text-sm" role="group" aria-label="Clear the whiteboard?">
|
|
492
|
+
<span>Clear for everyone?</span>
|
|
493
|
+
<button
|
|
494
|
+
type="button"
|
|
495
|
+
className="px-2 py-1 rounded-lg bg-[#d93025] hover:bg-[#b3261e] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
|
496
|
+
onClick={() => {
|
|
497
|
+
session.clear();
|
|
498
|
+
setConfirmClear(false);
|
|
499
|
+
}}
|
|
500
|
+
>
|
|
501
|
+
Clear
|
|
502
|
+
</button>
|
|
503
|
+
<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)}>
|
|
504
|
+
Cancel
|
|
505
|
+
</button>
|
|
506
|
+
</span>
|
|
507
|
+
) : (
|
|
508
|
+
<button
|
|
509
|
+
type="button"
|
|
510
|
+
className={`${TOOL_BUTTON} disabled:opacity-40 disabled:cursor-not-allowed`}
|
|
511
|
+
aria-label="Clear whiteboard"
|
|
512
|
+
title="Clear whiteboard"
|
|
513
|
+
disabled={!canDraw}
|
|
514
|
+
onClick={() => setConfirmClear(true)}
|
|
515
|
+
>
|
|
516
|
+
<ClearIcon />
|
|
517
|
+
</button>
|
|
518
|
+
)}
|
|
519
|
+
<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}>
|
|
520
|
+
Back to video
|
|
521
|
+
</button>
|
|
522
|
+
|
|
523
|
+
{popover === "wheel" && (
|
|
524
|
+
<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">
|
|
525
|
+
<ColorWheel color={color} onChange={setColor} />
|
|
526
|
+
</div>
|
|
527
|
+
)}
|
|
528
|
+
{popover === "stamps" && (
|
|
529
|
+
<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">
|
|
530
|
+
{STAMPS.map((candidate) => (
|
|
531
|
+
<button
|
|
532
|
+
key={candidate}
|
|
533
|
+
type="button"
|
|
534
|
+
aria-label={`Stamp ${candidate}`}
|
|
535
|
+
aria-pressed={stamp === candidate}
|
|
536
|
+
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"
|
|
537
|
+
onClick={() => {
|
|
538
|
+
setStamp(candidate);
|
|
539
|
+
setPopover(null);
|
|
540
|
+
}}
|
|
541
|
+
>
|
|
542
|
+
{candidate}
|
|
543
|
+
</button>
|
|
544
|
+
))}
|
|
545
|
+
</div>
|
|
546
|
+
)}
|
|
547
|
+
</div>
|
|
548
|
+
<div ref={stageRef} className="relative flex-1 min-h-0 flex items-center justify-center">
|
|
549
|
+
<canvas
|
|
550
|
+
ref={canvasRef}
|
|
551
|
+
width={Math.max(1, Math.round(box.width * ratio))}
|
|
552
|
+
height={Math.max(1, Math.round(box.height * ratio))}
|
|
553
|
+
style={{ width: box.width, height: box.height }}
|
|
554
|
+
className={`rounded-lg shadow-lg touch-none bg-white ${canDraw ? cursor : "cursor-not-allowed"}`}
|
|
555
|
+
aria-label="Whiteboard canvas"
|
|
556
|
+
role="img"
|
|
557
|
+
data-testid="whiteboard-canvas"
|
|
558
|
+
onPointerDown={handlePointerDown}
|
|
559
|
+
onPointerMove={handlePointerMove}
|
|
560
|
+
onPointerUp={handlePointerUp}
|
|
561
|
+
onPointerCancel={handlePointerUp}
|
|
562
|
+
onPointerLeave={() => session.hideCursor()}
|
|
563
|
+
/>
|
|
564
|
+
<div className="pointer-events-none absolute overflow-hidden" style={{ width: box.width, height: box.height }} aria-hidden="true">
|
|
565
|
+
{cursors.map((remote) => (
|
|
566
|
+
<CursorMarker key={remote.peer} cursor={remote} left={(remote.x / BOARD_WIDTH) * box.width} top={(remote.y / BOARD_HEIGHT) * box.height} />
|
|
567
|
+
))}
|
|
568
|
+
</div>
|
|
569
|
+
{!canDraw && (
|
|
570
|
+
<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">
|
|
571
|
+
🎙️ {drawLockedTo ? `Only ${drawLockedTo} can draw while the talking stick is on` : "Nobody can draw until the host hands out the talking stick"}
|
|
572
|
+
</p>
|
|
573
|
+
)}
|
|
574
|
+
</div>
|
|
575
|
+
</section>
|
|
576
|
+
);
|
|
577
|
+
}
|