@noya-app/noya-multiplayer-react 0.1.62 → 0.1.64

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,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
+ }
@@ -0,0 +1,202 @@
1
+ import { Asset, decodeSchema, encodeSchema } from "@noya-app/noya-schemas";
2
+ import { createHash, NoyaManager, TSchema } from "@noya-app/state-manager";
3
+ import { TinyZip } from "./zip/TinyZip";
4
+
5
+ export async function fetchAssetMap(
6
+ assets: Asset[]
7
+ ): Promise<Record<string, Uint8Array>> {
8
+ const res = await Promise.all(
9
+ assets.map(async (asset) => {
10
+ const url = asset.url;
11
+ const response = await fetch(url);
12
+ const data = await response.arrayBuffer();
13
+ return [asset.id, data];
14
+ })
15
+ );
16
+
17
+ return Object.fromEntries(res);
18
+ }
19
+
20
+ export async function exportAll(noyaManager: NoyaManager<any, any, any, any>) {
21
+ const { multiplayerStateManager, assetManager } = noyaManager;
22
+
23
+ const assetMap = await fetchAssetMap(assetManager.assets$.get());
24
+
25
+ return encodeAll({
26
+ state: multiplayerStateManager.optimisticState$.get(),
27
+ schema: multiplayerStateManager.schema,
28
+ assets: assetManager.assets$.get(),
29
+ assetMap,
30
+ });
31
+ }
32
+
33
+ export function encodeAll({
34
+ state,
35
+ schema,
36
+ assets,
37
+ assetMap,
38
+ }: {
39
+ state: unknown;
40
+ schema?: TSchema;
41
+ assets: Asset[];
42
+ assetMap: Record<string, Uint8Array>;
43
+ }) {
44
+ const zip = new TinyZip();
45
+
46
+ zip.addFile("state.json", JSON.stringify(state, null, 2));
47
+
48
+ if (schema) {
49
+ const encodedSchema = encodeSchema(schema);
50
+
51
+ zip.addFile("schema.json", JSON.stringify(encodedSchema, null, 2));
52
+ }
53
+
54
+ if (assets.length) {
55
+ zip.addFile("assets.json", JSON.stringify(assets, null, 2));
56
+ }
57
+
58
+ for (const [id, data] of Object.entries(assetMap)) {
59
+ zip.addFile(`assets/${id}`, data);
60
+ }
61
+
62
+ return zip.finish();
63
+ }
64
+
65
+ export async function importAll(
66
+ buffer: Uint8Array | ArrayBuffer,
67
+ noyaManager: NoyaManager<any, any, any, any>,
68
+ {
69
+ shouldAllowSchemaChange,
70
+ }: {
71
+ shouldAllowSchemaChange?: ({
72
+ oldSchema,
73
+ newSchema,
74
+ }: {
75
+ oldSchema: TSchema;
76
+ newSchema: TSchema;
77
+ }) => boolean;
78
+ } = {}
79
+ ) {
80
+ let { state, schema, assets, assetMap } = await decodeAll(buffer);
81
+
82
+ if (shouldAllowSchemaChange && noyaManager.options.schema && schema) {
83
+ const oldSchemaHash = createHash(noyaManager.options.schema);
84
+ const newSchemaHash = createHash(schema);
85
+
86
+ if (oldSchemaHash !== newSchemaHash) {
87
+ const shouldAllow = shouldAllowSchemaChange({
88
+ oldSchema: noyaManager.options.schema,
89
+ newSchema: schema,
90
+ });
91
+
92
+ if (!shouldAllow) {
93
+ throw new Error("Schema change not allowed");
94
+ }
95
+ }
96
+ }
97
+
98
+ if (assets.length) {
99
+ // Delete all existing assets
100
+ await Promise.all(
101
+ noyaManager.assetManager.assets$
102
+ .get()
103
+ .map((asset) => noyaManager.assetManager.delete(asset.id))
104
+ );
105
+
106
+ // Add new assets
107
+ const idPairs = await Promise.all(
108
+ assets.map(async (asset): Promise<[string, string]> => {
109
+ const newAsset = await noyaManager.assetManager.create({
110
+ data: assetMap[asset.id],
111
+ contentType: asset.contentType,
112
+ });
113
+
114
+ return [asset.id, newAsset.id];
115
+ })
116
+ );
117
+
118
+ const replacementMap = Object.fromEntries(idPairs);
119
+
120
+ state = replaceDeep(state, replacementMap);
121
+ }
122
+
123
+ // if (schema) {
124
+ // noyaManager.options.schema = schema;
125
+ // }
126
+
127
+ noyaManager.initialState = state;
128
+ noyaManager.forceInit();
129
+ }
130
+
131
+ export function decodeAll(buffer: Uint8Array | ArrayBuffer): {
132
+ state: unknown;
133
+ schema?: TSchema;
134
+ assets: Asset[];
135
+ assetMap: Record<string, Uint8Array>;
136
+ } {
137
+ const zip = TinyZip.extract(buffer);
138
+
139
+ const stateFile = zip["state.json"];
140
+ const schemaFile = zip["schema.json"];
141
+ const assetsFile = zip["assets.json"];
142
+
143
+ if (!stateFile) {
144
+ throw new Error("State file not found");
145
+ }
146
+
147
+ const state = JSON.parse(new TextDecoder().decode(stateFile));
148
+
149
+ let schema: TSchema | undefined;
150
+
151
+ if (schemaFile) {
152
+ const encodedSchema = JSON.parse(new TextDecoder().decode(schemaFile));
153
+ schema = decodeSchema(encodedSchema);
154
+ }
155
+
156
+ let assets: Asset[] = [];
157
+
158
+ let assetMap: Record<string, Uint8Array> = {};
159
+
160
+ if (assetsFile) {
161
+ assets = JSON.parse(new TextDecoder().decode(assetsFile));
162
+
163
+ for (const asset of assets) {
164
+ const data = zip[`assets/${asset.id}`];
165
+
166
+ if (!data) {
167
+ throw new Error(`Asset ${asset.id} not found`);
168
+ }
169
+
170
+ assetMap[asset.id] = data;
171
+ }
172
+ }
173
+
174
+ return { state, schema, assets, assetMap };
175
+ }
176
+
177
+ export function replaceDeep<T>(
178
+ obj: T,
179
+ replacementMap: Record<string, string>
180
+ ): T {
181
+ switch (typeof obj) {
182
+ case "object":
183
+ if (obj === null) {
184
+ return obj;
185
+ }
186
+
187
+ if (Array.isArray(obj)) {
188
+ return obj.map((item) => replaceDeep(item, replacementMap)) as any;
189
+ }
190
+
191
+ return Object.fromEntries(
192
+ Object.entries(obj).map(([key, value]) => [
193
+ key,
194
+ replaceDeep(value, replacementMap),
195
+ ])
196
+ ) as any;
197
+ case "string":
198
+ return (replacementMap[obj] ?? obj) as any;
199
+ default:
200
+ return obj;
201
+ }
202
+ }
@@ -0,0 +1,60 @@
1
+ import { ExtendedPathKey } from "@noya-app/state-manager";
2
+
3
+ export function pathToString(extendedPath: ExtendedPathKey[]) {
4
+ return extendedPath
5
+ .map((key) =>
6
+ typeof key === "object"
7
+ ? `:${ellipsis(key.id.toString(), 8, "middle")}`
8
+ : key
9
+ )
10
+ .map((key, index) =>
11
+ index === 0 || (typeof key === "string" && key.startsWith(":"))
12
+ ? key
13
+ : `.${key}`
14
+ )
15
+ .join("");
16
+ }
17
+
18
+ export type EllipsisPosition = "start" | "middle" | "end";
19
+
20
+ export function ellipsis(
21
+ str: string,
22
+ maxLength: number,
23
+ position: EllipsisPosition = "end"
24
+ ) {
25
+ if (str.length <= maxLength) return str;
26
+
27
+ switch (position) {
28
+ case "start":
29
+ return `…${str.slice(str.length - maxLength)}`;
30
+ case "middle": {
31
+ const halfLength = Math.floor(maxLength / 2);
32
+ return `${str.slice(0, halfLength)}…${str.slice(str.length - halfLength)}`;
33
+ }
34
+ case "end":
35
+ return `${str.slice(0, maxLength)}…`;
36
+ }
37
+ }
38
+
39
+ export function uploadFile() {
40
+ return new Promise<File>((resolve, reject) => {
41
+ const input = document.createElement("input");
42
+ input.type = "file";
43
+ input.onchange = () => {
44
+ const file = input.files?.[0];
45
+ if (file) {
46
+ const reader = new FileReader();
47
+ reader.onload = () => {
48
+ const buffer = reader.result as ArrayBuffer;
49
+ const blob = new File([buffer], file.name, { type: file.type });
50
+ resolve(blob);
51
+ };
52
+ reader.onerror = () => {
53
+ reject(new Error("Failed to read file"));
54
+ };
55
+ reader.readAsArrayBuffer(file);
56
+ }
57
+ };
58
+ input.click();
59
+ });
60
+ }