@cloudsignal/collaborate 0.1.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/PROMPT.md +128 -0
- package/README.md +192 -0
- package/dist/index.cjs +1182 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.mts +409 -0
- package/dist/index.d.ts +409 -0
- package/dist/index.js +1153 -0
- package/dist/index.js.map +1 -0
- package/llms.txt +45 -0
- package/package.json +60 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
import { createContext, useState, useRef, useEffect, useMemo, useCallback, useContext } from 'react';
|
|
2
|
+
import CloudSignalClient from '@cloudsignal/mqtt-client';
|
|
3
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @cloudsignal/collaborate v0.1.0
|
|
7
|
+
* Real-time collaboration primitives for React
|
|
8
|
+
* https://cloudsignal.io
|
|
9
|
+
* MIT License
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
var SpaceContext = createContext(null);
|
|
13
|
+
|
|
14
|
+
// src/utils/colors.ts
|
|
15
|
+
var CURSOR_COLORS = [
|
|
16
|
+
"#3B82F6",
|
|
17
|
+
// Blue
|
|
18
|
+
"#EF4444",
|
|
19
|
+
// Red
|
|
20
|
+
"#22C55E",
|
|
21
|
+
// Green
|
|
22
|
+
"#A855F7",
|
|
23
|
+
// Purple
|
|
24
|
+
"#F97316",
|
|
25
|
+
// Orange
|
|
26
|
+
"#EC4899",
|
|
27
|
+
// Pink
|
|
28
|
+
"#14B8A6",
|
|
29
|
+
// Teal
|
|
30
|
+
"#EAB308",
|
|
31
|
+
// Yellow
|
|
32
|
+
"#6366F1",
|
|
33
|
+
// Indigo
|
|
34
|
+
"#F43F5E"
|
|
35
|
+
// Rose
|
|
36
|
+
];
|
|
37
|
+
function getColorForUser(userId) {
|
|
38
|
+
let hash = 0;
|
|
39
|
+
for (let i = 0; i < userId.length; i++) {
|
|
40
|
+
hash = (hash << 5) - hash + userId.charCodeAt(i) | 0;
|
|
41
|
+
}
|
|
42
|
+
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
|
|
43
|
+
}
|
|
44
|
+
function getInitials(name) {
|
|
45
|
+
return name.split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/constants.ts
|
|
49
|
+
var TOPIC_PREFIX = "$spaces";
|
|
50
|
+
var TOPICS = {
|
|
51
|
+
PRESENCE: "presence",
|
|
52
|
+
CURSORS: "cursors",
|
|
53
|
+
LOCKS: "locks",
|
|
54
|
+
TYPING: "typing",
|
|
55
|
+
REACTIONS: "reactions",
|
|
56
|
+
BROADCAST: "broadcast",
|
|
57
|
+
STATE: "state"
|
|
58
|
+
};
|
|
59
|
+
var DEFAULTS = {
|
|
60
|
+
/** Presence heartbeat interval (ms) */
|
|
61
|
+
PRESENCE_HEARTBEAT_MS: 1e4,
|
|
62
|
+
/** Time before a user is considered offline (ms) */
|
|
63
|
+
PRESENCE_TIMEOUT_MS: 3e4,
|
|
64
|
+
/** Cursor publish throttle (ms) — ~33Hz */
|
|
65
|
+
CURSOR_THROTTLE_MS: 30,
|
|
66
|
+
/** Time before a cursor fades out (ms) */
|
|
67
|
+
CURSOR_STALE_MS: 3e3,
|
|
68
|
+
/** Typing indicator auto-reset (ms) */
|
|
69
|
+
TYPING_TIMEOUT_MS: 3e3,
|
|
70
|
+
/** Minimum interval between typing publishes (ms) */
|
|
71
|
+
TYPING_THROTTLE_MS: 2e3,
|
|
72
|
+
/** Time before a typing user is cleaned from the list (ms) */
|
|
73
|
+
TYPING_STALE_MS: 4e3,
|
|
74
|
+
/** How long a reaction stays visible (ms) */
|
|
75
|
+
REACTION_DURATION_MS: 3e3,
|
|
76
|
+
/** Max visible reactions at once */
|
|
77
|
+
REACTION_MAX_VISIBLE: 20,
|
|
78
|
+
/** Stats sync interval (ms) — for cursor count etc. */
|
|
79
|
+
STATS_TICK_MS: 1e3,
|
|
80
|
+
/** Opacity fade-out sync interval (ms) */
|
|
81
|
+
FADE_TICK_MS: 200
|
|
82
|
+
};
|
|
83
|
+
function spaceTopic(spaceId, segment) {
|
|
84
|
+
return `${TOPIC_PREFIX}/${spaceId}/${segment}`;
|
|
85
|
+
}
|
|
86
|
+
function spaceWildcard(spaceId) {
|
|
87
|
+
return `${TOPIC_PREFIX}/${spaceId}/#`;
|
|
88
|
+
}
|
|
89
|
+
function Space({
|
|
90
|
+
id,
|
|
91
|
+
connection,
|
|
92
|
+
userName,
|
|
93
|
+
userColor,
|
|
94
|
+
userAvatar,
|
|
95
|
+
userData,
|
|
96
|
+
debug = false,
|
|
97
|
+
presenceHeartbeatMs = DEFAULTS.PRESENCE_HEARTBEAT_MS,
|
|
98
|
+
presenceTimeoutMs = DEFAULTS.PRESENCE_TIMEOUT_MS,
|
|
99
|
+
onConnectionChange,
|
|
100
|
+
children
|
|
101
|
+
}) {
|
|
102
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
103
|
+
const [isConnecting, setIsConnecting] = useState(false);
|
|
104
|
+
const [error, setError] = useState(null);
|
|
105
|
+
const clientRef = useRef(null);
|
|
106
|
+
const connectingRef = useRef(false);
|
|
107
|
+
const mountedRef = useRef(true);
|
|
108
|
+
const handlersRef = useRef(/* @__PURE__ */ new Map());
|
|
109
|
+
const messageHandlerRef = useRef(null);
|
|
110
|
+
const onConnectionChangeRef = useRef(onConnectionChange);
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
onConnectionChangeRef.current = onConnectionChange;
|
|
113
|
+
}, [onConnectionChange]);
|
|
114
|
+
const resolvedColor = userColor || getColorForUser(userName);
|
|
115
|
+
const self = useMemo(() => ({
|
|
116
|
+
userId: connection.username || userName,
|
|
117
|
+
name: userName,
|
|
118
|
+
color: resolvedColor,
|
|
119
|
+
avatar: userAvatar,
|
|
120
|
+
data: userData,
|
|
121
|
+
joinedAt: Date.now(),
|
|
122
|
+
lastSeen: Date.now()
|
|
123
|
+
}), [connection.username, userName, resolvedColor, userAvatar, userData]);
|
|
124
|
+
const selfRef = useRef(self);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
selfRef.current = self;
|
|
127
|
+
}, [self]);
|
|
128
|
+
const log = useCallback((...args) => {
|
|
129
|
+
if (debug) console.log("[CloudSignal:Space]", ...args);
|
|
130
|
+
}, [debug]);
|
|
131
|
+
const addTopicHandler = useCallback((segment, handler) => {
|
|
132
|
+
if (!handlersRef.current.has(segment)) {
|
|
133
|
+
handlersRef.current.set(segment, /* @__PURE__ */ new Set());
|
|
134
|
+
}
|
|
135
|
+
handlersRef.current.get(segment).add(handler);
|
|
136
|
+
return () => {
|
|
137
|
+
handlersRef.current.get(segment)?.delete(handler);
|
|
138
|
+
};
|
|
139
|
+
}, []);
|
|
140
|
+
const routeMessage = useCallback((topic, messageStr) => {
|
|
141
|
+
const prefix = `${TOPIC_PREFIX}/${id}/`;
|
|
142
|
+
if (!topic.startsWith(prefix)) return;
|
|
143
|
+
const subtopic = topic.slice(prefix.length);
|
|
144
|
+
const segment = subtopic.split("/")[0];
|
|
145
|
+
let payload;
|
|
146
|
+
try {
|
|
147
|
+
payload = JSON.parse(messageStr);
|
|
148
|
+
} catch {
|
|
149
|
+
payload = messageStr;
|
|
150
|
+
}
|
|
151
|
+
log("\u2190", segment, subtopic);
|
|
152
|
+
const handlers = handlersRef.current.get(segment);
|
|
153
|
+
if (handlers) {
|
|
154
|
+
for (const handler of handlers) {
|
|
155
|
+
handler(subtopic, payload);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}, [id, log]);
|
|
159
|
+
const publish = useCallback((subtopic, payload, options) => {
|
|
160
|
+
if (!clientRef.current) {
|
|
161
|
+
log("Cannot publish: not connected");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const topic = spaceTopic(id, subtopic);
|
|
165
|
+
const message = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
166
|
+
clientRef.current.transmit(topic, message, options);
|
|
167
|
+
log("\u2192", subtopic);
|
|
168
|
+
}, [id, log]);
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!isConnected) return;
|
|
171
|
+
const sendHeartbeat = () => {
|
|
172
|
+
publish(TOPICS.PRESENCE, {
|
|
173
|
+
userId: selfRef.current.userId,
|
|
174
|
+
name: selfRef.current.name,
|
|
175
|
+
color: selfRef.current.color,
|
|
176
|
+
avatar: selfRef.current.avatar,
|
|
177
|
+
data: selfRef.current.data,
|
|
178
|
+
ts: Date.now()
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
sendHeartbeat();
|
|
182
|
+
const interval = setInterval(sendHeartbeat, presenceHeartbeatMs);
|
|
183
|
+
return () => clearInterval(interval);
|
|
184
|
+
}, [isConnected, publish, presenceHeartbeatMs]);
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (!isConnected) return;
|
|
187
|
+
const handleUnload = () => {
|
|
188
|
+
publish(TOPICS.PRESENCE, {
|
|
189
|
+
userId: selfRef.current.userId,
|
|
190
|
+
type: "leave",
|
|
191
|
+
ts: Date.now()
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
window.addEventListener("beforeunload", handleUnload);
|
|
195
|
+
return () => window.removeEventListener("beforeunload", handleUnload);
|
|
196
|
+
}, [isConnected, publish]);
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
mountedRef.current = true;
|
|
199
|
+
const doConnect = async () => {
|
|
200
|
+
if (connectingRef.current || clientRef.current) return;
|
|
201
|
+
connectingRef.current = true;
|
|
202
|
+
setIsConnecting(true);
|
|
203
|
+
setError(null);
|
|
204
|
+
try {
|
|
205
|
+
const clientOptions = {
|
|
206
|
+
debug,
|
|
207
|
+
preset: "desktop"
|
|
208
|
+
};
|
|
209
|
+
if (connection.tokenServiceUrl) {
|
|
210
|
+
clientOptions.tokenServiceUrl = connection.tokenServiceUrl;
|
|
211
|
+
}
|
|
212
|
+
const client = new CloudSignalClient(clientOptions);
|
|
213
|
+
client.onConnectionStatusChange = (connected) => {
|
|
214
|
+
log("Connection:", connected);
|
|
215
|
+
if (mountedRef.current) {
|
|
216
|
+
setIsConnected(connected);
|
|
217
|
+
onConnectionChangeRef.current?.(connected);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
client.onReconnecting = (attempt) => {
|
|
221
|
+
log("Reconnecting, attempt:", attempt);
|
|
222
|
+
};
|
|
223
|
+
client.onAuthError = (err) => {
|
|
224
|
+
log("Auth error:", err.message);
|
|
225
|
+
if (mountedRef.current) {
|
|
226
|
+
setError(err);
|
|
227
|
+
setIsConnected(false);
|
|
228
|
+
}
|
|
229
|
+
clientRef.current = null;
|
|
230
|
+
};
|
|
231
|
+
const handler = (topic, message) => routeMessage(topic, message);
|
|
232
|
+
messageHandlerRef.current = handler;
|
|
233
|
+
client.onMessage(handler);
|
|
234
|
+
const willPayload = JSON.stringify({
|
|
235
|
+
userId: selfRef.current.userId,
|
|
236
|
+
type: "leave",
|
|
237
|
+
ts: Date.now()
|
|
238
|
+
});
|
|
239
|
+
if (connection.secretKey || connection.externalToken) {
|
|
240
|
+
await client.connectWithToken({
|
|
241
|
+
host: connection.host,
|
|
242
|
+
organizationId: connection.organizationId,
|
|
243
|
+
secretKey: connection.secretKey,
|
|
244
|
+
externalToken: connection.externalToken,
|
|
245
|
+
willTopic: spaceTopic(id, TOPICS.PRESENCE),
|
|
246
|
+
willMessage: willPayload,
|
|
247
|
+
willQos: 0
|
|
248
|
+
});
|
|
249
|
+
} else {
|
|
250
|
+
await client.connect({
|
|
251
|
+
host: connection.host,
|
|
252
|
+
username: connection.username,
|
|
253
|
+
password: connection.password,
|
|
254
|
+
willTopic: spaceTopic(id, TOPICS.PRESENCE),
|
|
255
|
+
willMessage: willPayload,
|
|
256
|
+
willQos: 0
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (!mountedRef.current) {
|
|
260
|
+
client.destroy();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
clientRef.current = client;
|
|
264
|
+
await client.subscribe(spaceWildcard(id), 0);
|
|
265
|
+
log("Subscribed to", spaceWildcard(id));
|
|
266
|
+
} catch (err) {
|
|
267
|
+
log("Connection failed:", err);
|
|
268
|
+
if (mountedRef.current) {
|
|
269
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
connectingRef.current = false;
|
|
273
|
+
if (mountedRef.current) setIsConnecting(false);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
doConnect();
|
|
277
|
+
return () => {
|
|
278
|
+
mountedRef.current = false;
|
|
279
|
+
if (clientRef.current) {
|
|
280
|
+
try {
|
|
281
|
+
const leaveTopic = spaceTopic(id, TOPICS.PRESENCE);
|
|
282
|
+
const leavePayload = JSON.stringify({
|
|
283
|
+
userId: selfRef.current.userId,
|
|
284
|
+
type: "leave",
|
|
285
|
+
ts: Date.now()
|
|
286
|
+
});
|
|
287
|
+
clientRef.current.transmit(leaveTopic, leavePayload, { qos: 0 });
|
|
288
|
+
} catch {
|
|
289
|
+
}
|
|
290
|
+
clientRef.current.destroy();
|
|
291
|
+
clientRef.current = null;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
}, [id, connection.host, connection.username, connection.password, connection.secretKey, connection.externalToken, connection.organizationId]);
|
|
295
|
+
const contextValue = useMemo(() => ({
|
|
296
|
+
spaceId: id,
|
|
297
|
+
self,
|
|
298
|
+
isConnected,
|
|
299
|
+
isConnecting,
|
|
300
|
+
error,
|
|
301
|
+
presenceTimeoutMs,
|
|
302
|
+
publish,
|
|
303
|
+
addTopicHandler
|
|
304
|
+
}), [id, self, isConnected, isConnecting, error, presenceTimeoutMs, publish, addTopicHandler]);
|
|
305
|
+
return /* @__PURE__ */ jsx(SpaceContext.Provider, { value: contextValue, children });
|
|
306
|
+
}
|
|
307
|
+
function useSpace() {
|
|
308
|
+
const ctx = useContext(SpaceContext);
|
|
309
|
+
if (!ctx) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
'useSpace() must be used within a <Space> provider. Wrap your component tree with <Space id="..." connection={...}>'
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return ctx;
|
|
315
|
+
}
|
|
316
|
+
function usePresence() {
|
|
317
|
+
const { self, addTopicHandler, presenceTimeoutMs } = useSpace();
|
|
318
|
+
const [members, setMembers] = useState([self]);
|
|
319
|
+
const membersMapRef = useRef(/* @__PURE__ */ new Map());
|
|
320
|
+
const joinCallbacksRef = useRef(/* @__PURE__ */ new Set());
|
|
321
|
+
const leaveCallbacksRef = useRef(/* @__PURE__ */ new Set());
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
membersMapRef.current.set(self.userId, self);
|
|
324
|
+
setMembers([self]);
|
|
325
|
+
}, [self]);
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
const unsubscribe = addTopicHandler(TOPICS.PRESENCE, (_subtopic, payload) => {
|
|
328
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
329
|
+
const data = payload;
|
|
330
|
+
const userId = data.userId;
|
|
331
|
+
if (!userId) return;
|
|
332
|
+
if (data.type === "leave") {
|
|
333
|
+
const user2 = membersMapRef.current.get(userId);
|
|
334
|
+
if (user2) {
|
|
335
|
+
membersMapRef.current.delete(userId);
|
|
336
|
+
setMembers(Array.from(membersMapRef.current.values()));
|
|
337
|
+
for (const cb of leaveCallbacksRef.current) cb(user2);
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const isNew = !membersMapRef.current.has(userId);
|
|
342
|
+
const user = {
|
|
343
|
+
userId,
|
|
344
|
+
name: data.name || userId,
|
|
345
|
+
color: data.color || "#3B82F6",
|
|
346
|
+
avatar: data.avatar,
|
|
347
|
+
data: data.data,
|
|
348
|
+
joinedAt: isNew ? Date.now() : membersMapRef.current.get(userId)?.joinedAt ?? Date.now(),
|
|
349
|
+
lastSeen: Date.now()
|
|
350
|
+
};
|
|
351
|
+
membersMapRef.current.set(userId, user);
|
|
352
|
+
setMembers(Array.from(membersMapRef.current.values()));
|
|
353
|
+
if (isNew) {
|
|
354
|
+
for (const cb of joinCallbacksRef.current) cb(user);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
return unsubscribe;
|
|
358
|
+
}, [addTopicHandler]);
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
const interval = setInterval(() => {
|
|
361
|
+
const now = Date.now();
|
|
362
|
+
let changed = false;
|
|
363
|
+
for (const [userId, user] of membersMapRef.current) {
|
|
364
|
+
if (userId === self.userId) continue;
|
|
365
|
+
if (now - user.lastSeen > presenceTimeoutMs) {
|
|
366
|
+
membersMapRef.current.delete(userId);
|
|
367
|
+
changed = true;
|
|
368
|
+
for (const cb of leaveCallbacksRef.current) cb(user);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (changed) {
|
|
372
|
+
setMembers(Array.from(membersMapRef.current.values()));
|
|
373
|
+
}
|
|
374
|
+
}, 5e3);
|
|
375
|
+
return () => clearInterval(interval);
|
|
376
|
+
}, [self.userId, presenceTimeoutMs]);
|
|
377
|
+
const onJoin = useCallback((callback) => {
|
|
378
|
+
joinCallbacksRef.current.add(callback);
|
|
379
|
+
return () => {
|
|
380
|
+
joinCallbacksRef.current.delete(callback);
|
|
381
|
+
};
|
|
382
|
+
}, []);
|
|
383
|
+
const onLeave = useCallback((callback) => {
|
|
384
|
+
leaveCallbacksRef.current.add(callback);
|
|
385
|
+
return () => {
|
|
386
|
+
leaveCallbacksRef.current.delete(callback);
|
|
387
|
+
};
|
|
388
|
+
}, []);
|
|
389
|
+
return {
|
|
390
|
+
members,
|
|
391
|
+
count: members.length,
|
|
392
|
+
self,
|
|
393
|
+
onJoin,
|
|
394
|
+
onLeave
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function useCursors(options = {}) {
|
|
398
|
+
const {
|
|
399
|
+
throttleMs = DEFAULTS.CURSOR_THROTTLE_MS,
|
|
400
|
+
staleMs = DEFAULTS.CURSOR_STALE_MS
|
|
401
|
+
} = options;
|
|
402
|
+
const { self, publish, addTopicHandler } = useSpace();
|
|
403
|
+
const cursorsRef = useRef(/* @__PURE__ */ new Map());
|
|
404
|
+
const onUpdateRef = useRef(null);
|
|
405
|
+
const lastPublishRef = useRef(0);
|
|
406
|
+
const throttleMsRef = useRef(throttleMs);
|
|
407
|
+
const [cursors, setCursors] = useState([]);
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
throttleMsRef.current = throttleMs;
|
|
410
|
+
}, [throttleMs]);
|
|
411
|
+
useEffect(() => {
|
|
412
|
+
const unsubscribe = addTopicHandler(TOPICS.CURSORS, (_subtopic, payload) => {
|
|
413
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
414
|
+
const data = payload;
|
|
415
|
+
const userId = data.userId;
|
|
416
|
+
if (!userId || userId === self.userId) return;
|
|
417
|
+
const now = Date.now();
|
|
418
|
+
cursorsRef.current.set(userId, {
|
|
419
|
+
userId,
|
|
420
|
+
name: data.name || userId,
|
|
421
|
+
x: data.x || 0,
|
|
422
|
+
y: data.y || 0,
|
|
423
|
+
color: data.color || "#3B82F6",
|
|
424
|
+
ts: data.ts || now,
|
|
425
|
+
lastSeen: now
|
|
426
|
+
});
|
|
427
|
+
onUpdateRef.current?.();
|
|
428
|
+
});
|
|
429
|
+
return unsubscribe;
|
|
430
|
+
}, [addTopicHandler, self.userId]);
|
|
431
|
+
const publishCursor = useCallback((x, y) => {
|
|
432
|
+
const now = Date.now();
|
|
433
|
+
if (now - lastPublishRef.current < throttleMsRef.current) return;
|
|
434
|
+
lastPublishRef.current = now;
|
|
435
|
+
publish(TOPICS.CURSORS, {
|
|
436
|
+
userId: self.userId,
|
|
437
|
+
name: self.name,
|
|
438
|
+
x,
|
|
439
|
+
y,
|
|
440
|
+
color: self.color,
|
|
441
|
+
ts: now
|
|
442
|
+
}, { qos: 0 });
|
|
443
|
+
}, [publish, self.userId, self.name, self.color]);
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
const interval = setInterval(() => {
|
|
446
|
+
const now = Date.now();
|
|
447
|
+
let changed = false;
|
|
448
|
+
for (const [id, cursor] of cursorsRef.current) {
|
|
449
|
+
if (now - cursor.lastSeen > staleMs) {
|
|
450
|
+
cursorsRef.current.delete(id);
|
|
451
|
+
changed = true;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (changed) {
|
|
455
|
+
onUpdateRef.current?.();
|
|
456
|
+
}
|
|
457
|
+
setCursors(Array.from(cursorsRef.current.values()));
|
|
458
|
+
}, DEFAULTS.STATS_TICK_MS);
|
|
459
|
+
return () => clearInterval(interval);
|
|
460
|
+
}, [staleMs]);
|
|
461
|
+
return {
|
|
462
|
+
cursorsRef,
|
|
463
|
+
onUpdateRef,
|
|
464
|
+
cursors,
|
|
465
|
+
publishCursor
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
function useLock(componentId) {
|
|
469
|
+
const { self, publish, addTopicHandler } = useSpace();
|
|
470
|
+
const [lockedBy, setLockedBy] = useState(null);
|
|
471
|
+
const lockedByRef = useRef(null);
|
|
472
|
+
const isLockedByMeRef = useRef(false);
|
|
473
|
+
const publishRef = useRef(publish);
|
|
474
|
+
const selfUserIdRef = useRef(self.userId);
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
publishRef.current = publish;
|
|
477
|
+
}, [publish]);
|
|
478
|
+
useEffect(() => {
|
|
479
|
+
selfUserIdRef.current = self.userId;
|
|
480
|
+
}, [self.userId]);
|
|
481
|
+
const isLocked = lockedBy !== null;
|
|
482
|
+
const isLockedByMe = lockedBy?.userId === self.userId;
|
|
483
|
+
useEffect(() => {
|
|
484
|
+
const unsubscribe = addTopicHandler(TOPICS.LOCKS, (_subtopic, payload) => {
|
|
485
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
486
|
+
const data = payload;
|
|
487
|
+
if (data.componentId !== componentId) return;
|
|
488
|
+
const action = data.action;
|
|
489
|
+
if (action === "lock") {
|
|
490
|
+
const user = {
|
|
491
|
+
userId: data.userId,
|
|
492
|
+
name: data.name || data.userId,
|
|
493
|
+
color: data.color || "#3B82F6",
|
|
494
|
+
joinedAt: Date.now(),
|
|
495
|
+
lastSeen: Date.now()
|
|
496
|
+
};
|
|
497
|
+
lockedByRef.current = user;
|
|
498
|
+
isLockedByMeRef.current = user.userId === self.userId;
|
|
499
|
+
setLockedBy(user);
|
|
500
|
+
} else if (action === "unlock") {
|
|
501
|
+
lockedByRef.current = null;
|
|
502
|
+
isLockedByMeRef.current = false;
|
|
503
|
+
setLockedBy(null);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
return unsubscribe;
|
|
507
|
+
}, [addTopicHandler, componentId, self.userId]);
|
|
508
|
+
const lock = useCallback(() => {
|
|
509
|
+
if (lockedByRef.current && lockedByRef.current.userId !== self.userId) return;
|
|
510
|
+
publish(TOPICS.LOCKS, {
|
|
511
|
+
userId: self.userId,
|
|
512
|
+
name: self.name,
|
|
513
|
+
color: self.color,
|
|
514
|
+
componentId,
|
|
515
|
+
action: "lock",
|
|
516
|
+
ts: Date.now()
|
|
517
|
+
}, { qos: 1 });
|
|
518
|
+
}, [publish, self, componentId]);
|
|
519
|
+
const unlock = useCallback(() => {
|
|
520
|
+
if (!isLockedByMeRef.current) return;
|
|
521
|
+
publish(TOPICS.LOCKS, {
|
|
522
|
+
userId: self.userId,
|
|
523
|
+
componentId,
|
|
524
|
+
action: "unlock",
|
|
525
|
+
ts: Date.now()
|
|
526
|
+
}, { qos: 1 });
|
|
527
|
+
}, [publish, self.userId, componentId]);
|
|
528
|
+
useEffect(() => {
|
|
529
|
+
return () => {
|
|
530
|
+
if (isLockedByMeRef.current) {
|
|
531
|
+
publishRef.current(TOPICS.LOCKS, {
|
|
532
|
+
userId: selfUserIdRef.current,
|
|
533
|
+
componentId,
|
|
534
|
+
action: "unlock",
|
|
535
|
+
ts: Date.now()
|
|
536
|
+
}, { qos: 1 });
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
}, [componentId]);
|
|
540
|
+
return { isLocked, lockedBy, isLockedByMe, lock, unlock };
|
|
541
|
+
}
|
|
542
|
+
function useTypingIndicator(inputId) {
|
|
543
|
+
const { self, publish, addTopicHandler } = useSpace();
|
|
544
|
+
const [typingUsers, setTypingUsers] = useState([]);
|
|
545
|
+
const [isTyping, setIsTyping] = useState(false);
|
|
546
|
+
const typingMapRef = useRef(/* @__PURE__ */ new Map());
|
|
547
|
+
const lastPublishRef = useRef(0);
|
|
548
|
+
const stopTimerRef = useRef(null);
|
|
549
|
+
useEffect(() => {
|
|
550
|
+
const unsubscribe = addTopicHandler(TOPICS.TYPING, (_subtopic, payload) => {
|
|
551
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
552
|
+
const data = payload;
|
|
553
|
+
const userId = data.userId;
|
|
554
|
+
if (!userId || userId === self.userId) return;
|
|
555
|
+
if (inputId && data.inputId !== inputId) return;
|
|
556
|
+
const isCurrentlyTyping = data.isTyping;
|
|
557
|
+
if (isCurrentlyTyping) {
|
|
558
|
+
typingMapRef.current.set(userId, {
|
|
559
|
+
userId,
|
|
560
|
+
name: data.name || userId,
|
|
561
|
+
color: data.color || "#3B82F6",
|
|
562
|
+
joinedAt: Date.now(),
|
|
563
|
+
lastSeen: Date.now()
|
|
564
|
+
});
|
|
565
|
+
} else {
|
|
566
|
+
typingMapRef.current.delete(userId);
|
|
567
|
+
}
|
|
568
|
+
setTypingUsers(Array.from(typingMapRef.current.values()));
|
|
569
|
+
});
|
|
570
|
+
return unsubscribe;
|
|
571
|
+
}, [addTopicHandler, self.userId, inputId]);
|
|
572
|
+
useEffect(() => {
|
|
573
|
+
const interval = setInterval(() => {
|
|
574
|
+
const now = Date.now();
|
|
575
|
+
let changed = false;
|
|
576
|
+
for (const [userId, user] of typingMapRef.current) {
|
|
577
|
+
if (now - user.lastSeen > DEFAULTS.TYPING_STALE_MS) {
|
|
578
|
+
typingMapRef.current.delete(userId);
|
|
579
|
+
changed = true;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (changed) {
|
|
583
|
+
setTypingUsers(Array.from(typingMapRef.current.values()));
|
|
584
|
+
}
|
|
585
|
+
}, 2e3);
|
|
586
|
+
return () => clearInterval(interval);
|
|
587
|
+
}, []);
|
|
588
|
+
const publishTyping = useCallback((typing) => {
|
|
589
|
+
publish(TOPICS.TYPING, {
|
|
590
|
+
userId: self.userId,
|
|
591
|
+
name: self.name,
|
|
592
|
+
color: self.color,
|
|
593
|
+
inputId,
|
|
594
|
+
isTyping: typing,
|
|
595
|
+
ts: Date.now()
|
|
596
|
+
}, { qos: 0 });
|
|
597
|
+
}, [publish, self, inputId]);
|
|
598
|
+
const startTyping = useCallback(() => {
|
|
599
|
+
const now = Date.now();
|
|
600
|
+
if (now - lastPublishRef.current >= DEFAULTS.TYPING_THROTTLE_MS) {
|
|
601
|
+
lastPublishRef.current = now;
|
|
602
|
+
publishTyping(true);
|
|
603
|
+
}
|
|
604
|
+
setIsTyping(true);
|
|
605
|
+
if (stopTimerRef.current) clearTimeout(stopTimerRef.current);
|
|
606
|
+
stopTimerRef.current = setTimeout(() => {
|
|
607
|
+
publishTyping(false);
|
|
608
|
+
setIsTyping(false);
|
|
609
|
+
}, DEFAULTS.TYPING_TIMEOUT_MS);
|
|
610
|
+
}, [publishTyping]);
|
|
611
|
+
const stopTyping = useCallback(() => {
|
|
612
|
+
if (stopTimerRef.current) {
|
|
613
|
+
clearTimeout(stopTimerRef.current);
|
|
614
|
+
stopTimerRef.current = null;
|
|
615
|
+
}
|
|
616
|
+
publishTyping(false);
|
|
617
|
+
setIsTyping(false);
|
|
618
|
+
}, [publishTyping]);
|
|
619
|
+
const publishTypingRef = useRef(publishTyping);
|
|
620
|
+
useEffect(() => {
|
|
621
|
+
publishTypingRef.current = publishTyping;
|
|
622
|
+
}, [publishTyping]);
|
|
623
|
+
useEffect(() => {
|
|
624
|
+
return () => {
|
|
625
|
+
if (stopTimerRef.current) {
|
|
626
|
+
clearTimeout(stopTimerRef.current);
|
|
627
|
+
publishTypingRef.current(false);
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
}, []);
|
|
631
|
+
return { typingUsers, startTyping, stopTyping, isTyping };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// src/utils/uid.ts
|
|
635
|
+
function shortId() {
|
|
636
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
637
|
+
return crypto.randomUUID().slice(0, 8);
|
|
638
|
+
}
|
|
639
|
+
return Math.random().toString(36).slice(2, 10);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// src/hooks/useReactions.ts
|
|
643
|
+
function useReactions(options = {}) {
|
|
644
|
+
const {
|
|
645
|
+
maxVisible = DEFAULTS.REACTION_MAX_VISIBLE,
|
|
646
|
+
durationMs = DEFAULTS.REACTION_DURATION_MS
|
|
647
|
+
} = options;
|
|
648
|
+
const { self, publish, addTopicHandler } = useSpace();
|
|
649
|
+
const [reactions, setReactions] = useState([]);
|
|
650
|
+
const timersRef = useRef(/* @__PURE__ */ new Map());
|
|
651
|
+
const addReaction = useCallback((reaction) => {
|
|
652
|
+
const positioned = reaction.x != null ? reaction : { ...reaction, x: Math.random() * 80 + 10 };
|
|
653
|
+
setReactions((prev) => {
|
|
654
|
+
const next = [...prev, positioned];
|
|
655
|
+
return next.length > maxVisible ? next.slice(-maxVisible) : next;
|
|
656
|
+
});
|
|
657
|
+
const timer = setTimeout(() => {
|
|
658
|
+
setReactions((prev) => prev.filter((r) => r.id !== reaction.id));
|
|
659
|
+
timersRef.current.delete(reaction.id);
|
|
660
|
+
}, durationMs);
|
|
661
|
+
timersRef.current.set(reaction.id, timer);
|
|
662
|
+
}, [maxVisible, durationMs]);
|
|
663
|
+
useEffect(() => {
|
|
664
|
+
const unsubscribe = addTopicHandler(TOPICS.REACTIONS, (_subtopic, payload) => {
|
|
665
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
666
|
+
const data = payload;
|
|
667
|
+
if (!data.emoji) return;
|
|
668
|
+
if (data.userId === self.userId) return;
|
|
669
|
+
addReaction({
|
|
670
|
+
id: data.id || shortId(),
|
|
671
|
+
userId: data.userId || "",
|
|
672
|
+
name: data.name || "",
|
|
673
|
+
emoji: data.emoji,
|
|
674
|
+
color: data.color || "#3B82F6",
|
|
675
|
+
ts: data.ts || Date.now()
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
return unsubscribe;
|
|
679
|
+
}, [addTopicHandler, addReaction, self.userId]);
|
|
680
|
+
const sendReaction = useCallback((emoji) => {
|
|
681
|
+
const reaction = {
|
|
682
|
+
id: shortId(),
|
|
683
|
+
userId: self.userId,
|
|
684
|
+
name: self.name,
|
|
685
|
+
emoji,
|
|
686
|
+
color: self.color,
|
|
687
|
+
ts: Date.now()
|
|
688
|
+
};
|
|
689
|
+
publish(TOPICS.REACTIONS, reaction, { qos: 0 });
|
|
690
|
+
addReaction(reaction);
|
|
691
|
+
}, [publish, self, addReaction]);
|
|
692
|
+
useEffect(() => {
|
|
693
|
+
return () => {
|
|
694
|
+
for (const timer of timersRef.current.values()) {
|
|
695
|
+
clearTimeout(timer);
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
}, []);
|
|
699
|
+
return { reactions, sendReaction };
|
|
700
|
+
}
|
|
701
|
+
function useBroadcast(event) {
|
|
702
|
+
const { publish, addTopicHandler } = useSpace();
|
|
703
|
+
const [lastMessage, setLastMessage] = useState(null);
|
|
704
|
+
const callbacksRef = useRef(/* @__PURE__ */ new Set());
|
|
705
|
+
useEffect(() => {
|
|
706
|
+
const unsubscribe = addTopicHandler(TOPICS.BROADCAST, (subtopic, payload) => {
|
|
707
|
+
const eventName = subtopic.includes("/") ? subtopic.split("/").slice(1).join("/") : void 0;
|
|
708
|
+
if (event && eventName !== event) return;
|
|
709
|
+
const data = payload;
|
|
710
|
+
setLastMessage(data);
|
|
711
|
+
for (const cb of callbacksRef.current) {
|
|
712
|
+
cb(data);
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
return unsubscribe;
|
|
716
|
+
}, [addTopicHandler, event]);
|
|
717
|
+
const broadcast = useCallback((data) => {
|
|
718
|
+
const subtopic = event ? `${TOPICS.BROADCAST}/${event}` : TOPICS.BROADCAST;
|
|
719
|
+
publish(subtopic, data, { qos: 0 });
|
|
720
|
+
}, [publish, event]);
|
|
721
|
+
const onMessage = useCallback((callback) => {
|
|
722
|
+
callbacksRef.current.add(callback);
|
|
723
|
+
return () => {
|
|
724
|
+
callbacksRef.current.delete(callback);
|
|
725
|
+
};
|
|
726
|
+
}, []);
|
|
727
|
+
return { broadcast, lastMessage, onMessage };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// src/utils/lww.ts
|
|
731
|
+
function lwwMerge(current, incoming) {
|
|
732
|
+
return incoming.ts > current.ts ? incoming : current;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// src/hooks/useSharedState.ts
|
|
736
|
+
function useSharedState(key, initialValue) {
|
|
737
|
+
const { self, publish, addTopicHandler } = useSpace();
|
|
738
|
+
const [value, setValue] = useState(initialValue);
|
|
739
|
+
const entryRef = useRef({
|
|
740
|
+
value: initialValue,
|
|
741
|
+
ts: 0,
|
|
742
|
+
userId: self.userId
|
|
743
|
+
});
|
|
744
|
+
useEffect(() => {
|
|
745
|
+
`${TOPICS.STATE}/${key}`;
|
|
746
|
+
const unsubscribe = addTopicHandler(TOPICS.STATE, (incomingSubtopic, payload) => {
|
|
747
|
+
const incomingKey = incomingSubtopic.includes("/") ? incomingSubtopic.split("/").slice(1).join("/") : void 0;
|
|
748
|
+
if (incomingKey !== key) return;
|
|
749
|
+
if (typeof payload !== "object" || payload === null) return;
|
|
750
|
+
const data = payload;
|
|
751
|
+
const incoming = {
|
|
752
|
+
value: data.value,
|
|
753
|
+
ts: data.ts || 0,
|
|
754
|
+
userId: data.userId || ""
|
|
755
|
+
};
|
|
756
|
+
const merged = lwwMerge(entryRef.current, incoming);
|
|
757
|
+
if (merged !== entryRef.current) {
|
|
758
|
+
entryRef.current = merged;
|
|
759
|
+
setValue(merged.value);
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
return unsubscribe;
|
|
763
|
+
}, [addTopicHandler, key]);
|
|
764
|
+
const update = useCallback((newValue) => {
|
|
765
|
+
const ts = Date.now();
|
|
766
|
+
const entry = { value: newValue, ts, userId: self.userId };
|
|
767
|
+
entryRef.current = entry;
|
|
768
|
+
setValue(newValue);
|
|
769
|
+
publish(`${TOPICS.STATE}/${key}`, {
|
|
770
|
+
value: newValue,
|
|
771
|
+
ts,
|
|
772
|
+
userId: self.userId
|
|
773
|
+
}, { qos: 1, retain: true });
|
|
774
|
+
}, [publish, key, self.userId]);
|
|
775
|
+
return [value, update];
|
|
776
|
+
}
|
|
777
|
+
function AvatarStack({ max = 5, size = 32, className = "" }) {
|
|
778
|
+
const { members } = usePresence();
|
|
779
|
+
const visible = members.slice(0, max);
|
|
780
|
+
const overflow = members.length - max;
|
|
781
|
+
return /* @__PURE__ */ jsx(
|
|
782
|
+
"div",
|
|
783
|
+
{
|
|
784
|
+
className,
|
|
785
|
+
style: { display: "flex", alignItems: "center" },
|
|
786
|
+
children: /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexDirection: "row-reverse" }, children: [
|
|
787
|
+
overflow > 0 && /* @__PURE__ */ jsxs(
|
|
788
|
+
"div",
|
|
789
|
+
{
|
|
790
|
+
style: {
|
|
791
|
+
width: size,
|
|
792
|
+
height: size,
|
|
793
|
+
borderRadius: "50%",
|
|
794
|
+
backgroundColor: "#6B7280",
|
|
795
|
+
color: "white",
|
|
796
|
+
display: "flex",
|
|
797
|
+
alignItems: "center",
|
|
798
|
+
justifyContent: "center",
|
|
799
|
+
fontSize: size * 0.35,
|
|
800
|
+
fontWeight: 600,
|
|
801
|
+
border: "2px solid white",
|
|
802
|
+
marginLeft: -size * 0.25,
|
|
803
|
+
position: "relative",
|
|
804
|
+
zIndex: 0
|
|
805
|
+
},
|
|
806
|
+
children: [
|
|
807
|
+
"+",
|
|
808
|
+
overflow
|
|
809
|
+
]
|
|
810
|
+
}
|
|
811
|
+
),
|
|
812
|
+
visible.map((user, i) => /* @__PURE__ */ jsx(
|
|
813
|
+
"div",
|
|
814
|
+
{
|
|
815
|
+
title: user.name,
|
|
816
|
+
style: {
|
|
817
|
+
width: size,
|
|
818
|
+
height: size,
|
|
819
|
+
borderRadius: "50%",
|
|
820
|
+
backgroundColor: user.color,
|
|
821
|
+
color: "white",
|
|
822
|
+
display: "flex",
|
|
823
|
+
alignItems: "center",
|
|
824
|
+
justifyContent: "center",
|
|
825
|
+
fontSize: size * 0.35,
|
|
826
|
+
fontWeight: 600,
|
|
827
|
+
border: "2px solid white",
|
|
828
|
+
marginLeft: i === visible.length - 1 ? 0 : -size * 0.25,
|
|
829
|
+
position: "relative",
|
|
830
|
+
zIndex: visible.length - i,
|
|
831
|
+
overflow: "hidden"
|
|
832
|
+
},
|
|
833
|
+
children: user.avatar ? /* @__PURE__ */ jsx(
|
|
834
|
+
"img",
|
|
835
|
+
{
|
|
836
|
+
src: user.avatar,
|
|
837
|
+
alt: user.name,
|
|
838
|
+
style: { width: "100%", height: "100%", objectFit: "cover" }
|
|
839
|
+
}
|
|
840
|
+
) : getInitials(user.name)
|
|
841
|
+
},
|
|
842
|
+
user.userId
|
|
843
|
+
))
|
|
844
|
+
] })
|
|
845
|
+
}
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
function CursorOverlay({
|
|
849
|
+
throttleMs,
|
|
850
|
+
staleMs = DEFAULTS.CURSOR_STALE_MS,
|
|
851
|
+
className = "",
|
|
852
|
+
children
|
|
853
|
+
}) {
|
|
854
|
+
const { cursorsRef, onUpdateRef, publishCursor } = useCursors({ throttleMs, staleMs });
|
|
855
|
+
const containerRef = useRef(null);
|
|
856
|
+
const wrapperRef = useRef(null);
|
|
857
|
+
const nodesRef = useRef(/* @__PURE__ */ new Map());
|
|
858
|
+
const syncDOM = useCallback(() => {
|
|
859
|
+
const container = containerRef.current;
|
|
860
|
+
const wrapper = wrapperRef.current;
|
|
861
|
+
if (!container || !wrapper) return;
|
|
862
|
+
const w = container.clientWidth;
|
|
863
|
+
const h = container.clientHeight;
|
|
864
|
+
const now = Date.now();
|
|
865
|
+
const cursors = cursorsRef.current;
|
|
866
|
+
for (const [id, node] of nodesRef.current) {
|
|
867
|
+
if (!cursors.has(id)) {
|
|
868
|
+
node.remove();
|
|
869
|
+
nodesRef.current.delete(id);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
for (const [id, cursor] of cursors) {
|
|
873
|
+
const age = now - cursor.lastSeen;
|
|
874
|
+
const opacity = age > 1e3 ? Math.max(0, 1 - (age - 1e3) / (staleMs - 1e3)) : 1;
|
|
875
|
+
let node = nodesRef.current.get(id);
|
|
876
|
+
if (!node) {
|
|
877
|
+
node = createCursorNode(cursor);
|
|
878
|
+
wrapper.appendChild(node);
|
|
879
|
+
nodesRef.current.set(id, node);
|
|
880
|
+
}
|
|
881
|
+
node.style.transform = `translate(${cursor.x * w}px, ${cursor.y * h}px)`;
|
|
882
|
+
node.style.opacity = String(opacity);
|
|
883
|
+
}
|
|
884
|
+
}, [cursorsRef, staleMs]);
|
|
885
|
+
useEffect(() => {
|
|
886
|
+
onUpdateRef.current = syncDOM;
|
|
887
|
+
return () => {
|
|
888
|
+
onUpdateRef.current = null;
|
|
889
|
+
};
|
|
890
|
+
}, [onUpdateRef, syncDOM]);
|
|
891
|
+
useEffect(() => {
|
|
892
|
+
const container = containerRef.current;
|
|
893
|
+
if (!container) return;
|
|
894
|
+
const observer = new ResizeObserver(() => syncDOM());
|
|
895
|
+
observer.observe(container);
|
|
896
|
+
return () => observer.disconnect();
|
|
897
|
+
}, [syncDOM]);
|
|
898
|
+
useEffect(() => {
|
|
899
|
+
const interval = setInterval(syncDOM, DEFAULTS.FADE_TICK_MS);
|
|
900
|
+
return () => clearInterval(interval);
|
|
901
|
+
}, [syncDOM]);
|
|
902
|
+
const handleMouseMove = useCallback((e) => {
|
|
903
|
+
const container = containerRef.current;
|
|
904
|
+
if (!container) return;
|
|
905
|
+
const rect = container.getBoundingClientRect();
|
|
906
|
+
const x = (e.clientX - rect.left) / rect.width;
|
|
907
|
+
const y = (e.clientY - rect.top) / rect.height;
|
|
908
|
+
publishCursor(x, y);
|
|
909
|
+
}, [publishCursor]);
|
|
910
|
+
return /* @__PURE__ */ jsxs(
|
|
911
|
+
"div",
|
|
912
|
+
{
|
|
913
|
+
ref: containerRef,
|
|
914
|
+
className,
|
|
915
|
+
style: { position: "relative", overflow: "hidden" },
|
|
916
|
+
onMouseMove: handleMouseMove,
|
|
917
|
+
children: [
|
|
918
|
+
children,
|
|
919
|
+
/* @__PURE__ */ jsx(
|
|
920
|
+
"div",
|
|
921
|
+
{
|
|
922
|
+
ref: wrapperRef,
|
|
923
|
+
style: {
|
|
924
|
+
position: "absolute",
|
|
925
|
+
inset: 0,
|
|
926
|
+
pointerEvents: "none",
|
|
927
|
+
zIndex: 50
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
)
|
|
931
|
+
]
|
|
932
|
+
}
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
function createCursorNode(cursor) {
|
|
936
|
+
const node = document.createElement("div");
|
|
937
|
+
node.style.position = "absolute";
|
|
938
|
+
node.style.left = "0";
|
|
939
|
+
node.style.top = "0";
|
|
940
|
+
node.style.pointerEvents = "none";
|
|
941
|
+
node.style.transition = "transform 30ms linear";
|
|
942
|
+
node.innerHTML = `
|
|
943
|
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" style="transform: translate(-2px, -2px)">
|
|
944
|
+
<path d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z" fill="${cursor.color}" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
|
945
|
+
</svg>
|
|
946
|
+
<div style="margin-left: 16px; margin-top: 4px; white-space: nowrap; border-radius: 4px; padding: 2px 6px; font-size: 12px; font-weight: 500; color: white; background-color: ${cursor.color}; box-shadow: 0 1px 2px rgba(0,0,0,0.1);">${escapeHtml(cursor.name)}</div>
|
|
947
|
+
`;
|
|
948
|
+
return node;
|
|
949
|
+
}
|
|
950
|
+
function escapeHtml(str) {
|
|
951
|
+
const div = document.createElement("div");
|
|
952
|
+
div.textContent = str;
|
|
953
|
+
return div.innerHTML;
|
|
954
|
+
}
|
|
955
|
+
function TypingIndicator({ inputId, className = "" }) {
|
|
956
|
+
const { typingUsers } = useTypingIndicator(inputId);
|
|
957
|
+
if (typingUsers.length === 0) {
|
|
958
|
+
return /* @__PURE__ */ jsx("div", { className, style: { minHeight: "1.25rem" } });
|
|
959
|
+
}
|
|
960
|
+
let text;
|
|
961
|
+
if (typingUsers.length === 1) {
|
|
962
|
+
text = `${typingUsers[0].name} is typing...`;
|
|
963
|
+
} else if (typingUsers.length === 2) {
|
|
964
|
+
text = `${typingUsers[0].name} and ${typingUsers[1].name} are typing...`;
|
|
965
|
+
} else {
|
|
966
|
+
text = `${typingUsers.length} people are typing...`;
|
|
967
|
+
}
|
|
968
|
+
return /* @__PURE__ */ jsx("div", { className, style: { minHeight: "1.25rem" }, children: /* @__PURE__ */ jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: "4px" }, children: [
|
|
969
|
+
/* @__PURE__ */ jsx(TypingDots, {}),
|
|
970
|
+
text
|
|
971
|
+
] }) });
|
|
972
|
+
}
|
|
973
|
+
function TypingDots() {
|
|
974
|
+
return /* @__PURE__ */ jsxs("span", { style: { display: "inline-flex", gap: "2px", alignItems: "center" }, children: [
|
|
975
|
+
[0, 1, 2].map((i) => /* @__PURE__ */ jsx(
|
|
976
|
+
"span",
|
|
977
|
+
{
|
|
978
|
+
style: {
|
|
979
|
+
width: 4,
|
|
980
|
+
height: 4,
|
|
981
|
+
borderRadius: "50%",
|
|
982
|
+
backgroundColor: "currentColor",
|
|
983
|
+
opacity: 0.6,
|
|
984
|
+
animation: `cs-typing-dot 1.4s infinite`,
|
|
985
|
+
animationDelay: `${i * 0.2}s`
|
|
986
|
+
}
|
|
987
|
+
},
|
|
988
|
+
i
|
|
989
|
+
)),
|
|
990
|
+
/* @__PURE__ */ jsx("style", { children: `
|
|
991
|
+
@keyframes cs-typing-dot {
|
|
992
|
+
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
|
993
|
+
40% { opacity: 1; transform: scale(1); }
|
|
994
|
+
}
|
|
995
|
+
` })
|
|
996
|
+
] });
|
|
997
|
+
}
|
|
998
|
+
function LockIndicator({ componentId, className = "", children }) {
|
|
999
|
+
const { isLocked, lockedBy, isLockedByMe } = useLock(componentId);
|
|
1000
|
+
const borderColor = isLocked ? isLockedByMe ? lockedBy?.color || "#3B82F6" : lockedBy?.color || "#EF4444" : "transparent";
|
|
1001
|
+
return /* @__PURE__ */ jsxs(
|
|
1002
|
+
"div",
|
|
1003
|
+
{
|
|
1004
|
+
className,
|
|
1005
|
+
style: {
|
|
1006
|
+
position: "relative",
|
|
1007
|
+
borderRadius: "6px",
|
|
1008
|
+
border: `2px solid ${borderColor}`,
|
|
1009
|
+
transition: "border-color 200ms ease"
|
|
1010
|
+
},
|
|
1011
|
+
children: [
|
|
1012
|
+
children,
|
|
1013
|
+
isLocked && !isLockedByMe && lockedBy && /* @__PURE__ */ jsx(
|
|
1014
|
+
"div",
|
|
1015
|
+
{
|
|
1016
|
+
style: {
|
|
1017
|
+
position: "absolute",
|
|
1018
|
+
top: -10,
|
|
1019
|
+
right: 8,
|
|
1020
|
+
padding: "1px 6px",
|
|
1021
|
+
borderRadius: "4px",
|
|
1022
|
+
fontSize: "11px",
|
|
1023
|
+
fontWeight: 500,
|
|
1024
|
+
color: "white",
|
|
1025
|
+
backgroundColor: lockedBy.color,
|
|
1026
|
+
whiteSpace: "nowrap",
|
|
1027
|
+
zIndex: 10
|
|
1028
|
+
},
|
|
1029
|
+
children: lockedBy.name
|
|
1030
|
+
}
|
|
1031
|
+
)
|
|
1032
|
+
]
|
|
1033
|
+
}
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
var DEFAULT_EMOJIS = ["\u{1F44D}", "\u2764\uFE0F", "\u{1F602}", "\u{1F389}", "\u{1F525}", "\u{1F440}", "\u{1F680}", "\u{1F4AF}"];
|
|
1037
|
+
function ReactionBar({ emojis = DEFAULT_EMOJIS, className = "" }) {
|
|
1038
|
+
const { reactions, sendReaction } = useReactions();
|
|
1039
|
+
return /* @__PURE__ */ jsxs("div", { className, style: { position: "relative" }, children: [
|
|
1040
|
+
/* @__PURE__ */ jsx(
|
|
1041
|
+
"div",
|
|
1042
|
+
{
|
|
1043
|
+
style: {
|
|
1044
|
+
position: "absolute",
|
|
1045
|
+
bottom: "100%",
|
|
1046
|
+
left: 0,
|
|
1047
|
+
right: 0,
|
|
1048
|
+
height: 120,
|
|
1049
|
+
pointerEvents: "none",
|
|
1050
|
+
overflow: "hidden"
|
|
1051
|
+
},
|
|
1052
|
+
children: reactions.map((reaction) => /* @__PURE__ */ jsx(
|
|
1053
|
+
"span",
|
|
1054
|
+
{
|
|
1055
|
+
style: {
|
|
1056
|
+
position: "absolute",
|
|
1057
|
+
bottom: 0,
|
|
1058
|
+
left: `${reaction.x ?? 50}%`,
|
|
1059
|
+
fontSize: "24px",
|
|
1060
|
+
animation: "cs-reaction-float 3s ease-out forwards",
|
|
1061
|
+
pointerEvents: "none"
|
|
1062
|
+
},
|
|
1063
|
+
children: reaction.emoji
|
|
1064
|
+
},
|
|
1065
|
+
reaction.id
|
|
1066
|
+
))
|
|
1067
|
+
}
|
|
1068
|
+
),
|
|
1069
|
+
/* @__PURE__ */ jsx("div", { style: { display: "flex", gap: "4px", flexWrap: "wrap" }, children: emojis.map((emoji) => /* @__PURE__ */ jsx(
|
|
1070
|
+
"button",
|
|
1071
|
+
{
|
|
1072
|
+
onClick: () => sendReaction(emoji),
|
|
1073
|
+
style: {
|
|
1074
|
+
padding: "4px 8px",
|
|
1075
|
+
fontSize: "18px",
|
|
1076
|
+
border: "1px solid #E5E7EB",
|
|
1077
|
+
borderRadius: "8px",
|
|
1078
|
+
backgroundColor: "white",
|
|
1079
|
+
cursor: "pointer",
|
|
1080
|
+
transition: "transform 100ms ease, background-color 100ms ease"
|
|
1081
|
+
},
|
|
1082
|
+
onMouseEnter: (e) => {
|
|
1083
|
+
e.currentTarget.style.transform = "scale(1.15)";
|
|
1084
|
+
e.currentTarget.style.backgroundColor = "#F3F4F6";
|
|
1085
|
+
},
|
|
1086
|
+
onMouseLeave: (e) => {
|
|
1087
|
+
e.currentTarget.style.transform = "scale(1)";
|
|
1088
|
+
e.currentTarget.style.backgroundColor = "white";
|
|
1089
|
+
},
|
|
1090
|
+
children: emoji
|
|
1091
|
+
},
|
|
1092
|
+
emoji
|
|
1093
|
+
)) }),
|
|
1094
|
+
/* @__PURE__ */ jsx("style", { children: `
|
|
1095
|
+
@keyframes cs-reaction-float {
|
|
1096
|
+
0% { opacity: 1; transform: translateY(0) scale(1); }
|
|
1097
|
+
100% { opacity: 0; transform: translateY(-100px) scale(1.5); }
|
|
1098
|
+
}
|
|
1099
|
+
` })
|
|
1100
|
+
] });
|
|
1101
|
+
}
|
|
1102
|
+
function PresenceBorder({
|
|
1103
|
+
componentId,
|
|
1104
|
+
borderWidth = 2,
|
|
1105
|
+
className = "",
|
|
1106
|
+
children
|
|
1107
|
+
}) {
|
|
1108
|
+
const { isLocked, lockedBy, isLockedByMe, lock, unlock } = useLock(componentId);
|
|
1109
|
+
const borderColor = isLocked ? lockedBy?.color || "#3B82F6" : "transparent";
|
|
1110
|
+
return /* @__PURE__ */ jsxs(
|
|
1111
|
+
"div",
|
|
1112
|
+
{
|
|
1113
|
+
className,
|
|
1114
|
+
style: {
|
|
1115
|
+
position: "relative",
|
|
1116
|
+
borderRadius: "6px",
|
|
1117
|
+
border: `${borderWidth}px solid ${borderColor}`,
|
|
1118
|
+
transition: "border-color 200ms ease"
|
|
1119
|
+
},
|
|
1120
|
+
onFocusCapture: () => lock(),
|
|
1121
|
+
onBlurCapture: () => unlock(),
|
|
1122
|
+
children: [
|
|
1123
|
+
children,
|
|
1124
|
+
isLocked && lockedBy && /* @__PURE__ */ jsx(
|
|
1125
|
+
"div",
|
|
1126
|
+
{
|
|
1127
|
+
style: {
|
|
1128
|
+
position: "absolute",
|
|
1129
|
+
top: -(borderWidth + 8),
|
|
1130
|
+
left: 8,
|
|
1131
|
+
padding: "0px 6px",
|
|
1132
|
+
borderRadius: "4px 4px 0 0",
|
|
1133
|
+
fontSize: "11px",
|
|
1134
|
+
fontWeight: 500,
|
|
1135
|
+
color: "white",
|
|
1136
|
+
backgroundColor: lockedBy.color,
|
|
1137
|
+
whiteSpace: "nowrap",
|
|
1138
|
+
lineHeight: "16px"
|
|
1139
|
+
},
|
|
1140
|
+
children: isLockedByMe ? "You" : lockedBy.name
|
|
1141
|
+
}
|
|
1142
|
+
)
|
|
1143
|
+
]
|
|
1144
|
+
}
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// src/index.ts
|
|
1149
|
+
var VERSION = "0.1.0";
|
|
1150
|
+
|
|
1151
|
+
export { AvatarStack, Space as CloudSignalSpace, CursorOverlay, DEFAULTS, LockIndicator, PresenceBorder, ReactionBar, Space, TOPICS, TOPIC_PREFIX, TypingIndicator, VERSION, getColorForUser, getInitials, spaceTopic, spaceWildcard, useBroadcast, useCursors, useLock, usePresence, useReactions, useSharedState, useSpace, useTypingIndicator };
|
|
1152
|
+
//# sourceMappingURL=index.js.map
|
|
1153
|
+
//# sourceMappingURL=index.js.map
|