@affectively/aeon-pages 1.3.0 → 1.4.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.
@@ -0,0 +1,824 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type CSSProperties,
9
+ type ReactNode,
10
+ } from 'react';
11
+ import type { PresenceScroll, PresenceUser } from '../provider';
12
+
13
+ const USER_COLORS = [
14
+ '#ef4444',
15
+ '#3b82f6',
16
+ '#22c55e',
17
+ '#f59e0b',
18
+ '#14b8a6',
19
+ '#f97316',
20
+ '#ec4899',
21
+ '#84cc16',
22
+ ];
23
+
24
+ function hashColor(userId: string): string {
25
+ let hash = 0;
26
+ for (let i = 0; i < userId.length; i++) {
27
+ hash = (hash << 5) - hash + userId.charCodeAt(i);
28
+ hash |= 0;
29
+ }
30
+ return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
31
+ }
32
+
33
+ function displayUser(userId: string): string {
34
+ return userId.length > 10 ? userId.slice(0, 8) : userId;
35
+ }
36
+
37
+ function clampDepth(depth: number): number {
38
+ return Math.max(0, Math.min(1, depth));
39
+ }
40
+
41
+ function formatLastActivity(lastActivity?: string): string {
42
+ if (!lastActivity) return 'unknown activity';
43
+ const ts = Date.parse(lastActivity);
44
+ if (Number.isNaN(ts)) return lastActivity;
45
+ const deltaMs = Date.now() - ts;
46
+ const deltaSec = Math.max(0, Math.floor(deltaMs / 1000));
47
+ if (deltaSec < 10) return 'just now';
48
+ if (deltaSec < 60) return `${deltaSec}s ago`;
49
+ const deltaMin = Math.floor(deltaSec / 60);
50
+ if (deltaMin < 60) return `${deltaMin}m ago`;
51
+ const deltaHour = Math.floor(deltaMin / 60);
52
+ if (deltaHour < 24) return `${deltaHour}h ago`;
53
+ const deltaDay = Math.floor(deltaHour / 24);
54
+ return `${deltaDay}d ago`;
55
+ }
56
+
57
+ function panelStyle(base?: CSSProperties): CSSProperties {
58
+ return {
59
+ border: '1px solid #e5e7eb',
60
+ borderRadius: 10,
61
+ padding: 12,
62
+ background: '#ffffff',
63
+ ...base,
64
+ };
65
+ }
66
+
67
+ export interface PresenceCursorLayerProps {
68
+ presence: PresenceUser[];
69
+ localUserId?: string;
70
+ width?: number | string;
71
+ height?: number | string;
72
+ className?: string;
73
+ }
74
+
75
+ export function PresenceCursorLayer({
76
+ presence,
77
+ localUserId,
78
+ width = '100%',
79
+ height = 320,
80
+ className,
81
+ }: PresenceCursorLayerProps) {
82
+ const users = presence.filter((user) => user.userId !== localUserId && user.cursor);
83
+
84
+ return (
85
+ <div
86
+ className={className}
87
+ style={panelStyle({
88
+ position: 'relative',
89
+ width,
90
+ height,
91
+ overflow: 'hidden',
92
+ })}
93
+ >
94
+ {users.map((user) => {
95
+ if (!user.cursor) return null;
96
+ const color = hashColor(user.userId);
97
+
98
+ return (
99
+ <div
100
+ key={user.userId}
101
+ style={{
102
+ position: 'absolute',
103
+ left: user.cursor.x,
104
+ top: user.cursor.y,
105
+ transform: 'translate(-50%, -50%)',
106
+ pointerEvents: 'none',
107
+ }}
108
+ >
109
+ <div
110
+ style={{
111
+ width: 12,
112
+ height: 12,
113
+ borderRadius: '50%',
114
+ border: '2px solid #ffffff',
115
+ background: color,
116
+ boxShadow: '0 1px 6px rgba(0,0,0,0.2)',
117
+ }}
118
+ />
119
+ <div
120
+ style={{
121
+ marginTop: 4,
122
+ fontSize: 11,
123
+ color: '#111827',
124
+ fontWeight: 600,
125
+ padding: '2px 6px',
126
+ borderRadius: 999,
127
+ background: '#ffffff',
128
+ border: `1px solid ${color}`,
129
+ whiteSpace: 'nowrap',
130
+ }}
131
+ >
132
+ {displayUser(user.userId)}
133
+ </div>
134
+ </div>
135
+ );
136
+ })}
137
+ {users.length === 0 && (
138
+ <div style={{ color: '#6b7280', fontSize: 13 }}>No remote cursors</div>
139
+ )}
140
+ </div>
141
+ );
142
+ }
143
+
144
+ export interface PresenceFocusListProps {
145
+ presence: PresenceUser[];
146
+ localUserId?: string;
147
+ maxItems?: number;
148
+ className?: string;
149
+ }
150
+
151
+ export function PresenceFocusList({
152
+ presence,
153
+ localUserId,
154
+ maxItems = 8,
155
+ className,
156
+ }: PresenceFocusListProps) {
157
+ const focused = presence
158
+ .filter((user) => user.userId !== localUserId && user.focusNode)
159
+ .slice(0, maxItems);
160
+
161
+ return (
162
+ <div className={className} style={panelStyle()}>
163
+ <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
164
+ Focus Nodes
165
+ </div>
166
+ {focused.length === 0 ? (
167
+ <div style={{ color: '#6b7280', fontSize: 13 }}>No remote focus</div>
168
+ ) : (
169
+ <div style={{ display: 'grid', gap: 6 }}>
170
+ {focused.map((user) => (
171
+ <div
172
+ key={user.userId}
173
+ style={{
174
+ border: `1px solid ${hashColor(user.userId)}44`,
175
+ borderRadius: 8,
176
+ padding: '6px 8px',
177
+ fontSize: 12,
178
+ }}
179
+ >
180
+ <span style={{ fontWeight: 600 }}>{displayUser(user.userId)}</span>{' '}
181
+ <span style={{ color: '#6b7280' }}>focused</span>{' '}
182
+ <code style={{ color: '#111827' }}>{user.focusNode}</code>
183
+ </div>
184
+ ))}
185
+ </div>
186
+ )}
187
+ </div>
188
+ );
189
+ }
190
+
191
+ export interface PresenceTypingListProps {
192
+ presence: PresenceUser[];
193
+ localUserId?: string;
194
+ className?: string;
195
+ }
196
+
197
+ export function PresenceTypingList({
198
+ presence,
199
+ localUserId,
200
+ className,
201
+ }: PresenceTypingListProps) {
202
+ const typing = presence.filter(
203
+ (user) => user.userId !== localUserId && user.typing?.isTyping,
204
+ );
205
+
206
+ return (
207
+ <div className={className} style={panelStyle()}>
208
+ <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
209
+ Typing
210
+ </div>
211
+ {typing.length === 0 ? (
212
+ <div style={{ color: '#6b7280', fontSize: 13 }}>No one is typing</div>
213
+ ) : (
214
+ <div style={{ display: 'grid', gap: 6 }}>
215
+ {typing.map((user) => (
216
+ <div
217
+ key={user.userId}
218
+ style={{
219
+ display: 'flex',
220
+ alignItems: 'center',
221
+ gap: 8,
222
+ fontSize: 12,
223
+ borderRadius: 8,
224
+ padding: '6px 8px',
225
+ background: '#f9fafb',
226
+ }}
227
+ >
228
+ <div
229
+ style={{
230
+ width: 8,
231
+ height: 8,
232
+ borderRadius: '50%',
233
+ background: hashColor(user.userId),
234
+ }}
235
+ />
236
+ <span style={{ fontWeight: 600 }}>{displayUser(user.userId)}</span>
237
+ <span style={{ color: '#6b7280' }}>
238
+ {user.typing?.field ? `typing in ${user.typing.field}` : 'typing'}
239
+ </span>
240
+ {user.typing?.isComposing ? (
241
+ <span style={{ color: '#92400e' }}>(composing)</span>
242
+ ) : null}
243
+ </div>
244
+ ))}
245
+ </div>
246
+ )}
247
+ </div>
248
+ );
249
+ }
250
+
251
+ export interface PresenceSelectionListProps {
252
+ presence: PresenceUser[];
253
+ localUserId?: string;
254
+ className?: string;
255
+ }
256
+
257
+ export function PresenceSelectionList({
258
+ presence,
259
+ localUserId,
260
+ className,
261
+ }: PresenceSelectionListProps) {
262
+ const selections = presence.filter(
263
+ (user) => user.userId !== localUserId && user.selection,
264
+ );
265
+
266
+ return (
267
+ <div className={className} style={panelStyle()}>
268
+ <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
269
+ Selections
270
+ </div>
271
+ {selections.length === 0 ? (
272
+ <div style={{ color: '#6b7280', fontSize: 13 }}>No active selections</div>
273
+ ) : (
274
+ <div style={{ display: 'grid', gap: 6 }}>
275
+ {selections.map((user) => (
276
+ <div
277
+ key={user.userId}
278
+ style={{
279
+ borderLeft: `4px solid ${hashColor(user.userId)}`,
280
+ paddingLeft: 8,
281
+ fontSize: 12,
282
+ }}
283
+ >
284
+ <div style={{ fontWeight: 600 }}>{displayUser(user.userId)}</div>
285
+ <div style={{ color: '#6b7280' }}>
286
+ {user.selection?.path ?? 'document'}: {user.selection?.start} -{' '}
287
+ {user.selection?.end}
288
+ </div>
289
+ </div>
290
+ ))}
291
+ </div>
292
+ )}
293
+ </div>
294
+ );
295
+ }
296
+
297
+ export interface PresenceScrollBarProps {
298
+ presence: PresenceUser[];
299
+ localUserId?: string;
300
+ height?: number;
301
+ className?: string;
302
+ }
303
+
304
+ export function PresenceScrollBar({
305
+ presence,
306
+ localUserId,
307
+ height = 220,
308
+ className,
309
+ }: PresenceScrollBarProps) {
310
+ const users = presence.filter((user) => user.scroll);
311
+
312
+ return (
313
+ <div className={className} style={panelStyle()}>
314
+ <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
315
+ Scroll Presence
316
+ </div>
317
+ <div style={{ display: 'flex', gap: 12 }}>
318
+ <div
319
+ style={{
320
+ position: 'relative',
321
+ width: 12,
322
+ height,
323
+ borderRadius: 999,
324
+ background: '#e5e7eb',
325
+ overflow: 'hidden',
326
+ }}
327
+ >
328
+ {users.map((user) => {
329
+ const depth = clampDepth(user.scroll?.depth ?? 0);
330
+ const color = hashColor(user.userId);
331
+ const isLocal = user.userId === localUserId;
332
+ return (
333
+ <div
334
+ key={user.userId}
335
+ title={`${displayUser(user.userId)}: ${Math.round(depth * 100)}%`}
336
+ style={{
337
+ position: 'absolute',
338
+ left: isLocal ? 0 : 1,
339
+ right: isLocal ? 0 : 1,
340
+ height: isLocal ? 4 : 3,
341
+ top: `calc(${depth * 100}% - ${isLocal ? 2 : 1.5}px)`,
342
+ borderRadius: 999,
343
+ background: color,
344
+ }}
345
+ />
346
+ );
347
+ })}
348
+ </div>
349
+ <div style={{ display: 'grid', gap: 6, fontSize: 12, flex: 1 }}>
350
+ {users.length === 0 ? (
351
+ <div style={{ color: '#6b7280' }}>No scroll telemetry yet</div>
352
+ ) : (
353
+ users.map((user) => {
354
+ const depth = clampDepth(user.scroll?.depth ?? 0);
355
+ return (
356
+ <div key={user.userId} style={{ display: 'flex', gap: 8 }}>
357
+ <span style={{ fontWeight: 600, minWidth: 68 }}>
358
+ {displayUser(user.userId)}
359
+ </span>
360
+ <span style={{ color: '#6b7280' }}>
361
+ {Math.round(depth * 100)}%
362
+ </span>
363
+ </div>
364
+ );
365
+ })
366
+ )}
367
+ </div>
368
+ </div>
369
+ </div>
370
+ );
371
+ }
372
+
373
+ export interface PresenceViewportListProps {
374
+ presence: PresenceUser[];
375
+ localUserId?: string;
376
+ className?: string;
377
+ }
378
+
379
+ export function PresenceViewportList({
380
+ presence,
381
+ localUserId,
382
+ className,
383
+ }: PresenceViewportListProps) {
384
+ const users = presence.filter(
385
+ (user) => user.userId !== localUserId && user.viewport,
386
+ );
387
+
388
+ return (
389
+ <div className={className} style={panelStyle()}>
390
+ <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
391
+ Viewports
392
+ </div>
393
+ {users.length === 0 ? (
394
+ <div style={{ color: '#6b7280', fontSize: 13 }}>No viewport data</div>
395
+ ) : (
396
+ <div style={{ display: 'grid', gap: 8 }}>
397
+ {users.map((user) => {
398
+ const viewport = user.viewport;
399
+ if (!viewport) return null;
400
+ const ratio = viewport.width / Math.max(1, viewport.height);
401
+ return (
402
+ <div key={user.userId} style={{ fontSize: 12 }}>
403
+ <div style={{ marginBottom: 4 }}>
404
+ <span style={{ fontWeight: 600 }}>{displayUser(user.userId)}</span>{' '}
405
+ <span style={{ color: '#6b7280' }}>
406
+ {viewport.width}x{viewport.height}
407
+ </span>
408
+ </div>
409
+ <div
410
+ style={{
411
+ height: 6,
412
+ borderRadius: 999,
413
+ background: '#e5e7eb',
414
+ overflow: 'hidden',
415
+ }}
416
+ >
417
+ <div
418
+ style={{
419
+ width: `${Math.min(100, Math.max(10, ratio * 40))}%`,
420
+ height: '100%',
421
+ borderRadius: 999,
422
+ background: hashColor(user.userId),
423
+ }}
424
+ />
425
+ </div>
426
+ </div>
427
+ );
428
+ })}
429
+ </div>
430
+ )}
431
+ </div>
432
+ );
433
+ }
434
+
435
+ export interface PresenceInputStateListProps {
436
+ presence: PresenceUser[];
437
+ localUserId?: string;
438
+ className?: string;
439
+ }
440
+
441
+ export function PresenceInputStateList({
442
+ presence,
443
+ localUserId,
444
+ className,
445
+ }: PresenceInputStateListProps) {
446
+ const users = presence.filter(
447
+ (user) => user.userId !== localUserId && user.inputState,
448
+ );
449
+
450
+ return (
451
+ <div className={className} style={panelStyle()}>
452
+ <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
453
+ Input States
454
+ </div>
455
+ {users.length === 0 ? (
456
+ <div style={{ color: '#6b7280', fontSize: 13 }}>No active input state</div>
457
+ ) : (
458
+ <div style={{ display: 'grid', gap: 6 }}>
459
+ {users.map((user) => {
460
+ const state = user.inputState;
461
+ if (!state) return null;
462
+ return (
463
+ <div
464
+ key={user.userId}
465
+ style={{
466
+ display: 'flex',
467
+ flexWrap: 'wrap',
468
+ gap: 8,
469
+ fontSize: 12,
470
+ padding: '6px 8px',
471
+ borderRadius: 8,
472
+ background: '#f9fafb',
473
+ }}
474
+ >
475
+ <span style={{ fontWeight: 600 }}>{displayUser(user.userId)}</span>
476
+ <span style={{ color: '#6b7280' }}>{state.field}</span>
477
+ <span>{state.hasFocus ? 'focused' : 'blurred'}</span>
478
+ {state.selectionStart !== undefined && state.selectionEnd !== undefined ? (
479
+ <span style={{ color: '#6b7280' }}>
480
+ caret {state.selectionStart}-{state.selectionEnd}
481
+ </span>
482
+ ) : null}
483
+ {state.valueLength !== undefined ? (
484
+ <span style={{ color: '#6b7280' }}>len {state.valueLength}</span>
485
+ ) : null}
486
+ </div>
487
+ );
488
+ })}
489
+ </div>
490
+ )}
491
+ </div>
492
+ );
493
+ }
494
+
495
+ export interface PresenceEmotionListProps {
496
+ presence: PresenceUser[];
497
+ localUserId?: string;
498
+ className?: string;
499
+ }
500
+
501
+ export function PresenceEmotionList({
502
+ presence,
503
+ localUserId,
504
+ className,
505
+ }: PresenceEmotionListProps) {
506
+ const users = presence.filter(
507
+ (user) => user.userId !== localUserId && user.emotion,
508
+ );
509
+
510
+ return (
511
+ <div className={className} style={panelStyle()}>
512
+ <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
513
+ Emotion Channel
514
+ </div>
515
+ {users.length === 0 ? (
516
+ <div style={{ color: '#6b7280', fontSize: 13 }}>No emotion signal</div>
517
+ ) : (
518
+ <div style={{ display: 'grid', gap: 8 }}>
519
+ {users.map((user) => {
520
+ const emotion = user.emotion;
521
+ if (!emotion) return null;
522
+ const intensity = Math.max(
523
+ 0,
524
+ Math.min(1, emotion.intensity ?? emotion.confidence ?? 0),
525
+ );
526
+ return (
527
+ <div key={user.userId} style={{ fontSize: 12 }}>
528
+ <div style={{ marginBottom: 4 }}>
529
+ <span style={{ fontWeight: 600 }}>{displayUser(user.userId)}</span>{' '}
530
+ <span style={{ color: '#6b7280' }}>
531
+ {emotion.primary ?? 'unspecified'}
532
+ </span>
533
+ </div>
534
+ <div
535
+ style={{
536
+ height: 6,
537
+ borderRadius: 999,
538
+ background: '#e5e7eb',
539
+ overflow: 'hidden',
540
+ }}
541
+ >
542
+ <div
543
+ style={{
544
+ width: `${Math.round(intensity * 100)}%`,
545
+ height: '100%',
546
+ borderRadius: 999,
547
+ background: hashColor(user.userId),
548
+ }}
549
+ />
550
+ </div>
551
+ </div>
552
+ );
553
+ })}
554
+ </div>
555
+ )}
556
+ </div>
557
+ );
558
+ }
559
+
560
+ export interface PresenceEditingListProps {
561
+ presence: PresenceUser[];
562
+ localUserId?: string;
563
+ className?: string;
564
+ }
565
+
566
+ export function PresenceEditingList({
567
+ presence,
568
+ localUserId,
569
+ className,
570
+ }: PresenceEditingListProps) {
571
+ const users = presence.filter((user) => user.userId !== localUserId && user.editing);
572
+
573
+ return (
574
+ <div className={className} style={panelStyle()}>
575
+ <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
576
+ Editing Targets
577
+ </div>
578
+ {users.length === 0 ? (
579
+ <div style={{ color: '#6b7280', fontSize: 13 }}>No active edit targets</div>
580
+ ) : (
581
+ <div style={{ display: 'grid', gap: 6 }}>
582
+ {users.map((user) => (
583
+ <div
584
+ key={user.userId}
585
+ style={{
586
+ display: 'flex',
587
+ gap: 8,
588
+ alignItems: 'center',
589
+ fontSize: 12,
590
+ }}
591
+ >
592
+ <span style={{ fontWeight: 600 }}>{displayUser(user.userId)}</span>
593
+ <code style={{ color: '#6b7280' }}>{user.editing}</code>
594
+ </div>
595
+ ))}
596
+ </div>
597
+ )}
598
+ </div>
599
+ );
600
+ }
601
+
602
+ export interface PresenceStatusListProps {
603
+ presence: PresenceUser[];
604
+ localUserId?: string;
605
+ className?: string;
606
+ }
607
+
608
+ export function PresenceStatusList({
609
+ presence,
610
+ localUserId,
611
+ className,
612
+ }: PresenceStatusListProps) {
613
+ const users = presence.filter((user) => user.userId !== localUserId);
614
+
615
+ return (
616
+ <div className={className} style={panelStyle()}>
617
+ <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
618
+ Status
619
+ </div>
620
+ {users.length === 0 ? (
621
+ <div style={{ color: '#6b7280', fontSize: 13 }}>No collaborators online</div>
622
+ ) : (
623
+ <div style={{ display: 'grid', gap: 6 }}>
624
+ {users.map((user) => {
625
+ const color =
626
+ user.status === 'online'
627
+ ? '#10b981'
628
+ : user.status === 'away'
629
+ ? '#f59e0b'
630
+ : '#9ca3af';
631
+ return (
632
+ <div
633
+ key={user.userId}
634
+ style={{
635
+ display: 'flex',
636
+ alignItems: 'center',
637
+ gap: 8,
638
+ fontSize: 12,
639
+ }}
640
+ >
641
+ <span
642
+ style={{
643
+ width: 8,
644
+ height: 8,
645
+ borderRadius: '50%',
646
+ background: color,
647
+ }}
648
+ />
649
+ <span style={{ fontWeight: 600 }}>{displayUser(user.userId)}</span>
650
+ <span style={{ color: '#6b7280' }}>
651
+ {user.role} {user.status}
652
+ </span>
653
+ <span style={{ color: '#9ca3af' }}>
654
+ {formatLastActivity(user.lastActivity)}
655
+ </span>
656
+ </div>
657
+ );
658
+ })}
659
+ </div>
660
+ )}
661
+ </div>
662
+ );
663
+ }
664
+
665
+ export interface PresenceElementsPanelProps {
666
+ presence: PresenceUser[];
667
+ localUserId?: string;
668
+ className?: string;
669
+ showCursorLayer?: boolean;
670
+ cursorLayerHeight?: number | string;
671
+ }
672
+
673
+ export function PresenceElementsPanel({
674
+ presence,
675
+ localUserId,
676
+ className,
677
+ showCursorLayer = true,
678
+ cursorLayerHeight = 220,
679
+ }: PresenceElementsPanelProps) {
680
+ return (
681
+ <div className={className} style={{ display: 'grid', gap: 10 }}>
682
+ {showCursorLayer ? (
683
+ <PresenceCursorLayer
684
+ presence={presence}
685
+ localUserId={localUserId}
686
+ height={cursorLayerHeight}
687
+ />
688
+ ) : null}
689
+ <PresenceStatusList presence={presence} localUserId={localUserId} />
690
+ <PresenceEditingList presence={presence} localUserId={localUserId} />
691
+ <PresenceTypingList presence={presence} localUserId={localUserId} />
692
+ <PresenceFocusList presence={presence} localUserId={localUserId} />
693
+ <PresenceSelectionList presence={presence} localUserId={localUserId} />
694
+ <PresenceScrollBar presence={presence} localUserId={localUserId} />
695
+ <PresenceViewportList presence={presence} localUserId={localUserId} />
696
+ <PresenceInputStateList presence={presence} localUserId={localUserId} />
697
+ <PresenceEmotionList presence={presence} localUserId={localUserId} />
698
+ </div>
699
+ );
700
+ }
701
+
702
+ export interface CollaborativePresenceScrollContainerProps {
703
+ children: ReactNode;
704
+ presence: PresenceUser[];
705
+ localUserId?: string;
706
+ height?: number | string;
707
+ className?: string;
708
+ style?: CSSProperties;
709
+ onScrollStateChange?: (scroll: PresenceScroll) => void;
710
+ }
711
+
712
+ export function CollaborativePresenceScrollContainer({
713
+ children,
714
+ presence,
715
+ localUserId,
716
+ height = 320,
717
+ className,
718
+ style,
719
+ onScrollStateChange,
720
+ }: CollaborativePresenceScrollContainerProps) {
721
+ const containerRef = useRef<HTMLDivElement | null>(null);
722
+ const [localDepth, setLocalDepth] = useState(0);
723
+
724
+ const markers = useMemo(
725
+ () => presence.filter((user) => user.scroll),
726
+ [presence],
727
+ );
728
+
729
+ useEffect(() => {
730
+ const element = containerRef.current;
731
+ if (!element) return;
732
+
733
+ const update = () => {
734
+ const denominator = Math.max(1, element.scrollHeight - element.clientHeight);
735
+ const depth = clampDepth(element.scrollTop / denominator);
736
+ setLocalDepth(depth);
737
+ onScrollStateChange?.({
738
+ depth,
739
+ y: element.scrollTop,
740
+ viewportHeight: element.clientHeight,
741
+ documentHeight: element.scrollHeight,
742
+ });
743
+ };
744
+
745
+ update();
746
+ element.addEventListener('scroll', update, { passive: true });
747
+ return () => {
748
+ element.removeEventListener('scroll', update);
749
+ };
750
+ }, [onScrollStateChange]);
751
+
752
+ return (
753
+ <div
754
+ className={className}
755
+ style={{
756
+ ...panelStyle({
757
+ position: 'relative',
758
+ height,
759
+ overflow: 'hidden',
760
+ padding: 0,
761
+ }),
762
+ ...style,
763
+ }}
764
+ >
765
+ <div
766
+ ref={containerRef}
767
+ style={{
768
+ height: '100%',
769
+ overflowY: 'auto',
770
+ scrollbarWidth: 'none',
771
+ msOverflowStyle: 'none',
772
+ paddingRight: 22,
773
+ padding: 12,
774
+ }}
775
+ >
776
+ {children}
777
+ </div>
778
+
779
+ <div
780
+ style={{
781
+ position: 'absolute',
782
+ top: 10,
783
+ bottom: 10,
784
+ right: 6,
785
+ width: 10,
786
+ borderRadius: 999,
787
+ background: '#e5e7eb',
788
+ }}
789
+ >
790
+ <div
791
+ style={{
792
+ position: 'absolute',
793
+ left: 0,
794
+ right: 0,
795
+ height: 4,
796
+ top: `calc(${localDepth * 100}% - 2px)`,
797
+ borderRadius: 999,
798
+ background: '#111827',
799
+ }}
800
+ title={localUserId ? `${displayUser(localUserId)} (you)` : 'you'}
801
+ />
802
+
803
+ {markers.map((user) => {
804
+ const depth = clampDepth(user.scroll?.depth ?? 0);
805
+ return (
806
+ <div
807
+ key={user.userId}
808
+ title={`${displayUser(user.userId)}: ${Math.round(depth * 100)}%`}
809
+ style={{
810
+ position: 'absolute',
811
+ left: 1,
812
+ right: 1,
813
+ height: 3,
814
+ borderRadius: 999,
815
+ top: `calc(${depth * 100}% - 1.5px)`,
816
+ background: hashColor(user.userId),
817
+ }}
818
+ />
819
+ );
820
+ })}
821
+ </div>
822
+ </div>
823
+ );
824
+ }