@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,194 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The in-call chat drawer, beside the call like the participants drawer (`_ParticipantsDrawer.tsx`) - `_CallView.tsx`
|
|
7
|
+
* shows one or the other. The messages come from `CollabSession` (`apps/shared/collab/CollabSession.ts`) and are
|
|
8
|
+
* rendered as Markdown by `ChatMarkdown` (bare links become links, image links become images).
|
|
9
|
+
*
|
|
10
|
+
* The composer is a textarea: Enter sends, Shift+Enter starts a new line. Its emoji button opens the web client's own
|
|
11
|
+
* emoji picker and inserts at the cursor; its GIF button opens the web client's own Giphy picker, and picking a GIF
|
|
12
|
+
* sends it at once as a message holding just its address - which every participant's chat then shows as the GIF
|
|
13
|
+
* itself. Both pickers are the very components the mail composer uses (`@rapidmx/web-client`'s `EmojiPicker` and
|
|
14
|
+
* `GifPicker`); GIF search goes through the server's own Giphy proxy, which a guest's token authenticates like a
|
|
15
|
+
* signed-in user's session.
|
|
16
|
+
*
|
|
17
|
+
* The list follows new messages only while it is already scrolled to the bottom, so reading back through the history
|
|
18
|
+
* isn't interrupted by someone else's message.
|
|
19
|
+
*/
|
|
20
|
+
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
21
|
+
import EmojiPicker from "@rapidmx/web-client/shared/components/mail/compose/EmojiPicker.js";
|
|
22
|
+
import GifPicker from "@rapidmx/web-client/shared/components/mail/compose/GifPicker.js";
|
|
23
|
+
import ChatMarkdown from "../shared/collab/chatMarkdown.js";
|
|
24
|
+
import type { ChatEntry } from "../shared/collab/CollabSession.js";
|
|
25
|
+
import { MAX_CHAT_LENGTH } from "../shared/collab/protocol.js";
|
|
26
|
+
import { EmojiIcon, GifIcon, SendIcon } from "./_icons.js";
|
|
27
|
+
|
|
28
|
+
export interface ChatPanelProps {
|
|
29
|
+
entries: readonly ChatEntry[];
|
|
30
|
+
/** Sends a message; `false` when it could not be sent (blank or too long). */
|
|
31
|
+
onSend: (text: string) => boolean;
|
|
32
|
+
onClose: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** How close to the bottom (px) still counts as "at the bottom" for following new messages. */
|
|
36
|
+
const STICK_THRESHOLD_PX = 48;
|
|
37
|
+
/** Within this many characters of `MAX_CHAT_LENGTH`, the composer shows how many are left. */
|
|
38
|
+
const COUNTER_THRESHOLD = 200;
|
|
39
|
+
/** Two messages from the same person this close together (ms) share one name heading. */
|
|
40
|
+
const GROUP_WINDOW_MS = 2 * 60_000;
|
|
41
|
+
|
|
42
|
+
const ICON_BUTTON =
|
|
43
|
+
"w-9 h-9 shrink-0 flex items-center justify-center rounded-full hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 disabled:opacity-40 disabled:cursor-not-allowed";
|
|
44
|
+
|
|
45
|
+
function formatTime(ms: number): string {
|
|
46
|
+
return new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function ChatPanel({ entries, onSend, onClose }: ChatPanelProps) {
|
|
50
|
+
const [draft, setDraft] = useState("");
|
|
51
|
+
const [picker, setPicker] = useState<"emoji" | "gif" | null>(null);
|
|
52
|
+
const panelRef = useRef<HTMLElement>(null);
|
|
53
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
54
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
55
|
+
const emojiButtonRef = useRef<HTMLButtonElement>(null);
|
|
56
|
+
const gifButtonRef = useRef<HTMLButtonElement>(null);
|
|
57
|
+
const stickRef = useRef(true);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
inputRef.current?.focus();
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
// Only an Escape meant for the chat - with focus in it - closes it, not one closing a menu elsewhere.
|
|
65
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
66
|
+
if (event.key === "Escape" && !picker && panelRef.current?.contains(document.activeElement)) {
|
|
67
|
+
onClose();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
document.addEventListener("keydown", onKeyDown);
|
|
71
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
72
|
+
}, [onClose, picker]);
|
|
73
|
+
|
|
74
|
+
useLayoutEffect(() => {
|
|
75
|
+
const list = listRef.current;
|
|
76
|
+
if (list && stickRef.current) {
|
|
77
|
+
list.scrollTop = list.scrollHeight;
|
|
78
|
+
}
|
|
79
|
+
}, [entries]);
|
|
80
|
+
|
|
81
|
+
function handleScroll() {
|
|
82
|
+
const list = listRef.current!;
|
|
83
|
+
stickRef.current = list.scrollHeight - list.scrollTop - list.clientHeight <= STICK_THRESHOLD_PX;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function send(text: string) {
|
|
87
|
+
if (onSend(text)) {
|
|
88
|
+
stickRef.current = true;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function handleSubmit(event?: React.FormEvent) {
|
|
95
|
+
event?.preventDefault();
|
|
96
|
+
if (send(draft)) {
|
|
97
|
+
setDraft("");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
102
|
+
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
handleSubmit();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function insertEmoji(native: string) {
|
|
109
|
+
const input = inputRef.current!;
|
|
110
|
+
const start = input.selectionStart ?? draft.length;
|
|
111
|
+
const end = input.selectionEnd ?? draft.length;
|
|
112
|
+
setPicker(null);
|
|
113
|
+
const next = draft.slice(0, start) + native + draft.slice(end);
|
|
114
|
+
// Past the limit the emoji is left out whole, never cut in half.
|
|
115
|
+
if (next.length > MAX_CHAT_LENGTH) {
|
|
116
|
+
input.focus();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
setDraft(next);
|
|
120
|
+
requestAnimationFrame(() => {
|
|
121
|
+
input.focus();
|
|
122
|
+
input.setSelectionRange(start + native.length, start + native.length);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sendGif(url: string) {
|
|
127
|
+
setPicker(null);
|
|
128
|
+
send(url);
|
|
129
|
+
inputRef.current?.focus();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const remaining = MAX_CHAT_LENGTH - draft.length;
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<aside ref={panelRef} role="dialog" aria-label="Chat" className="relative z-10 shrink-0 w-80 max-w-[85vw] h-full flex flex-col bg-[#2b2d30] text-white shadow-xl border-l border-white/10">
|
|
136
|
+
<div className="flex items-center justify-between gap-2 px-3 py-3 border-b border-white/10">
|
|
137
|
+
<h2 className="text-sm font-medium">Chat</h2>
|
|
138
|
+
<button type="button" className={ICON_BUTTON} aria-label="Close chat" onClick={onClose}>
|
|
139
|
+
✕
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
<div ref={listRef} onScroll={handleScroll} className="flex-1 min-h-0 overflow-y-auto px-3 py-2" role="log" aria-label="Messages" aria-live="polite">
|
|
143
|
+
{entries.length === 0 && <p className="mt-6 text-center text-sm text-white/60">Messages are visible to everyone in the call and disappear when it ends.</p>}
|
|
144
|
+
{entries.map((entry, index) => {
|
|
145
|
+
const previous = entries[index - 1];
|
|
146
|
+
const grouped = !!previous && previous.peer === entry.peer && entry.sentAt - previous.sentAt < GROUP_WINDOW_MS && entry.sentAt >= previous.sentAt;
|
|
147
|
+
return (
|
|
148
|
+
<div key={entry.key} className={grouped ? "mt-1" : "mt-3"} data-testid="chat-message">
|
|
149
|
+
{!grouped && (
|
|
150
|
+
<p className="flex items-baseline gap-2 text-xs">
|
|
151
|
+
<span className="font-semibold text-white">{entry.self ? "You" : entry.name}</span>
|
|
152
|
+
<span className="text-white/50">{formatTime(entry.sentAt)}</span>
|
|
153
|
+
</p>
|
|
154
|
+
)}
|
|
155
|
+
<div className="mt-0.5 text-white/90">
|
|
156
|
+
<ChatMarkdown text={entry.text} />
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</div>
|
|
162
|
+
<form onSubmit={handleSubmit} className="shrink-0 border-t border-white/10 p-2">
|
|
163
|
+
<div className="flex items-end gap-1 rounded-2xl bg-[#3c4043] px-1 py-1">
|
|
164
|
+
<button ref={emojiButtonRef} type="button" className={ICON_BUTTON} aria-label="Insert emoji" aria-expanded={picker === "emoji"} onClick={() => setPicker((prev) => (prev === "emoji" ? null : "emoji"))}>
|
|
165
|
+
<EmojiIcon />
|
|
166
|
+
</button>
|
|
167
|
+
<button ref={gifButtonRef} type="button" className={ICON_BUTTON} aria-label="Send a GIF" aria-expanded={picker === "gif"} onClick={() => setPicker((prev) => (prev === "gif" ? null : "gif"))}>
|
|
168
|
+
<GifIcon />
|
|
169
|
+
</button>
|
|
170
|
+
<textarea
|
|
171
|
+
ref={inputRef}
|
|
172
|
+
rows={1}
|
|
173
|
+
value={draft}
|
|
174
|
+
maxLength={MAX_CHAT_LENGTH}
|
|
175
|
+
aria-label="Message"
|
|
176
|
+
placeholder="Send a message"
|
|
177
|
+
className="flex-1 min-w-0 max-h-32 resize-none bg-transparent py-2 px-1 text-sm placeholder:text-white/50 focus:outline-none [field-sizing:content]"
|
|
178
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
179
|
+
onKeyDown={handleKeyDown}
|
|
180
|
+
/>
|
|
181
|
+
<button type="submit" className={ICON_BUTTON} aria-label="Send message" disabled={!draft.trim()}>
|
|
182
|
+
<SendIcon />
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
<p className="mt-1 px-2 flex justify-between gap-2 text-[11px] text-white/50">
|
|
186
|
+
<span>**bold** *italic* `code` - Shift+Enter for a new line</span>
|
|
187
|
+
{remaining <= COUNTER_THRESHOLD && <span aria-live="polite">{remaining} left</span>}
|
|
188
|
+
</p>
|
|
189
|
+
</form>
|
|
190
|
+
{picker === "emoji" && <EmojiPicker anchorRef={emojiButtonRef} onSelect={insertEmoji} onClose={() => setPicker(null)} />}
|
|
191
|
+
{picker === "gif" && <GifPicker anchorRef={gifButtonRef} onSelect={sendGif} onClose={() => setPicker(null)} />}
|
|
192
|
+
</aside>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The whiteboard's custom color picker: a hue/saturation wheel (hue around it, saturation out from its center) and a
|
|
7
|
+
* brightness slider below it, for any color the swatches don't offer. Dragging on the wheel, or using the arrow keys
|
|
8
|
+
* on it (left/right turn the hue, up/down change the saturation), picks a color; `onChange` gets every pick as
|
|
9
|
+
* `#rrggbb`.
|
|
10
|
+
*/
|
|
11
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
12
|
+
import { hexToHsv, hsvToHex, hsvToRgb, wheelPoint, type Hsv } from "../shared/collab/color.js";
|
|
13
|
+
|
|
14
|
+
export interface ColorWheelProps {
|
|
15
|
+
color: string;
|
|
16
|
+
onChange: (color: string) => void;
|
|
17
|
+
/** The wheel's diameter, in CSS pixels. */
|
|
18
|
+
size?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** How far one arrow-key press turns the hue (degrees) or moves the saturation. */
|
|
22
|
+
const HUE_STEP = 5;
|
|
23
|
+
const SATURATION_STEP = 0.05;
|
|
24
|
+
|
|
25
|
+
export default function ColorWheel({ color, onChange, size = 168 }: ColorWheelProps) {
|
|
26
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
27
|
+
const [hsv, setHsv] = useState<Hsv>(() => hexToHsv(color));
|
|
28
|
+
const radius = size / 2;
|
|
29
|
+
|
|
30
|
+
// Follows a color chosen elsewhere (a swatch) - unless it is the one this wheel just produced, which would
|
|
31
|
+
// otherwise snap the hue of a gray (where hue is meaningless) back to red.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
setHsv((prev) => (hsvToHex(prev) === color ? prev : hexToHsv(color)));
|
|
34
|
+
}, [color]);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const ctx = canvasRef.current?.getContext("2d");
|
|
38
|
+
if (!ctx) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const image = ctx.createImageData(size, size);
|
|
42
|
+
for (let y = 0; y < size; y++) {
|
|
43
|
+
for (let x = 0; x < size; x++) {
|
|
44
|
+
const offset = (y * size + x) * 4;
|
|
45
|
+
if (Math.hypot(x - radius, y - radius) > radius) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const { h, s } = wheelPoint(x, y, radius);
|
|
49
|
+
const [r, g, b] = hsvToRgb({ h, s, v: hsv.v });
|
|
50
|
+
image.data[offset] = r;
|
|
51
|
+
image.data[offset + 1] = g;
|
|
52
|
+
image.data[offset + 2] = b;
|
|
53
|
+
image.data[offset + 3] = 255;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
ctx.putImageData(image, 0, 0);
|
|
57
|
+
}, [hsv.v, size, radius]);
|
|
58
|
+
|
|
59
|
+
function pick(next: Hsv) {
|
|
60
|
+
setHsv(next);
|
|
61
|
+
onChange(hsvToHex(next));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function pickAt(event: React.PointerEvent<HTMLCanvasElement>) {
|
|
65
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
66
|
+
const scale = rect.width > 0 ? size / rect.width : 1;
|
|
67
|
+
const { h, s } = wheelPoint((event.clientX - rect.left) * scale, (event.clientY - rect.top) * scale, radius);
|
|
68
|
+
pick({ ...hsv, h, s });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function handleKeyDown(event: React.KeyboardEvent) {
|
|
72
|
+
const moves: Record<string, Partial<Hsv>> = {
|
|
73
|
+
ArrowLeft: { h: (hsv.h - HUE_STEP + 360) % 360 },
|
|
74
|
+
ArrowRight: { h: (hsv.h + HUE_STEP) % 360 },
|
|
75
|
+
ArrowUp: { s: Math.min(1, hsv.s + SATURATION_STEP) },
|
|
76
|
+
ArrowDown: { s: Math.max(0, hsv.s - SATURATION_STEP) },
|
|
77
|
+
};
|
|
78
|
+
const move = moves[event.key];
|
|
79
|
+
if (move) {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
pick({ ...hsv, ...move });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const angle = (hsv.h * Math.PI) / 180;
|
|
86
|
+
const markerX = radius + Math.cos(angle) * hsv.s * radius;
|
|
87
|
+
const markerY = radius + Math.sin(angle) * hsv.s * radius;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="flex flex-col items-center gap-3" data-testid="color-wheel">
|
|
91
|
+
<div className="relative" style={{ width: size, height: size }}>
|
|
92
|
+
<canvas
|
|
93
|
+
ref={canvasRef}
|
|
94
|
+
width={size}
|
|
95
|
+
height={size}
|
|
96
|
+
tabIndex={0}
|
|
97
|
+
role="slider"
|
|
98
|
+
aria-label="Hue and saturation"
|
|
99
|
+
aria-valuemin={0}
|
|
100
|
+
aria-valuemax={359}
|
|
101
|
+
aria-valuenow={Math.round(hsv.h)}
|
|
102
|
+
aria-valuetext={`Hue ${Math.round(hsv.h)} degrees, saturation ${Math.round(hsv.s * 100)}%`}
|
|
103
|
+
className="rounded-full cursor-crosshair touch-none focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
|
104
|
+
onPointerDown={(event) => {
|
|
105
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
106
|
+
pickAt(event);
|
|
107
|
+
}}
|
|
108
|
+
onPointerMove={(event) => {
|
|
109
|
+
if (event.buttons & 1) {
|
|
110
|
+
pickAt(event);
|
|
111
|
+
}
|
|
112
|
+
}}
|
|
113
|
+
onKeyDown={handleKeyDown}
|
|
114
|
+
/>
|
|
115
|
+
<span
|
|
116
|
+
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)]"
|
|
117
|
+
style={{ left: markerX, top: markerY, backgroundColor: hsvToHex(hsv) }}
|
|
118
|
+
aria-hidden="true"
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
<label className="w-full flex items-center gap-2 text-xs text-white/70">
|
|
122
|
+
<span className="shrink-0">Brightness</span>
|
|
123
|
+
<input
|
|
124
|
+
type="range"
|
|
125
|
+
min={0}
|
|
126
|
+
max={100}
|
|
127
|
+
value={Math.round(hsv.v * 100)}
|
|
128
|
+
aria-label="Brightness"
|
|
129
|
+
className="flex-1"
|
|
130
|
+
style={{ accentColor: hsvToHex({ ...hsv, v: Math.max(hsv.v, 0.3) }) }}
|
|
131
|
+
onChange={(event) => pick({ ...hsv, v: Number(event.target.value) / 100 })}
|
|
132
|
+
/>
|
|
133
|
+
</label>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|