@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.
@@ -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 nodes = state.root.children ?? [];
139
- return nodes
140
- .filter((n) => isPanelElementNode(n) && n.props)
141
- .map((n) => {
142
- const props = n.props as PanelElement;
143
- return { ...props, zIndex: typeof props.zIndex === "number" ? props.zIndex : 1 };
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
- () => allElements.filter((el) => el.layerId === activeLayerId),
149
- [activeLayerId, allElements]
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
- return JSON.parse(JSON.stringify(current)) as State;
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
- const items = await listWorkspaceProjects(nameSpace);
149
- setProjects(items);
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
- const snapshot = cloneWorkspaceSnapshot(buildCurrentSnapshot());
276
- const now = Date.now();
277
- const id = options.id ?? createWorkspaceProjectId();
278
- const createdAt = options.createdAt ?? now;
279
- const name = (options.name ?? "").trim() || resolveProjectName();
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
- const record: WorkspaceProjectRecord = {
282
- id,
283
- name,
284
- createdAt,
285
- updatedAt: now,
286
- panelState: exportPanelData(),
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
- await putWorkspaceProject(record, nameSpace);
295
- writeWorkspacePreviewCache(record, nameSpace);
296
- broadcastWorkspaceProjectUpdate(id, now, nameSpace);
297
- setActiveProjectId(id);
298
- setActiveProjectName(name);
299
- syncedSnapshotRef.current = snapshot;
300
- syncedPanelRevisionRef.current = panelRevision;
301
- pendingRevisionSyncRef.current = false;
302
- await refreshProjects();
303
- return record;
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
- [buildCurrentSnapshot, exportPanelData, nameSpace, panelRevision, resolveProjectName, refreshProjects]
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
- await runBusyTask(t("common.loadingWorkspace"), async () => {
336
- const record = await getWorkspaceProject(id, nameSpace);
337
- if (!record) {
338
- await refreshProjects();
339
- throw new Error(t("panel.messages.workspaceNotFound"));
340
- }
341
- applyProjectRecord(record);
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
- await runBusyTask(t("common.deletingWorkspace"), async () => {
374
- await deleteWorkspaceProject(id, nameSpace);
375
- if (activeProjectId === id) {
376
- setActiveProjectId(null);
377
- setActiveProjectName(null);
378
- syncedSnapshotRef.current = null;
379
- }
380
- previewWindowsRef.current.delete(id);
381
- refreshPreviewing();
382
- await refreshProjects();
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
- return new Promise((resolve, reject) => {
31
- const request = indexedDB.open(appStorageKey(DB_NAME, nameSpace), DB_VERSION);
32
- request.onerror = () => reject(request.error ?? new Error("indexeddb-open-failed"));
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 = () => resolve(request.result);
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
- const tx = db.transaction(STORE_NAME, mode);
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
- reject(request.error ?? new Error("indexeddb-request-failed"));
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
- db.close();
180
+ if (settled) return;
181
+ settled = true;
65
182
  resolve(result);
66
183
  };
67
- tx.onerror = () => {
68
- db.close();
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
- await runTransaction<IDBValidKey>("readwrite", (store) => store.put(record), nameSpace);
133
- return record;
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
+ }