@arronqzy/react-view 1.0.28 → 1.0.30
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/package.json +7 -7
- package/src/panel/ReactViewPanel.tsx +52 -8
- package/src/panel/components/ElementsLayer.tsx +178 -106
- package/src/panel/components/MoveableLayer.tsx +40 -3
- package/src/panel/components/PanelConfigSidebar.tsx +32 -0
- package/src/panel/constants/messages.ts +2 -0
- package/src/panel/hooks/usePanelElements.ts +24 -13
- package/src/panel/hooks/useWorkspaceProjects.ts +84 -49
- package/src/panel/library/workspace-project-db.ts +163 -16
- package/src/panel/library/workspace-storage-error.ts +60 -0
- package/src/panel/utils/canvas-interaction-busy.ts +36 -0
- package/src/panel/utils/materializePanelElements.ts +97 -0
- package/src/panel/utils/panelStateIO.ts +22 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
2
2
|
import { store, type Node, type State } from "@arronqzy/rx-store";
|
|
3
3
|
import { useI18nOptional } from "@arronqzy/i18n/react";
|
|
4
4
|
import type {
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
applyGridLayoutPatchAcrossMappingFamily,
|
|
48
48
|
applyMappingFamilySyncPatch,
|
|
49
49
|
} from "../utils/updateElementDraft";
|
|
50
|
+
import { materializePanelElements, stabilizeElementList } from "../utils/materializePanelElements";
|
|
50
51
|
|
|
51
52
|
export type { PanelActionResult, PanelHistoryItem, PanelLayer } from "../types";
|
|
52
53
|
|
|
@@ -54,6 +55,8 @@ export function usePanelElements() {
|
|
|
54
55
|
const { t } = useI18nOptional();
|
|
55
56
|
const messages = useMemo(() => getPanelMessages(t), [t]);
|
|
56
57
|
const defaultLayer = useMemo(() => getDefaultLayer(t), [t]);
|
|
58
|
+
const allElementsCacheRef = useRef<PanelElement[]>([]);
|
|
59
|
+
const elementsCacheRef = useRef<PanelElement[]>([]);
|
|
57
60
|
|
|
58
61
|
const state = useSyncExternalStore(
|
|
59
62
|
(cb) => {
|
|
@@ -135,19 +138,20 @@ export function usePanelElements() {
|
|
|
135
138
|
}, []);
|
|
136
139
|
|
|
137
140
|
const allElements = useMemo(() => {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
});
|
|
141
|
+
const next = materializePanelElements(
|
|
142
|
+
state.root.children,
|
|
143
|
+
allElementsCacheRef.current
|
|
144
|
+
);
|
|
145
|
+
allElementsCacheRef.current = next;
|
|
146
|
+
return next;
|
|
145
147
|
}, [state.root.children]);
|
|
146
148
|
|
|
147
|
-
const elements = useMemo(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
const elements = useMemo(() => {
|
|
150
|
+
const next = allElements.filter((el) => el.layerId === activeLayerId);
|
|
151
|
+
const stable = stabilizeElementList(next, elementsCacheRef.current);
|
|
152
|
+
elementsCacheRef.current = stable;
|
|
153
|
+
return stable;
|
|
154
|
+
}, [activeLayerId, allElements]);
|
|
151
155
|
|
|
152
156
|
const byId = useMemo(() => {
|
|
153
157
|
const map = new Map<string, PanelElement>();
|
|
@@ -1077,7 +1081,14 @@ export function usePanelElements() {
|
|
|
1077
1081
|
/* fall through */
|
|
1078
1082
|
}
|
|
1079
1083
|
}
|
|
1080
|
-
|
|
1084
|
+
try {
|
|
1085
|
+
return JSON.parse(JSON.stringify(current)) as State;
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
throw Object.assign(new Error("indexeddb-serialize-failed"), {
|
|
1088
|
+
name: "DataCloneError",
|
|
1089
|
+
cause: error,
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1081
1092
|
}, []);
|
|
1082
1093
|
|
|
1083
1094
|
const importPanelData = useCallback((nextState: State) => {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
workspaceSnapshotsEqual,
|
|
20
20
|
type WorkspaceSnapshot,
|
|
21
21
|
} from "../library/workspace-snapshot";
|
|
22
|
+
import { mapWorkspaceStorageError } from "../library/workspace-storage-error";
|
|
22
23
|
import { runBusyTask } from "../utils/async-work";
|
|
23
24
|
import { AbuilderEvents, emitAbuilderEvent } from "../library/event-subscription";
|
|
24
25
|
import { parseBlueprintData } from "@arronqzy/react-blueprint";
|
|
@@ -145,8 +146,13 @@ export function useWorkspaceProjects({
|
|
|
145
146
|
}, [blueprintDocument, blueprintMeta, productName, titleIconDataUrl]);
|
|
146
147
|
|
|
147
148
|
const refreshProjects = useCallback(async () => {
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
try {
|
|
150
|
+
const items = await listWorkspaceProjects(nameSpace);
|
|
151
|
+
setProjects(items);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// Listing failure should not brick the editor; create/sync still surface errors.
|
|
154
|
+
console.warn("[abuilder] listWorkspaceProjects failed", error);
|
|
155
|
+
}
|
|
150
156
|
}, [nameSpace]);
|
|
151
157
|
|
|
152
158
|
useEffect(() => {
|
|
@@ -272,37 +278,58 @@ export function useWorkspaceProjects({
|
|
|
272
278
|
|
|
273
279
|
const persistProject = useCallback(
|
|
274
280
|
async (options: { id?: string; name: string; createdAt?: number }) => {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
281
|
+
try {
|
|
282
|
+
const snapshot = cloneWorkspaceSnapshot(buildCurrentSnapshot());
|
|
283
|
+
const now = Date.now();
|
|
284
|
+
const id = options.id ?? createWorkspaceProjectId();
|
|
285
|
+
const createdAt = options.createdAt ?? now;
|
|
286
|
+
const name = (options.name ?? "").trim() || resolveProjectName();
|
|
280
287
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
blueprintDocument: snapshot.blueprintDocument,
|
|
288
|
-
blueprintMeta: snapshot.blueprintMeta,
|
|
289
|
-
productName:
|
|
290
|
-
typeof snapshot.productName === "string" ? snapshot.productName : resolveProjectName(),
|
|
291
|
-
titleIconDataUrl: snapshot.titleIconDataUrl || undefined,
|
|
292
|
-
};
|
|
288
|
+
let panelState: State;
|
|
289
|
+
try {
|
|
290
|
+
panelState = exportPanelData();
|
|
291
|
+
} catch (error) {
|
|
292
|
+
throw mapWorkspaceStorageError(error, t);
|
|
293
|
+
}
|
|
293
294
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
295
|
+
const record: WorkspaceProjectRecord = {
|
|
296
|
+
id,
|
|
297
|
+
name,
|
|
298
|
+
createdAt,
|
|
299
|
+
updatedAt: now,
|
|
300
|
+
panelState,
|
|
301
|
+
blueprintDocument: snapshot.blueprintDocument,
|
|
302
|
+
blueprintMeta: snapshot.blueprintMeta,
|
|
303
|
+
productName:
|
|
304
|
+
typeof snapshot.productName === "string"
|
|
305
|
+
? snapshot.productName
|
|
306
|
+
: resolveProjectName(),
|
|
307
|
+
titleIconDataUrl: snapshot.titleIconDataUrl || undefined,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
await putWorkspaceProject(record, nameSpace);
|
|
311
|
+
writeWorkspacePreviewCache(record, nameSpace);
|
|
312
|
+
broadcastWorkspaceProjectUpdate(id, now, nameSpace);
|
|
313
|
+
setActiveProjectId(id);
|
|
314
|
+
setActiveProjectName(name);
|
|
315
|
+
syncedSnapshotRef.current = snapshot;
|
|
316
|
+
syncedPanelRevisionRef.current = panelRevision;
|
|
317
|
+
pendingRevisionSyncRef.current = false;
|
|
318
|
+
await refreshProjects();
|
|
319
|
+
return record;
|
|
320
|
+
} catch (error) {
|
|
321
|
+
throw mapWorkspaceStorageError(error, t);
|
|
322
|
+
}
|
|
304
323
|
},
|
|
305
|
-
[
|
|
324
|
+
[
|
|
325
|
+
buildCurrentSnapshot,
|
|
326
|
+
exportPanelData,
|
|
327
|
+
nameSpace,
|
|
328
|
+
panelRevision,
|
|
329
|
+
resolveProjectName,
|
|
330
|
+
refreshProjects,
|
|
331
|
+
t,
|
|
332
|
+
]
|
|
306
333
|
);
|
|
307
334
|
|
|
308
335
|
const handleCreateProject = useCallback(async () => {
|
|
@@ -332,14 +359,18 @@ export function useWorkspaceProjects({
|
|
|
332
359
|
|
|
333
360
|
const handleOpenProject = useCallback(
|
|
334
361
|
async (id: string) => {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
362
|
+
try {
|
|
363
|
+
await runBusyTask(t("common.loadingWorkspace"), async () => {
|
|
364
|
+
const record = await getWorkspaceProject(id, nameSpace);
|
|
365
|
+
if (!record) {
|
|
366
|
+
await refreshProjects();
|
|
367
|
+
throw new Error(t("panel.messages.workspaceNotFound"));
|
|
368
|
+
}
|
|
369
|
+
applyProjectRecord(record);
|
|
370
|
+
});
|
|
371
|
+
} catch (error) {
|
|
372
|
+
throw mapWorkspaceStorageError(error, t);
|
|
373
|
+
}
|
|
343
374
|
},
|
|
344
375
|
[applyProjectRecord, nameSpace, refreshProjects, t]
|
|
345
376
|
);
|
|
@@ -370,17 +401,21 @@ export function useWorkspaceProjects({
|
|
|
370
401
|
|
|
371
402
|
const handleDeleteProject = useCallback(
|
|
372
403
|
async (id: string) => {
|
|
373
|
-
|
|
374
|
-
await
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
404
|
+
try {
|
|
405
|
+
await runBusyTask(t("common.deletingWorkspace"), async () => {
|
|
406
|
+
await deleteWorkspaceProject(id, nameSpace);
|
|
407
|
+
if (activeProjectId === id) {
|
|
408
|
+
setActiveProjectId(null);
|
|
409
|
+
setActiveProjectName(null);
|
|
410
|
+
syncedSnapshotRef.current = null;
|
|
411
|
+
}
|
|
412
|
+
previewWindowsRef.current.delete(id);
|
|
413
|
+
refreshPreviewing();
|
|
414
|
+
await refreshProjects();
|
|
415
|
+
});
|
|
416
|
+
} catch (error) {
|
|
417
|
+
throw mapWorkspaceStorageError(error, t);
|
|
418
|
+
}
|
|
384
419
|
},
|
|
385
420
|
[activeProjectId, nameSpace, refreshPreviewing, refreshProjects, t]
|
|
386
421
|
);
|
|
@@ -6,6 +6,7 @@ import { appStorageKey } from "@arronqzy/blueprint-dsl";
|
|
|
6
6
|
const DB_NAME = "arronqzy-workspace-projects";
|
|
7
7
|
const DB_VERSION = 1;
|
|
8
8
|
const STORE_NAME = "projects";
|
|
9
|
+
const OPEN_TIMEOUT_MS = 10_000;
|
|
9
10
|
|
|
10
11
|
export type WorkspaceProjectListItem = {
|
|
11
12
|
id: string;
|
|
@@ -26,10 +27,77 @@ export type WorkspaceProjectRecord = {
|
|
|
26
27
|
titleIconDataUrl?: string;
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
const dbPromises = new Map<string, Promise<IDBDatabase>>();
|
|
31
|
+
|
|
32
|
+
function getIndexedDb(): IDBFactory {
|
|
33
|
+
if (typeof indexedDB === "undefined" || !indexedDB) {
|
|
34
|
+
throw Object.assign(new Error("indexeddb-unavailable"), {
|
|
35
|
+
name: "IndexedDbUnavailable",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return indexedDB;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Make a value safe for IndexedDB structured clone (drops non-cloneable fields). */
|
|
42
|
+
export function cloneForIndexedDb<T>(value: T): T {
|
|
43
|
+
if (typeof structuredClone === "function") {
|
|
44
|
+
try {
|
|
45
|
+
return structuredClone(value);
|
|
46
|
+
} catch {
|
|
47
|
+
/* fall through to JSON */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw Object.assign(new Error("indexeddb-serialize-failed"), {
|
|
54
|
+
name: "DataCloneError",
|
|
55
|
+
cause: error,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
29
60
|
function openDb(nameSpace?: string | null): Promise<IDBDatabase> {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
61
|
+
const key = appStorageKey(DB_NAME, nameSpace);
|
|
62
|
+
const cached = dbPromises.get(key);
|
|
63
|
+
if (cached) return cached;
|
|
64
|
+
|
|
65
|
+
const promise = new Promise<IDBDatabase>((resolve, reject) => {
|
|
66
|
+
let settled = false;
|
|
67
|
+
const fail = (error: unknown) => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
dbPromises.delete(key);
|
|
71
|
+
reject(
|
|
72
|
+
error instanceof Error
|
|
73
|
+
? error
|
|
74
|
+
: Object.assign(new Error("indexeddb-open-failed"), { cause: error })
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let request: IDBOpenDBRequest;
|
|
79
|
+
try {
|
|
80
|
+
request = getIndexedDb().open(key, DB_VERSION);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
fail(error);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
fail(
|
|
88
|
+
Object.assign(new Error("indexeddb-open-timeout"), {
|
|
89
|
+
name: "TimeoutError",
|
|
90
|
+
})
|
|
91
|
+
);
|
|
92
|
+
}, OPEN_TIMEOUT_MS);
|
|
93
|
+
|
|
94
|
+
request.onerror = () => {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
fail(request.error ?? new Error("indexeddb-open-failed"));
|
|
97
|
+
};
|
|
98
|
+
request.onblocked = () => {
|
|
99
|
+
// Wait for onsuccess / timeout; another tab may be holding an older connection.
|
|
100
|
+
};
|
|
33
101
|
request.onupgradeneeded = () => {
|
|
34
102
|
const db = request.result;
|
|
35
103
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
@@ -37,8 +105,35 @@ function openDb(nameSpace?: string | null): Promise<IDBDatabase> {
|
|
|
37
105
|
store.createIndex("updatedAt", "updatedAt", { unique: false });
|
|
38
106
|
}
|
|
39
107
|
};
|
|
40
|
-
request.onsuccess = () =>
|
|
108
|
+
request.onsuccess = () => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
if (settled) {
|
|
111
|
+
try {
|
|
112
|
+
request.result.close();
|
|
113
|
+
} catch {
|
|
114
|
+
/* ignore */
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
settled = true;
|
|
119
|
+
const db = request.result;
|
|
120
|
+
db.onversionchange = () => {
|
|
121
|
+
try {
|
|
122
|
+
db.close();
|
|
123
|
+
} catch {
|
|
124
|
+
/* ignore */
|
|
125
|
+
}
|
|
126
|
+
dbPromises.delete(key);
|
|
127
|
+
};
|
|
128
|
+
db.onclose = () => {
|
|
129
|
+
dbPromises.delete(key);
|
|
130
|
+
};
|
|
131
|
+
resolve(db);
|
|
132
|
+
};
|
|
41
133
|
});
|
|
134
|
+
|
|
135
|
+
dbPromises.set(key, promise);
|
|
136
|
+
return promise;
|
|
42
137
|
}
|
|
43
138
|
|
|
44
139
|
function runTransaction<T>(
|
|
@@ -49,27 +144,76 @@ function runTransaction<T>(
|
|
|
49
144
|
return openDb(nameSpace).then(
|
|
50
145
|
(db) =>
|
|
51
146
|
new Promise<T>((resolve, reject) => {
|
|
52
|
-
|
|
147
|
+
let tx: IDBTransaction;
|
|
148
|
+
try {
|
|
149
|
+
tx = db.transaction(STORE_NAME, mode);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// Stale connection (e.g. Safari closed it); reopen once.
|
|
152
|
+
dbPromises.delete(appStorageKey(DB_NAME, nameSpace));
|
|
153
|
+
reject(error);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
53
156
|
const store = tx.objectStore(STORE_NAME);
|
|
54
157
|
const request = runner(store);
|
|
55
158
|
let result!: T;
|
|
159
|
+
let settled = false;
|
|
160
|
+
|
|
161
|
+
const fail = (error: unknown) => {
|
|
162
|
+
if (settled) return;
|
|
163
|
+
settled = true;
|
|
164
|
+
reject(
|
|
165
|
+
error instanceof Error
|
|
166
|
+
? error
|
|
167
|
+
: Object.assign(new Error("indexeddb-transaction-failed"), {
|
|
168
|
+
cause: error,
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
};
|
|
56
172
|
|
|
57
173
|
request.onerror = () =>
|
|
58
|
-
|
|
174
|
+
fail(request.error ?? new Error("indexeddb-request-failed"));
|
|
59
175
|
request.onsuccess = () => {
|
|
60
176
|
result = request.result as T;
|
|
61
177
|
};
|
|
62
178
|
|
|
63
179
|
tx.oncomplete = () => {
|
|
64
|
-
|
|
180
|
+
if (settled) return;
|
|
181
|
+
settled = true;
|
|
65
182
|
resolve(result);
|
|
66
183
|
};
|
|
67
|
-
tx.onerror = () =>
|
|
68
|
-
|
|
69
|
-
reject(tx.error ?? new Error("indexeddb-transaction-failed"));
|
|
70
|
-
};
|
|
184
|
+
tx.onerror = () => fail(tx.error ?? new Error("indexeddb-transaction-failed"));
|
|
185
|
+
tx.onabort = () => fail(tx.error ?? new Error("indexeddb-transaction-aborted"));
|
|
71
186
|
})
|
|
72
|
-
)
|
|
187
|
+
).catch(async (error) => {
|
|
188
|
+
// One retry after connection reset (common on iOS/Safari when DB was closed).
|
|
189
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
190
|
+
const name = error instanceof DOMException ? error.name : "";
|
|
191
|
+
if (
|
|
192
|
+
name === "InvalidStateError" ||
|
|
193
|
+
message.includes("InvalidStateError") ||
|
|
194
|
+
message.includes("closing")
|
|
195
|
+
) {
|
|
196
|
+
dbPromises.delete(appStorageKey(DB_NAME, nameSpace));
|
|
197
|
+
const db = await openDb(nameSpace);
|
|
198
|
+
return new Promise<T>((resolve, reject) => {
|
|
199
|
+
const tx = db.transaction(STORE_NAME, mode);
|
|
200
|
+
const store = tx.objectStore(STORE_NAME);
|
|
201
|
+
const request = runner(store);
|
|
202
|
+
let result!: T;
|
|
203
|
+
request.onerror = () =>
|
|
204
|
+
reject(request.error ?? new Error("indexeddb-request-failed"));
|
|
205
|
+
request.onsuccess = () => {
|
|
206
|
+
result = request.result as T;
|
|
207
|
+
};
|
|
208
|
+
tx.oncomplete = () => resolve(result);
|
|
209
|
+
tx.onerror = () =>
|
|
210
|
+
reject(tx.error ?? new Error("indexeddb-transaction-failed"));
|
|
211
|
+
tx.onabort = () =>
|
|
212
|
+
reject(tx.error ?? new Error("indexeddb-transaction-aborted"));
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
throw error;
|
|
216
|
+
});
|
|
73
217
|
}
|
|
74
218
|
|
|
75
219
|
export function createWorkspaceProjectId(): string {
|
|
@@ -103,11 +247,9 @@ export async function listWorkspaceProjects(
|
|
|
103
247
|
cursor.continue();
|
|
104
248
|
};
|
|
105
249
|
tx.oncomplete = () => {
|
|
106
|
-
db.close();
|
|
107
250
|
resolve(items.sort((a, b) => b.updatedAt - a.updatedAt));
|
|
108
251
|
};
|
|
109
252
|
tx.onerror = () => {
|
|
110
|
-
db.close();
|
|
111
253
|
reject(tx.error ?? new Error("indexeddb-transaction-failed"));
|
|
112
254
|
};
|
|
113
255
|
});
|
|
@@ -129,8 +271,13 @@ export async function putWorkspaceProject(
|
|
|
129
271
|
record: WorkspaceProjectRecord,
|
|
130
272
|
nameSpace?: string | null
|
|
131
273
|
): Promise<WorkspaceProjectRecord> {
|
|
132
|
-
|
|
133
|
-
|
|
274
|
+
const safeRecord = cloneForIndexedDb(record);
|
|
275
|
+
await runTransaction<IDBValidKey>(
|
|
276
|
+
"readwrite",
|
|
277
|
+
(store) => store.put(safeRecord),
|
|
278
|
+
nameSpace
|
|
279
|
+
);
|
|
280
|
+
return safeRecord;
|
|
134
281
|
}
|
|
135
282
|
|
|
136
283
|
export async function deleteWorkspaceProject(
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map IndexedDB / clone failures to localized workspace messages.
|
|
3
|
+
* Used by create / sync / open / delete so device-specific DOMExceptions
|
|
4
|
+
* show actionable copy instead of opaque browser English.
|
|
5
|
+
*/
|
|
6
|
+
export function mapWorkspaceStorageError(
|
|
7
|
+
error: unknown,
|
|
8
|
+
t: (key: string) => string
|
|
9
|
+
): Error {
|
|
10
|
+
const raw = error instanceof Error ? error : new Error(String(error));
|
|
11
|
+
const name =
|
|
12
|
+
(error as { name?: string } | null)?.name ||
|
|
13
|
+
(raw as { name?: string }).name ||
|
|
14
|
+
"";
|
|
15
|
+
const message = raw.message || "";
|
|
16
|
+
|
|
17
|
+
if (
|
|
18
|
+
name === "IndexedDbUnavailable" ||
|
|
19
|
+
name === "SecurityError" ||
|
|
20
|
+
message.includes("indexeddb-unavailable") ||
|
|
21
|
+
/indexedDB is not available|Access is denied|The operation is insecure/i.test(
|
|
22
|
+
message
|
|
23
|
+
)
|
|
24
|
+
) {
|
|
25
|
+
return new Error(t("panel.messages.workspaceStorageUnavailable"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (
|
|
29
|
+
name === "TimeoutError" ||
|
|
30
|
+
message.includes("indexeddb-open-timeout")
|
|
31
|
+
) {
|
|
32
|
+
return new Error(t("panel.messages.workspaceStorageTimeout"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (
|
|
36
|
+
name === "QuotaExceededError" ||
|
|
37
|
+
/QuotaExceeded|quota|storage.*full|exceeded the quota/i.test(message)
|
|
38
|
+
) {
|
|
39
|
+
return new Error(t("panel.messages.workspaceStorageQuotaExceeded"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
name === "DataCloneError" ||
|
|
44
|
+
message.includes("indexeddb-serialize-failed") ||
|
|
45
|
+
/could not be cloned|DataCloneError|circular/i.test(message)
|
|
46
|
+
) {
|
|
47
|
+
return new Error(t("panel.messages.workspaceStorageSerializeFailed"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
message.includes("indexeddb-open-failed") ||
|
|
52
|
+
message.includes("indexeddb-transaction-failed") ||
|
|
53
|
+
message.includes("indexeddb-request-failed") ||
|
|
54
|
+
message.includes("indexeddb-transaction-aborted")
|
|
55
|
+
) {
|
|
56
|
+
return new Error(t("panel.messages.workspaceStorageFailed"));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return raw;
|
|
60
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* While Moveable is actively dragging/resizing/rotating, pause expensive
|
|
3
|
+
* canvas-wide work (e.g. ellipsis overflow measurement via MutationObserver).
|
|
4
|
+
*/
|
|
5
|
+
let busyDepth = 0;
|
|
6
|
+
const listeners = new Set<() => void>();
|
|
7
|
+
|
|
8
|
+
export function isCanvasInteractionBusy(): boolean {
|
|
9
|
+
return busyDepth > 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function setCanvasInteractionBusy(busy: boolean): void {
|
|
13
|
+
const prev = busyDepth > 0;
|
|
14
|
+
if (busy) busyDepth += 1;
|
|
15
|
+
else busyDepth = Math.max(0, busyDepth - 1);
|
|
16
|
+
const next = busyDepth > 0;
|
|
17
|
+
if (prev === next) return;
|
|
18
|
+
listeners.forEach((listener) => listener());
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function subscribeCanvasInteractionBusy(listener: () => void): () => void {
|
|
22
|
+
listeners.add(listener);
|
|
23
|
+
return () => {
|
|
24
|
+
listeners.delete(listener);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Run `fn` with busy=true; always clears one busy level afterward. */
|
|
29
|
+
export function withCanvasInteractionBusy<T>(fn: () => T): T {
|
|
30
|
+
setCanvasInteractionBusy(true);
|
|
31
|
+
try {
|
|
32
|
+
return fn();
|
|
33
|
+
} finally {
|
|
34
|
+
setCanvasInteractionBusy(false);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { PanelElement } from "../types";
|
|
2
|
+
import { isPanelElementNode } from "./panelElementNodes";
|
|
3
|
+
import type { Node } from "@arronqzy/rx-store";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Preserve Immer structural sharing: unchanged nodes keep the same props
|
|
7
|
+
* object reference so React.memo / Vue can skip re-rendering them.
|
|
8
|
+
*/
|
|
9
|
+
export function materializePanelElements(
|
|
10
|
+
nodes: Node[] | undefined,
|
|
11
|
+
previous?: readonly PanelElement[] | null
|
|
12
|
+
): PanelElement[] {
|
|
13
|
+
const list = nodes ?? [];
|
|
14
|
+
const prevById = previous?.length
|
|
15
|
+
? new Map(previous.map((el) => [el.id, el] as const))
|
|
16
|
+
: null;
|
|
17
|
+
const next: PanelElement[] = [];
|
|
18
|
+
|
|
19
|
+
for (const n of list) {
|
|
20
|
+
if (!isPanelElementNode(n) || !n.props) continue;
|
|
21
|
+
const props = n.props as PanelElement;
|
|
22
|
+
const normalized: PanelElement =
|
|
23
|
+
typeof props.zIndex === "number" ? props : { ...props, zIndex: 1 };
|
|
24
|
+
|
|
25
|
+
const prev = prevById?.get(normalized.id);
|
|
26
|
+
// Same Immer props reference → reuse previous materialized element.
|
|
27
|
+
if (prev && prev === normalized) {
|
|
28
|
+
next.push(prev);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (
|
|
32
|
+
prev &&
|
|
33
|
+
prev.id === normalized.id &&
|
|
34
|
+
prev.x === normalized.x &&
|
|
35
|
+
prev.y === normalized.y &&
|
|
36
|
+
prev.width === normalized.width &&
|
|
37
|
+
prev.height === normalized.height &&
|
|
38
|
+
prev.rotate === normalized.rotate &&
|
|
39
|
+
prev.zIndex === normalized.zIndex &&
|
|
40
|
+
prev.layerId === normalized.layerId &&
|
|
41
|
+
prev.locked === normalized.locked &&
|
|
42
|
+
prev.materialType === normalized.materialType &&
|
|
43
|
+
prev.name === normalized.name &&
|
|
44
|
+
prev.chart === normalized.chart &&
|
|
45
|
+
prev.table === normalized.table &&
|
|
46
|
+
prev.style === normalized.style &&
|
|
47
|
+
prev.textHtml === normalized.textHtml &&
|
|
48
|
+
prev.textAllowInput === normalized.textAllowInput &&
|
|
49
|
+
prev.gridRows === normalized.gridRows &&
|
|
50
|
+
prev.gridCols === normalized.gridCols &&
|
|
51
|
+
prev.gridGap === normalized.gridGap &&
|
|
52
|
+
prev.gridPadding === normalized.gridPadding &&
|
|
53
|
+
prev.parentGridId === normalized.parentGridId &&
|
|
54
|
+
prev.gridSlotIndex === normalized.gridSlotIndex &&
|
|
55
|
+
prev.gridColSpan === normalized.gridColSpan &&
|
|
56
|
+
prev.gridRowSpan === normalized.gridRowSpan &&
|
|
57
|
+
prev.viewportOverflow === normalized.viewportOverflow &&
|
|
58
|
+
prev.scene3d === normalized.scene3d &&
|
|
59
|
+
prev.refLayerId === normalized.refLayerId &&
|
|
60
|
+
prev.refCopyMode === normalized.refCopyMode &&
|
|
61
|
+
prev.refSnapshot === normalized.refSnapshot &&
|
|
62
|
+
prev.audioSrc === normalized.audioSrc &&
|
|
63
|
+
prev.videoSrc === normalized.videoSrc &&
|
|
64
|
+
prev.geometryShape === normalized.geometryShape &&
|
|
65
|
+
prev.geometryColor === normalized.geometryColor &&
|
|
66
|
+
prev.geometrySketchDataUrl === normalized.geometrySketchDataUrl
|
|
67
|
+
) {
|
|
68
|
+
next.push(prev);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
next.push(normalized);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (
|
|
75
|
+
previous &&
|
|
76
|
+
previous.length === next.length &&
|
|
77
|
+
previous.every((el, i) => el === next[i])
|
|
78
|
+
) {
|
|
79
|
+
return previous as PanelElement[];
|
|
80
|
+
}
|
|
81
|
+
return next;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Keep previous array identity when item references are unchanged (order-sensitive). */
|
|
85
|
+
export function stabilizeElementList(
|
|
86
|
+
next: PanelElement[],
|
|
87
|
+
previous?: readonly PanelElement[] | null
|
|
88
|
+
): PanelElement[] {
|
|
89
|
+
if (
|
|
90
|
+
previous &&
|
|
91
|
+
previous.length === next.length &&
|
|
92
|
+
previous.every((el, i) => el === next[i])
|
|
93
|
+
) {
|
|
94
|
+
return previous as PanelElement[];
|
|
95
|
+
}
|
|
96
|
+
return next;
|
|
97
|
+
}
|