@noya-app/noya-multiplayer-react 0.1.61 → 0.1.63

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,28 @@
1
+ import React, { CSSProperties } from "react";
2
+
3
+ // Rotates ▶️ or ▼ based on expanded state
4
+ export function StateInspectorArrow({
5
+ expanded,
6
+ onClick,
7
+ style,
8
+ }: {
9
+ expanded: boolean;
10
+ onClick?: () => void;
11
+ style?: CSSProperties;
12
+ }) {
13
+ return (
14
+ <span
15
+ role="button"
16
+ onClick={onClick}
17
+ style={{
18
+ display: "inline-block",
19
+ textAlign: "center",
20
+ transform: expanded ? "rotate(0deg)" : "rotate(-90deg)",
21
+ fontFamily: "Menlo, monospace",
22
+ ...style,
23
+ }}
24
+ >
25
+ {"▼"}
26
+ </span>
27
+ );
28
+ }
@@ -0,0 +1,45 @@
1
+ import React, { CSSProperties } from "react";
2
+ import { lightInspectorTheme } from "./inspectorTheme";
3
+
4
+ export function StateInspectorButton({
5
+ children,
6
+ onClick,
7
+ style,
8
+ theme,
9
+ disabled,
10
+ }: {
11
+ children: React.ReactNode;
12
+ onClick: () => void;
13
+ style?: CSSProperties;
14
+ theme: typeof lightInspectorTheme;
15
+ disabled?: boolean;
16
+ }) {
17
+ return (
18
+ <button
19
+ type="button"
20
+ disabled={disabled}
21
+ onClick={(event) => {
22
+ event.stopPropagation();
23
+ onClick();
24
+ }}
25
+ style={{
26
+ flex: "0",
27
+ appearance: "none",
28
+ background: "none",
29
+ color: theme.BASE_COLOR,
30
+ opacity: disabled ? 0.25 : 1,
31
+ border: "none",
32
+ fontSize: "12px",
33
+ whiteSpace: "nowrap",
34
+ display: "inline-flex",
35
+ alignItems: "center",
36
+ justifyContent: "center",
37
+ padding: "0",
38
+ cursor: "pointer",
39
+ ...style,
40
+ }}
41
+ >
42
+ {children}
43
+ </button>
44
+ );
45
+ }
@@ -0,0 +1,97 @@
1
+ "use client";
2
+
3
+ import React, {
4
+ CSSProperties,
5
+ DetailedHTMLProps,
6
+ ForwardedRef,
7
+ forwardRef,
8
+ HTMLAttributes,
9
+ } from "react";
10
+ import { darkInspectorTheme, lightInspectorTheme } from "./inspectorTheme";
11
+ import { StateInspectorArrow } from "./StateInspectorArrow";
12
+
13
+ export function StateInspectorDisclosureSection({
14
+ open,
15
+ setOpen,
16
+ title,
17
+ right,
18
+ children,
19
+ colorScheme,
20
+ isFirst,
21
+ style,
22
+ }: {
23
+ open: boolean;
24
+ setOpen?: (value: boolean) => void;
25
+ title: React.ReactNode;
26
+ right?: React.ReactNode;
27
+ children: React.ReactNode;
28
+ colorScheme: "light" | "dark";
29
+ isFirst?: boolean;
30
+ style?: CSSProperties;
31
+ }) {
32
+ const theme =
33
+ colorScheme === "light" ? lightInspectorTheme : darkInspectorTheme;
34
+
35
+ const borderColor =
36
+ colorScheme === "light" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.1)";
37
+
38
+ return (
39
+ <div
40
+ style={{
41
+ flex: open ? "1 1 0" : "0",
42
+ display: "flex",
43
+ flexDirection: "column",
44
+ ...style,
45
+ }}
46
+ >
47
+ <div
48
+ onClick={() => setOpen?.(!open)}
49
+ style={{
50
+ cursor: "default",
51
+ fontSize: "12px",
52
+ padding: "4px 10px",
53
+ display: "flex",
54
+ alignItems: "center",
55
+ ...(!isFirst && { borderTop: `1px solid ${borderColor}` }),
56
+ ...(open && { borderBottom: `1px solid ${borderColor}` }),
57
+ }}
58
+ >
59
+ {setOpen && (
60
+ <StateInspectorArrow
61
+ expanded={open}
62
+ style={{
63
+ fontSize: theme.ARROW_FONT_SIZE,
64
+ marginRight: theme.ARROW_MARGIN_RIGHT + 1,
65
+ color: theme.ARROW_COLOR,
66
+ }}
67
+ />
68
+ )}
69
+ <span style={{ flex: "1 1 0", userSelect: "none" }}>{title}</span>
70
+ {right}
71
+ </div>
72
+ {open && children}
73
+ </div>
74
+ );
75
+ }
76
+
77
+ export const StateInspectorDisclosureRowInner = forwardRef(
78
+ function StateInspectorDisclosureRowInner(
79
+ props: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>,
80
+ forwardedRef: ForwardedRef<HTMLDivElement>
81
+ ) {
82
+ return (
83
+ <div
84
+ style={{
85
+ flex: "1 1 0",
86
+ overflowY: "auto",
87
+ overflowX: "hidden",
88
+ display: "flex",
89
+ flexDirection: "column",
90
+ ...props.style,
91
+ }}
92
+ {...props}
93
+ ref={forwardedRef}
94
+ />
95
+ );
96
+ }
97
+ );
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import React, { CSSProperties } from "react";
4
+
5
+ export function StateInspectorRow({
6
+ children,
7
+ colorScheme,
8
+ selected,
9
+ style,
10
+ variant,
11
+ }: {
12
+ children: React.ReactNode;
13
+ colorScheme: "light" | "dark";
14
+ selected?: boolean;
15
+ style?: CSSProperties;
16
+ variant?: "up" | "down";
17
+ }) {
18
+ const solidBorderColor =
19
+ colorScheme === "light" ? "rgb(223 223 223)" : "rgb(29 29 29)";
20
+
21
+ return (
22
+ <div
23
+ style={{
24
+ borderBottom: `1px solid ${solidBorderColor}`,
25
+ fontSize: "12px",
26
+ fontFamily: "Menlo, monospace",
27
+ padding: "2px 12px 1px",
28
+ display: "flex",
29
+ alignItems: "center",
30
+ background:
31
+ variant === "up"
32
+ ? "rgba(0,255,0,0.1)"
33
+ : variant === "down"
34
+ ? "transparent"
35
+ : selected
36
+ ? "rgb(59 130 246 / 15%)"
37
+ : undefined,
38
+ // background:
39
+ // colorScheme === "light"
40
+ // ? "rgba(0,0,0,0.05)"
41
+ // : "rgba(255,255,255,0.05)",
42
+ ...style,
43
+ }}
44
+ >
45
+ <span
46
+ style={{
47
+ fontFamily: "Menlo, monospace",
48
+ fontSize: "11px",
49
+ borderRadius: 4,
50
+ display: "inline-block",
51
+ }}
52
+ >
53
+ {children}
54
+ </span>
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,82 @@
1
+ import React from "react";
2
+ import { StateInspectorAnchor } from "./StateInspector";
3
+ import { lightInspectorTheme } from "./inspectorTheme";
4
+
5
+ export function StateInspectorToggleButton({
6
+ showInspector,
7
+ setShowInspector,
8
+ theme,
9
+ anchor,
10
+ }: {
11
+ showInspector: boolean;
12
+ setShowInspector: (value: boolean) => void;
13
+ theme: typeof lightInspectorTheme;
14
+ anchor: StateInspectorAnchor;
15
+ }) {
16
+ const isRightAnchor = anchor === "right";
17
+
18
+ const rightIcon = (
19
+ <svg
20
+ xmlns="http://www.w3.org/2000/svg"
21
+ fill="none"
22
+ viewBox="0 0 24 24"
23
+ strokeWidth={1.5}
24
+ stroke="currentColor"
25
+ className="size-6"
26
+ >
27
+ <path
28
+ strokeLinecap="round"
29
+ strokeLinejoin="round"
30
+ d="m5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5"
31
+ />
32
+ </svg>
33
+ );
34
+
35
+ const leftIcon = (
36
+ <svg
37
+ xmlns="http://www.w3.org/2000/svg"
38
+ fill="none"
39
+ viewBox="0 0 24 24"
40
+ strokeWidth={1.5}
41
+ stroke="currentColor"
42
+ className="size-6"
43
+ >
44
+ <path
45
+ strokeLinecap="round"
46
+ strokeLinejoin="round"
47
+ d="m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5"
48
+ />
49
+ </svg>
50
+ );
51
+
52
+ return (
53
+ <span
54
+ role="button"
55
+ style={{
56
+ flex: "0",
57
+ appearance: "none",
58
+ color: theme.BASE_COLOR,
59
+ border: "none",
60
+ fontSize: "12px",
61
+ whiteSpace: "nowrap",
62
+ display: "inline-flex",
63
+ alignItems: "center",
64
+ justifyContent: "center",
65
+ padding: "2px 0",
66
+ }}
67
+ onClick={(event) => {
68
+ event.stopPropagation();
69
+
70
+ setShowInspector(!showInspector);
71
+ }}
72
+ >
73
+ {showInspector ? (
74
+ "Hide Inspector"
75
+ ) : (
76
+ <span style={{ width: "12px", height: "12px" }}>
77
+ {isRightAnchor ? rightIcon : leftIcon}
78
+ </span>
79
+ )}
80
+ </span>
81
+ );
82
+ }
@@ -0,0 +1,21 @@
1
+ import { chromeDark, chromeLight } from "react-inspector";
2
+
3
+ export const lightInspectorTheme = {
4
+ ...chromeLight,
5
+ BASE_BACKGROUND_COLOR: "transparent",
6
+ OBJECT_NAME_COLOR: "rgba(0,0,0,0.7)",
7
+ } as any;
8
+
9
+ export const darkInspectorTheme = {
10
+ ...chromeDark,
11
+ BASE_BACKGROUND_COLOR: "transparent",
12
+ OBJECT_NAME_COLOR: "rgba(255,255,255,0.7)",
13
+ } as any;
14
+
15
+ export const getStateInspectorTheme = (colorScheme: "light" | "dark") => {
16
+ return colorScheme === "light" ? lightInspectorTheme : darkInspectorTheme;
17
+ };
18
+
19
+ export const getStateInspectorBorderColor = (colorScheme: "light" | "dark") => {
20
+ return colorScheme === "light" ? "rgb(223 223 223)" : "rgb(29 29 29)";
21
+ };
@@ -0,0 +1,89 @@
1
+ import { ConnectionEvent } from "@noya-app/state-manager";
2
+ import React from "react";
3
+ import { ObjectInspector, ObjectLabel } from "react-inspector";
4
+ import { getStateInspectorTheme } from "../inspectorTheme";
5
+ import { ObjectRootLabel } from "../ObjectRootLabel";
6
+ import {
7
+ StateInspectorDisclosureRowInner,
8
+ StateInspectorDisclosureSection,
9
+ } from "../StateInspectorDisclosureSection";
10
+ import { StateInspectorRow } from "../StateInspectorRow";
11
+
12
+ export function EventsSection<S>({
13
+ showEvents,
14
+ setShowEvents,
15
+ colorScheme,
16
+ eventsContainerRef,
17
+ connectionEvents,
18
+ }: {
19
+ showEvents: boolean;
20
+ setShowEvents: (value: boolean) => void;
21
+ colorScheme: "light" | "dark";
22
+ eventsContainerRef: React.RefObject<HTMLDivElement | null>;
23
+ connectionEvents: ConnectionEvent<S>[];
24
+ }) {
25
+ const theme = getStateInspectorTheme(colorScheme);
26
+
27
+ return (
28
+ <StateInspectorDisclosureSection
29
+ open={showEvents}
30
+ setOpen={setShowEvents}
31
+ title="Events"
32
+ colorScheme={colorScheme}
33
+ >
34
+ <StateInspectorDisclosureRowInner ref={eventsContainerRef}>
35
+ {connectionEvents?.map((event, index) =>
36
+ event.type === "stateChange" ? (
37
+ <StateInspectorRow key={index} colorScheme={colorScheme}>
38
+ connection:{" "}
39
+ <span style={{ fontStyle: "italic" }}>
40
+ {event.state.toLowerCase()}
41
+ </span>
42
+ </StateInspectorRow>
43
+ ) : (
44
+ <StateInspectorRow
45
+ key={index}
46
+ colorScheme={colorScheme}
47
+ variant={event.type === "send" ? "up" : "down"}
48
+ style={{
49
+ padding: "0px 12px 1px",
50
+ }}
51
+ >
52
+ <ObjectInspector
53
+ data={event.type === "error" ? event.error : event.message}
54
+ theme={theme}
55
+ nodeRenderer={({ depth, name, data, isNonenumerable }: any) => {
56
+ const direction = event.type === "send" ? "up" : "down";
57
+
58
+ return depth === 0 ? (
59
+ <ObjectRootLabel direction={direction} data={data} />
60
+ ) : (
61
+ <ObjectLabel
62
+ direction={direction}
63
+ name={name}
64
+ data={data}
65
+ isNonenumerable={isNonenumerable}
66
+ />
67
+ );
68
+ }}
69
+ />
70
+ </StateInspectorRow>
71
+ )
72
+ )}
73
+ {!connectionEvents && (
74
+ <div
75
+ style={{
76
+ padding: "12px",
77
+ fontSize: "12px",
78
+ display: "flex",
79
+ flexDirection: "column",
80
+ gap: "4px",
81
+ }}
82
+ >
83
+ <span>No recorded events</span>
84
+ </div>
85
+ )}
86
+ </StateInspectorDisclosureRowInner>
87
+ </StateInspectorDisclosureSection>
88
+ );
89
+ }
@@ -0,0 +1,236 @@
1
+ "use client";
2
+
3
+ import {
4
+ HistorySnapshot,
5
+ MultiplayerPatchMetadata,
6
+ MultiplayerStateManager,
7
+ } from "@noya-app/state-manager";
8
+ import React, { useEffect, useRef } from "react";
9
+ import { ObjectInspector } from "react-inspector";
10
+ import { StateInspectorButton } from "../StateInspectorButton";
11
+ import {
12
+ StateInspectorDisclosureRowInner,
13
+ StateInspectorDisclosureSection,
14
+ } from "../StateInspectorDisclosureSection";
15
+ import {
16
+ getStateInspectorBorderColor,
17
+ getStateInspectorTheme,
18
+ } from "../inspectorTheme";
19
+ import { pathToString } from "../utils";
20
+
21
+ const HISTORY_ELEMENT_PREFIX = "noya-multiplayer-history-";
22
+
23
+ export function HistorySection<S, M extends object>({
24
+ showHistory,
25
+ setShowHistory,
26
+ colorScheme,
27
+ historySnapshot,
28
+ multiplayerStateManager,
29
+ }: {
30
+ showHistory: boolean;
31
+ setShowHistory: (value: boolean) => void;
32
+ colorScheme: "light" | "dark";
33
+ historySnapshot: HistorySnapshot<S, Partial<M> & MultiplayerPatchMetadata>;
34
+ multiplayerStateManager: MultiplayerStateManager<S, M>;
35
+ }) {
36
+ const ref = useRef<HTMLDivElement>(null);
37
+ const theme = getStateInspectorTheme(colorScheme);
38
+ const solidBorderColor = getStateInspectorBorderColor(colorScheme);
39
+
40
+ useEffect(() => {
41
+ if (ref.current) {
42
+ ref.current.scrollTop = ref.current.scrollHeight;
43
+ }
44
+ }, [historySnapshot]);
45
+
46
+ // If history index changes, scroll to the new history entry into view
47
+ useEffect(() => {
48
+ if (!ref.current) return;
49
+
50
+ const historyEntry = ref.current.querySelector(
51
+ `#${HISTORY_ELEMENT_PREFIX}${historySnapshot.historyIndex}`
52
+ );
53
+
54
+ if (historyEntry) {
55
+ historyEntry.scrollIntoView({
56
+ block: "nearest",
57
+ inline: "nearest",
58
+ });
59
+ }
60
+ }, [historySnapshot.historyIndex]);
61
+
62
+ return (
63
+ <StateInspectorDisclosureSection
64
+ open={showHistory}
65
+ setOpen={setShowHistory}
66
+ title="History"
67
+ colorScheme={colorScheme}
68
+ right={
69
+ <span
70
+ style={{
71
+ display: "flex",
72
+ gap: "4px",
73
+ }}
74
+ >
75
+ <StateInspectorButton
76
+ disabled={!historySnapshot.canUndo}
77
+ theme={theme}
78
+ onClick={() => {
79
+ multiplayerStateManager.undo();
80
+ }}
81
+ >
82
+ <span style={{ width: "12px", height: "12px" }}>
83
+ <svg
84
+ xmlns="http://www.w3.org/2000/svg"
85
+ width="1em"
86
+ height="1em"
87
+ viewBox="0 0 20 20"
88
+ >
89
+ <path
90
+ fill="currentColor"
91
+ d="M12 5H7V2L1 6l6 4V7h5c2.2 0 4 1.8 4 4s-1.8 4-4 4H7v2h5c3.3 0 6-2.7 6-6s-2.7-6-6-6"
92
+ />
93
+ </svg>
94
+ </span>
95
+ </StateInspectorButton>
96
+ <StateInspectorButton
97
+ disabled={!historySnapshot.canRedo}
98
+ theme={theme}
99
+ onClick={() => {
100
+ multiplayerStateManager.redo();
101
+ }}
102
+ >
103
+ <span style={{ width: "12px", height: "12px" }}>
104
+ <svg
105
+ xmlns="http://www.w3.org/2000/svg"
106
+ width="1em"
107
+ height="1em"
108
+ viewBox="0 0 20 20"
109
+ >
110
+ <path
111
+ fill="currentColor"
112
+ d="M8 5h5V2l6 4l-6 4V7H8c-2.2 0-4 1.8-4 4s1.8 4 4 4h5v2H8c-3.3 0-6-2.7-6-6s2.7-6 6-6"
113
+ />
114
+ </svg>
115
+ </span>
116
+ </StateInspectorButton>
117
+ </span>
118
+ }
119
+ >
120
+ <StateInspectorDisclosureRowInner ref={ref}>
121
+ <div
122
+ id={`${HISTORY_ELEMENT_PREFIX}0`}
123
+ style={{
124
+ borderBottom: `1px solid ${solidBorderColor}`,
125
+ fontSize: "12px",
126
+ fontFamily: "Menlo, monospace",
127
+ padding: "2px 12px 1px",
128
+ display: "flex",
129
+ alignItems: "center",
130
+ background:
131
+ historySnapshot.historyIndex === 0
132
+ ? "rgb(59 130 246 / 15%)"
133
+ : undefined,
134
+ }}
135
+ >
136
+ <span
137
+ style={{
138
+ fontFamily: "Menlo, monospace",
139
+ fontSize: "11px",
140
+ borderRadius: 4,
141
+ display: "inline-block",
142
+ }}
143
+ >
144
+ Initial state
145
+ </span>
146
+ </div>
147
+ {historySnapshot.history.map((entry, index) => {
148
+ const metadata = entry.metadata;
149
+ const { id, name, timestamp, ...rest } = metadata;
150
+
151
+ return (
152
+ <div
153
+ id={`${HISTORY_ELEMENT_PREFIX}${index + 1}`}
154
+ key={index}
155
+ style={{
156
+ padding: "0px 12px 1px",
157
+ borderBottom: `1px solid ${solidBorderColor}`,
158
+ background:
159
+ index + 1 === historySnapshot.historyIndex
160
+ ? "rgb(59 130 246 / 15%)"
161
+ : undefined,
162
+ }}
163
+ >
164
+ {typeof name === "string" && (
165
+ <pre
166
+ style={{
167
+ fontSize: "11px",
168
+ display: "flex",
169
+ flexWrap: "wrap",
170
+ margin: 0,
171
+ }}
172
+ >
173
+ {entry.metadata.name}
174
+ {Object.keys(rest).length > 0 && (
175
+ <ObjectInspector data={rest} theme={theme} />
176
+ )}
177
+ </pre>
178
+ )}
179
+ {entry.redoPatches?.map((patch, j) => (
180
+ <pre
181
+ key={j}
182
+ style={{
183
+ fontSize: "11px",
184
+ display: "flex",
185
+ flexWrap: "wrap",
186
+ margin: 0,
187
+ }}
188
+ >
189
+ {patch.op === "add" && (
190
+ <>
191
+ <span style={{ fontStyle: "italic" }}>
192
+ {pathToString(patch.path)}
193
+ </span>
194
+ {" = "}
195
+ <ObjectInspector data={patch.value} theme={theme} />
196
+ </>
197
+ )}
198
+ {patch.op === "replace" && (
199
+ <>
200
+ <span style={{ fontStyle: "italic" }}>
201
+ {pathToString(patch.path)}
202
+ </span>
203
+ {" = "}
204
+ <ObjectInspector data={patch.value} theme={theme} />
205
+ </>
206
+ )}
207
+ {patch.op === "remove" && (
208
+ <>
209
+ <span style={{ color: theme.OBJECT_VALUE_STRING_COLOR }}>
210
+ delete{" "}
211
+ </span>
212
+ <span style={{ fontStyle: "italic" }}>
213
+ {pathToString(patch.path)}
214
+ </span>
215
+ </>
216
+ )}
217
+ {patch.op === "move" && (
218
+ <>
219
+ <span style={{ fontStyle: "italic" }}>
220
+ {pathToString(patch.from!)}
221
+ </span>
222
+ {" -> "}
223
+ <span style={{ fontStyle: "italic" }}>
224
+ {pathToString(patch.path)}
225
+ </span>
226
+ </>
227
+ )}
228
+ </pre>
229
+ ))}
230
+ </div>
231
+ );
232
+ })}
233
+ </StateInspectorDisclosureRowInner>
234
+ </StateInspectorDisclosureSection>
235
+ );
236
+ }