@noya-app/noya-multiplayer-react 0.1.9

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,688 @@
1
+ import {
2
+ ConnectionEvent,
3
+ ExtendedPathKey,
4
+ StateManager,
5
+ } from "@noya-app/state-manager";
6
+ import React, {
7
+ CSSProperties,
8
+ ComponentPropsWithoutRef,
9
+ FC,
10
+ memo,
11
+ useEffect,
12
+ useLayoutEffect,
13
+ } from "react";
14
+ import {
15
+ ObjectInspector,
16
+ ObjectLabel,
17
+ ObjectName,
18
+ ObjectPreview,
19
+ chromeDark,
20
+ chromeLight,
21
+ } from "react-inspector";
22
+ import { trackEvents } from "../globals";
23
+ import { useManagedHistory } from "../hooks";
24
+ import { useLocalStorageState } from "./useLocalStorageState";
25
+
26
+ const lightTheme = {
27
+ ...chromeLight,
28
+ BASE_BACKGROUND_COLOR: "transparent",
29
+ OBJECT_NAME_COLOR: "rgba(0,0,0,0.7)",
30
+ } as any;
31
+
32
+ const darkTheme = {
33
+ ...chromeDark,
34
+ BASE_BACKGROUND_COLOR: "transparent",
35
+ OBJECT_NAME_COLOR: "rgba(255,255,255,0.7)",
36
+ } as any;
37
+
38
+ function ToggleButton({
39
+ showInspector,
40
+ setShowInspector,
41
+ theme,
42
+ }: {
43
+ showInspector: boolean;
44
+ setShowInspector: (value: boolean) => void;
45
+ theme: typeof chromeLight;
46
+ }) {
47
+ return (
48
+ <span
49
+ role="button"
50
+ style={{
51
+ flex: "0",
52
+ appearance: "none",
53
+ color: theme.BASE_COLOR,
54
+ background: "rgba(0,0,0,0.1)",
55
+ border: "none",
56
+ fontSize: "12px",
57
+ whiteSpace: "nowrap",
58
+ display: "inline-flex",
59
+ alignItems: "center",
60
+ justifyContent: "center",
61
+ padding: "2px 0",
62
+ }}
63
+ onClick={() => setShowInspector(!showInspector)}
64
+ >
65
+ <span style={{ width: "12px", height: "12px" }}>
66
+ {showInspector ? (
67
+ <svg
68
+ xmlns="http://www.w3.org/2000/svg"
69
+ fill="none"
70
+ viewBox="0 0 24 24"
71
+ strokeWidth={1.5}
72
+ stroke="currentColor"
73
+ className="size-6"
74
+ >
75
+ <path
76
+ strokeLinecap="round"
77
+ strokeLinejoin="round"
78
+ d="m5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5"
79
+ />
80
+ </svg>
81
+ ) : (
82
+ <svg
83
+ xmlns="http://www.w3.org/2000/svg"
84
+ fill="none"
85
+ viewBox="0 0 24 24"
86
+ strokeWidth={1.5}
87
+ stroke="currentColor"
88
+ className="size-6"
89
+ >
90
+ <path
91
+ strokeLinecap="round"
92
+ strokeLinejoin="round"
93
+ d="m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5"
94
+ />
95
+ </svg>
96
+ )}
97
+ </span>
98
+ </span>
99
+ );
100
+ }
101
+
102
+ function DisclosureSection({
103
+ open,
104
+ setOpen,
105
+ title,
106
+ right,
107
+ children,
108
+ colorScheme,
109
+ isFirst,
110
+ }: {
111
+ open: boolean;
112
+ setOpen?: (value: boolean) => void;
113
+ title: string;
114
+ right?: React.ReactNode;
115
+ children: React.ReactNode;
116
+ colorScheme: "light" | "dark";
117
+ isFirst?: boolean;
118
+ }) {
119
+ const theme = colorScheme === "light" ? lightTheme : darkTheme;
120
+
121
+ const borderColor =
122
+ colorScheme === "light" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.1)";
123
+
124
+ return (
125
+ <div
126
+ style={{
127
+ flex: open ? "1 1 0" : "0",
128
+ display: "flex",
129
+ flexDirection: "column",
130
+ }}
131
+ >
132
+ <div
133
+ onClick={() => setOpen?.(!open)}
134
+ style={{
135
+ cursor: "default",
136
+ fontSize: "12px",
137
+ padding: "4px 10px",
138
+ display: "flex",
139
+ ...(!isFirst && { borderTop: `1px solid ${borderColor}` }),
140
+ ...(open && { borderBottom: `1px solid ${borderColor}` }),
141
+ }}
142
+ >
143
+ {setOpen && (
144
+ <Arrow
145
+ expanded={open}
146
+ style={{
147
+ fontSize: "11px",
148
+ // fontSize: theme.ARROW_FONT_SIZE,
149
+ marginRight: theme.ARROW_MARGIN_RIGHT,
150
+ color: theme.ARROW_COLOR,
151
+ }}
152
+ />
153
+ )}
154
+ <span style={{ flex: "1 1 0" }}>{title}</span>
155
+ {right}
156
+ </div>
157
+ {open && children}
158
+ </div>
159
+ );
160
+ }
161
+
162
+ export const StateInspector = memo(function StateInspector<
163
+ S extends object,
164
+ M,
165
+ >({
166
+ state,
167
+ connectionEvents,
168
+ unstyled,
169
+ colorScheme = "light",
170
+ stateManager,
171
+ ...props
172
+ }: {
173
+ state: S;
174
+ connectionEvents?: ConnectionEvent<S>[];
175
+ unstyled?: boolean;
176
+ colorScheme?: "light" | "dark";
177
+ stateManager: StateManager<S, M>;
178
+ } & ComponentPropsWithoutRef<"div">) {
179
+ const [didMount, setDidMount] = React.useState(false);
180
+
181
+ useLayoutEffect(() => {
182
+ setDidMount(true);
183
+ }, []);
184
+
185
+ const eventsContainerRef = React.useRef<HTMLDivElement>(null);
186
+ const historyContainerRef = React.useRef<HTMLDivElement>(null);
187
+
188
+ const [showInspector, setShowInspector] = useLocalStorageState(
189
+ "noya-multiplayer-react-show-inspector",
190
+ true
191
+ );
192
+ const [showEvents, setShowEvents] = useLocalStorageState(
193
+ "noya-multiplayer-react-show-events",
194
+ false
195
+ );
196
+ const [showHistory, setShowHistory] = useLocalStorageState(
197
+ "noya-multiplayer-react-show-history",
198
+ false
199
+ );
200
+
201
+ useEffect(() => {
202
+ trackEvents.set(showEvents);
203
+ }, [showEvents]);
204
+
205
+ useEffect(() => {
206
+ if (eventsContainerRef.current) {
207
+ eventsContainerRef.current.scrollTop =
208
+ eventsContainerRef.current.scrollHeight;
209
+ }
210
+ }, [connectionEvents]);
211
+
212
+ const historySnapshot = useManagedHistory(stateManager);
213
+
214
+ useEffect(() => {
215
+ if (historyContainerRef.current) {
216
+ historyContainerRef.current.scrollTop =
217
+ historyContainerRef.current.scrollHeight;
218
+ }
219
+ }, [historySnapshot]);
220
+
221
+ // If history index changes, scroll to the new history entry into view
222
+ useEffect(() => {
223
+ if (!historyContainerRef.current) return;
224
+
225
+ const historyEntry = historyContainerRef.current.querySelector(
226
+ `#history-${historySnapshot.historyIndex - 1}`
227
+ );
228
+
229
+ if (historyEntry) {
230
+ historyEntry.scrollIntoView({
231
+ block: "nearest",
232
+ inline: "nearest",
233
+ });
234
+ }
235
+ }, [historySnapshot.historyIndex]);
236
+
237
+ const theme = colorScheme === "light" ? lightTheme : darkTheme;
238
+ const borderColor =
239
+ colorScheme === "light" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.1)";
240
+
241
+ const baseStyle: CSSProperties = {
242
+ position: "fixed",
243
+ top: 12,
244
+ right: 12,
245
+ bottom: 12,
246
+ width: 400,
247
+ background:
248
+ colorScheme === "light" ? "rgba(255,255,255,0.95)" : "rgba(0,0,0,0.95)",
249
+ backdropFilter: "blur(48px)",
250
+ border: `1px solid ${borderColor}`,
251
+ // outlineOffset: -1,
252
+ borderRadius: 4,
253
+ display: "flex",
254
+ flexDirection: "column",
255
+ // gap: 12,
256
+ color: theme.BASE_COLOR,
257
+ overflow: "hidden",
258
+ zIndex: 9999,
259
+ };
260
+
261
+ if (!didMount) return null;
262
+
263
+ if (!showInspector) {
264
+ return (
265
+ <div
266
+ {...props}
267
+ style={{
268
+ ...baseStyle,
269
+ padding: "4px 10px",
270
+ borderRadius: 8,
271
+ bottom: undefined,
272
+ width: undefined,
273
+ }}
274
+ >
275
+ <ToggleButton
276
+ showInspector={showInspector}
277
+ setShowInspector={setShowInspector}
278
+ theme={theme}
279
+ />
280
+ </div>
281
+ );
282
+ }
283
+
284
+ return (
285
+ <div
286
+ {...props}
287
+ style={{
288
+ ...(!unstyled && baseStyle),
289
+ ...props.style,
290
+ }}
291
+ >
292
+ {/* <div style={{ flex: "0", fontSize: "12px" }}>User ID: {userId}</div> */}
293
+ <DisclosureSection
294
+ open
295
+ title="Data"
296
+ colorScheme={colorScheme}
297
+ isFirst
298
+ right={
299
+ <ToggleButton
300
+ showInspector={showInspector}
301
+ setShowInspector={setShowInspector}
302
+ theme={theme}
303
+ />
304
+ }
305
+ >
306
+ <div
307
+ style={{
308
+ flex: "1 1 0",
309
+ overflowY: "auto",
310
+ display: "flex",
311
+ flexDirection: "column",
312
+ gap: "1px",
313
+ padding: "1px 12px",
314
+ }}
315
+ >
316
+ <ObjectInspector data={state} theme={theme} />
317
+ </div>
318
+ </DisclosureSection>
319
+ <DisclosureSection
320
+ open={showHistory}
321
+ setOpen={setShowHistory}
322
+ title="History"
323
+ colorScheme={colorScheme}
324
+ >
325
+ <div
326
+ ref={historyContainerRef}
327
+ style={{
328
+ flex: "1 1 0",
329
+ overflowY: "auto",
330
+ overflowX: "hidden",
331
+ display: "flex",
332
+ flexDirection: "column",
333
+ }}
334
+ >
335
+ {historySnapshot.history.length < 50 && (
336
+ <div
337
+ id={`history-${0}`}
338
+ style={{
339
+ borderBottom: `1px solid ${borderColor}`,
340
+ fontSize: "12px",
341
+ fontFamily: "Menlo, monospace",
342
+ padding: "2px 12px 1px",
343
+ display: "flex",
344
+ alignItems: "center",
345
+ background:
346
+ historySnapshot.historyIndex === 0
347
+ ? colorScheme === "light"
348
+ ? "rgba(0,0,0,0.2)"
349
+ : "rgb(59 130 246 / 38%)"
350
+ : undefined,
351
+ }}
352
+ >
353
+ <span
354
+ style={{
355
+ fontFamily: "Menlo, monospace",
356
+ fontSize: "11px",
357
+ borderRadius: 4,
358
+ display: "inline-block",
359
+ }}
360
+ >
361
+ Initial state
362
+ </span>
363
+ </div>
364
+ )}
365
+ {historySnapshot.history.slice(-50).map((entry, index) => (
366
+ <div
367
+ id={`history-${index}`}
368
+ key={index}
369
+ style={{
370
+ padding: "0px 12px 1px",
371
+ borderBottom: `1px solid ${borderColor}`,
372
+ background:
373
+ index === historySnapshot.historyIndex - 1
374
+ ? colorScheme === "light"
375
+ ? "rgba(0,0,0,0.2)"
376
+ : "rgb(59 130 246 / 15%)"
377
+ : undefined,
378
+ }}
379
+ >
380
+ {entry.redoPatches?.map((patch, j) => (
381
+ <pre
382
+ key={j}
383
+ style={{
384
+ fontSize: "11px",
385
+ display: "flex",
386
+ flexWrap: "wrap",
387
+ }}
388
+ >
389
+ {patch.op === "add" && (
390
+ <>
391
+ <span style={{ fontStyle: "italic" }}>
392
+ {pathToString(patch.path)}
393
+ </span>
394
+ {" = "}
395
+ <ObjectInspector data={patch.value} theme={theme} />
396
+ </>
397
+ )}
398
+ {patch.op === "replace" && (
399
+ <>
400
+ <span style={{ fontStyle: "italic" }}>
401
+ {pathToString(patch.path)}
402
+ </span>
403
+ {" = "}
404
+ <ObjectInspector data={patch.value} theme={theme} />
405
+ </>
406
+ )}
407
+ {patch.op === "remove" && (
408
+ <>
409
+ <span style={{ color: theme.OBJECT_VALUE_STRING_COLOR }}>
410
+ delete{" "}
411
+ </span>
412
+ <span style={{ fontStyle: "italic" }}>
413
+ {pathToString(patch.path)}
414
+ </span>
415
+ </>
416
+ )}
417
+ </pre>
418
+ ))}
419
+ </div>
420
+ ))}
421
+ </div>
422
+ </DisclosureSection>
423
+ <DisclosureSection
424
+ open={showEvents}
425
+ setOpen={setShowEvents}
426
+ title="Events"
427
+ colorScheme={colorScheme}
428
+ >
429
+ <div
430
+ ref={eventsContainerRef}
431
+ style={{
432
+ flex: "1 1 0",
433
+ overflowY: "auto",
434
+ display: "flex",
435
+ flexDirection: "column",
436
+ }}
437
+ >
438
+ {connectionEvents?.map((event, index) => (
439
+ <div
440
+ key={index}
441
+ style={{
442
+ borderBottom: `1px solid ${borderColor}`,
443
+ }}
444
+ >
445
+ {event.type === "stateChange" ? (
446
+ <div
447
+ style={{
448
+ padding: "2px 12px",
449
+ background:
450
+ colorScheme === "light"
451
+ ? "rgba(0,0,0,0.05)"
452
+ : "rgba(255,255,255,0.05)",
453
+ display: "flex",
454
+ alignItems: "center",
455
+ }}
456
+ >
457
+ <span
458
+ style={{
459
+ fontFamily: "Menlo, monospace",
460
+ fontSize: "11px",
461
+ borderRadius: 4,
462
+ display: "inline-block",
463
+ }}
464
+ >
465
+ connection:{" "}
466
+ <span style={{ fontStyle: "italic" }}>
467
+ {event.state.toLowerCase()}
468
+ </span>
469
+ </span>
470
+ </div>
471
+ ) : (
472
+ <div
473
+ style={{
474
+ padding: "0px 12px 1px",
475
+ background:
476
+ event.type === "send"
477
+ ? "rgba(0,255,0,0.1)"
478
+ : "rgba(255,0,0,0.1)",
479
+ }}
480
+ >
481
+ <ObjectInspector
482
+ data={event.message}
483
+ theme={theme}
484
+ nodeRenderer={({
485
+ depth,
486
+ name,
487
+ data,
488
+ isNonenumerable,
489
+ expanded,
490
+ }: any) => {
491
+ const direction = event.type === "send" ? "up" : "down";
492
+
493
+ return depth === 0 ? (
494
+ <ObjectRootLabel direction={direction} data={data} />
495
+ ) : (
496
+ <ObjectLabel
497
+ direction={direction}
498
+ name={name}
499
+ data={data}
500
+ isNonenumerable={isNonenumerable}
501
+ />
502
+ );
503
+ }}
504
+ />
505
+ </div>
506
+ )}
507
+ </div>
508
+ ))}
509
+ {!connectionEvents && (
510
+ <div
511
+ style={{
512
+ padding: "12px",
513
+ fontSize: "12px",
514
+ display: "flex",
515
+ flexDirection: "column",
516
+ gap: "4px",
517
+ }}
518
+ >
519
+ <span>No recorded events</span>
520
+ </div>
521
+ )}
522
+ </div>
523
+ </DisclosureSection>
524
+ </div>
525
+ );
526
+ });
527
+
528
+ type MessageDirection = "up" | "down";
529
+
530
+ const ObjectRootLabel: FC<{
531
+ name?: string;
532
+ data: any;
533
+ direction?: MessageDirection;
534
+ }> = ({ name, data, direction }) => {
535
+ if (typeof name === "string") {
536
+ return (
537
+ <span>
538
+ <ObjectName name={name} />
539
+ <span>: </span>
540
+ <ObjectPreview data={data} />
541
+ </span>
542
+ );
543
+ }
544
+ if (direction === "up" || direction === "down") {
545
+ const arrow = direction === "up" ? "↑" : "↓";
546
+
547
+ return (
548
+ <span>
549
+ <span
550
+ style={{
551
+ background:
552
+ direction === "up" ? "rgba(0,255,0,0.2)" : "rgba(255,0,0,0.2)",
553
+ // color: "white",
554
+ width: 12,
555
+ height: 12,
556
+ borderRadius: 2,
557
+ display: "inline-flex",
558
+ justifyContent: "center",
559
+ alignItems: "center",
560
+ marginRight: 4,
561
+ lineHeight: "12px",
562
+ }}
563
+ >
564
+ {arrow}
565
+ </span>
566
+ <ObjectPreview data={data} />
567
+ </span>
568
+ );
569
+ } else {
570
+ return <ObjectPreview data={data} />;
571
+ }
572
+ };
573
+
574
+ // Rotates ▶️ or ▼ based on expanded state
575
+ function Arrow({
576
+ expanded,
577
+ onClick,
578
+ style,
579
+ }: {
580
+ expanded: boolean;
581
+ onClick?: () => void;
582
+ style?: CSSProperties;
583
+ }) {
584
+ return (
585
+ <span
586
+ role="button"
587
+ onClick={onClick}
588
+ style={{
589
+ display: "inline-block",
590
+ textAlign: "center",
591
+ transform: expanded ? "rotate(0deg)" : "rotate(-90deg)",
592
+ ...style,
593
+ }}
594
+ >
595
+ {"▼"}
596
+ </span>
597
+ );
598
+ }
599
+
600
+ // <div className="flex py-px flex-col-reverse">
601
+ // <button
602
+ // type="button"
603
+ // className={cx(
604
+ // "px-4 py-2 cursor-pointer text-left",
605
+ // 0 === historySnapshot.historyIndex && "bg-orange-500/25"
606
+ // )}
607
+ // onClick={() => {
608
+ // // stateManager.goToHistoryIndex(0);
609
+ // }}
610
+ // >
611
+ // <div className="flex justify-between gap-2">
612
+ // <span>Initial State</span>
613
+ // </div>
614
+ // </button>
615
+ // {historySnapshot.history.map((entry, index) => (
616
+ // <button
617
+ // key={index}
618
+ // type="button"
619
+ // className={cx(
620
+ // "px-4 py-2 cursor-pointer text-left",
621
+ // index === historySnapshot.historyIndex - 1 &&
622
+ // "bg-orange-500/25"
623
+ // )}
624
+ // onClick={() => {
625
+ // // stateManager.goToHistoryIndex(index + 1);
626
+ // }}
627
+ // >
628
+ // <div className="flex justify-between gap-2">
629
+ // <span>
630
+ // {index + 1}. {entry.metadata.name}
631
+ // </span>
632
+ // <span>
633
+ // {new Date(
634
+ // entry.metadata.timestamp
635
+ // ).toLocaleTimeString()}
636
+ // </span>
637
+ // </div>
638
+ // <div className="font-mono text-xs">
639
+ // {entry.redoPatches?.map((patch, index) => (
640
+ // <pre key={index} className="max-w-full">
641
+ // {patch.op === "remove"
642
+ // ? `Delete ${pathToString(patch.path)}`
643
+ // : `${pathToString(patch.path)} = ${JSON.stringify(
644
+ // patch.value,
645
+ // null,
646
+ // 2
647
+ // )}`}
648
+ // </pre>
649
+ // ))}
650
+ // </div>
651
+ // </button>
652
+ // ))}
653
+ // </div>
654
+
655
+ function pathToString(extendedPath: ExtendedPathKey[]) {
656
+ return extendedPath
657
+ .map((key) =>
658
+ typeof key === "object"
659
+ ? `:${ellipsis(key.id.toString(), 8, "middle")}`
660
+ : key
661
+ )
662
+ .map((key, index) =>
663
+ index === 0 || (typeof key === "string" && key.startsWith(":"))
664
+ ? key
665
+ : `.${key}`
666
+ )
667
+ .join("");
668
+ }
669
+
670
+ type EllipsisPosition = "start" | "middle" | "end";
671
+
672
+ function ellipsis(
673
+ str: string,
674
+ maxLength: number,
675
+ position: EllipsisPosition = "end"
676
+ ) {
677
+ if (str.length <= maxLength) return str;
678
+
679
+ switch (position) {
680
+ case "start":
681
+ return `…${str.slice(str.length - maxLength)}`;
682
+ case "middle":
683
+ const halfLength = Math.floor(maxLength / 2);
684
+ return `${str.slice(0, halfLength)}…${str.slice(str.length - halfLength)}`;
685
+ case "end":
686
+ return `${str.slice(0, maxLength)}…`;
687
+ }
688
+ }
@@ -0,0 +1,28 @@
1
+ import React from "react";
2
+
3
+ const localStorage = typeof window !== "undefined" ? window.localStorage : null;
4
+
5
+ export function useLocalStorageState<S>(
6
+ key: string,
7
+ defaultValue: S
8
+ ): [S, (value: S) => void] {
9
+ const [state, setState] = React.useState<S>(() => {
10
+ const value = localStorage?.getItem(key);
11
+ let result = defaultValue;
12
+ if (value) {
13
+ try {
14
+ result = JSON.parse(value);
15
+ } catch (error) {
16
+ console.error("Error parsing localStorage value", error);
17
+ }
18
+ }
19
+ return result;
20
+ });
21
+
22
+ const setLocalStorageState = (value: S) => {
23
+ localStorage?.setItem(key, JSON.stringify(value));
24
+ setState(value);
25
+ };
26
+
27
+ return [state, setLocalStorageState];
28
+ }