@arronqzy/react-view 1.0.13 → 1.0.14

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.
Files changed (29) hide show
  1. package/dist/styles.css +96 -4
  2. package/package.json +7 -6
  3. package/src/panel/ReactViewOnlinePreview.tsx +31 -2
  4. package/src/panel/ReactViewPanel.tsx +186 -124
  5. package/src/panel/ai/applyAssistantAction.ts +1 -0
  6. package/src/panel/components/BusyOverlay.tsx +25 -0
  7. package/src/panel/components/ElementsLayer.tsx +110 -68
  8. package/src/panel/components/MaterialSidebar.tsx +15 -0
  9. package/src/panel/components/MoveableLayer.tsx +8 -0
  10. package/src/panel/components/PanelCanvas.tsx +3 -0
  11. package/src/panel/components/PanelConfigSidebar.tsx +140 -91
  12. package/src/panel/components/SelectLayer.tsx +5 -0
  13. package/src/panel/components/WorkspaceConfigSidebar.tsx +1 -0
  14. package/src/panel/components/WorkspaceProjectNav.tsx +27 -4
  15. package/src/panel/components/config/PanelConfigScene3dSection.tsx +874 -0
  16. package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.tsx +1 -2
  17. package/src/panel/components/table/PanelConfigTableSection.tsx +82 -50
  18. package/src/panel/components/table/TableNodeContent.tsx +40 -15
  19. package/src/panel/hooks/usePanelElements.ts +13 -1
  20. package/src/panel/hooks/useWorkspaceProjects.ts +133 -66
  21. package/src/panel/library/workspace-project-cache.ts +8 -2
  22. package/src/panel/library/workspace-project-db.ts +29 -11
  23. package/src/panel/library/workspace-snapshot.ts +21 -4
  24. package/src/panel/scope/view-scope-store.ts +8 -0
  25. package/src/panel/types.ts +4 -3
  26. package/src/panel/utils/async-work.ts +178 -0
  27. package/src/panel/utils/gridPlacement.ts +29 -0
  28. package/src/panel/utils/panelElementDefaults.ts +37 -1
  29. package/src/panel/utils/scope-autocomplete.ts +0 -114
@@ -19,6 +19,7 @@ import {
19
19
  workspaceSnapshotsEqual,
20
20
  type WorkspaceSnapshot,
21
21
  } from "../library/workspace-snapshot";
22
+ import { runBusyTask } from "../utils/async-work";
22
23
 
23
24
  export type UseWorkspaceProjectsOptions = {
24
25
  exportPanelData: () => State;
@@ -69,6 +70,29 @@ export function useWorkspaceProjects({
69
70
  const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
70
71
  const [activeProjectName, setActiveProjectName] = useState<string | null>(null);
71
72
  const syncedSnapshotRef = useRef<WorkspaceSnapshot | null>(null);
73
+ const syncedPanelRevisionRef = useRef<unknown>(null);
74
+ const pendingRevisionSyncRef = useRef(false);
75
+ const previewWindowsRef = useRef(new Map<string, Window>());
76
+ const [previewingProjectIds, setPreviewingProjectIds] = useState<string[]>([]);
77
+
78
+ const refreshPreviewing = useCallback(() => {
79
+ const ids: string[] = [];
80
+ for (const [id, win] of previewWindowsRef.current) {
81
+ if (!win.closed) ids.push(id);
82
+ else previewWindowsRef.current.delete(id);
83
+ }
84
+ setPreviewingProjectIds((prev) => {
85
+ if (prev.length === ids.length && prev.every((value, index) => value === ids[index])) {
86
+ return prev;
87
+ }
88
+ return ids;
89
+ });
90
+ }, []);
91
+
92
+ useEffect(() => {
93
+ const timer = window.setInterval(refreshPreviewing, 2000);
94
+ return () => window.clearInterval(timer);
95
+ }, [refreshPreviewing]);
72
96
 
73
97
  const resolveProjectName = useCallback(() => {
74
98
  return productName.trim() || t("panel.defaults.unnamedProduct");
@@ -76,19 +100,12 @@ export function useWorkspaceProjects({
76
100
 
77
101
  const buildCurrentSnapshot = useCallback((): WorkspaceSnapshot => {
78
102
  return {
79
- panelState: exportPanelData(),
80
103
  blueprintDocument,
81
104
  blueprintMeta: { ...blueprintMeta },
82
105
  productName,
83
106
  titleIconDataUrl,
84
107
  };
85
- }, [
86
- blueprintDocument,
87
- blueprintMeta,
88
- exportPanelData,
89
- productName,
90
- titleIconDataUrl,
91
- ]);
108
+ }, [blueprintDocument, blueprintMeta, productName, titleIconDataUrl]);
92
109
 
93
110
  const refreshProjects = useCallback(async () => {
94
111
  const items = await listWorkspaceProjects();
@@ -99,8 +116,16 @@ export function useWorkspaceProjects({
99
116
  void refreshProjects();
100
117
  }, [refreshProjects]);
101
118
 
119
+ useEffect(() => {
120
+ if (!pendingRevisionSyncRef.current) return;
121
+ syncedPanelRevisionRef.current = panelRevision;
122
+ pendingRevisionSyncRef.current = false;
123
+ }, [panelRevision]);
124
+
102
125
  const dirty = useMemo(() => {
103
126
  if (!activeProjectId || !syncedSnapshotRef.current) return false;
127
+ if (pendingRevisionSyncRef.current) return false;
128
+ if (panelRevision !== syncedPanelRevisionRef.current) return true;
104
129
  return !workspaceSnapshotsEqual(buildCurrentSnapshot(), syncedSnapshotRef.current);
105
130
  }, [
106
131
  activeProjectId,
@@ -129,12 +154,13 @@ export function useWorkspaceProjects({
129
154
  setActiveProjectId(record.id);
130
155
  setActiveProjectName(record.name);
131
156
  syncedSnapshotRef.current = cloneWorkspaceSnapshot({
132
- panelState: record.panelState,
133
157
  blueprintDocument,
134
158
  blueprintMeta,
135
159
  productName: record.productName,
136
160
  titleIconDataUrl: record.titleIconDataUrl ?? "",
137
161
  });
162
+ syncedPanelRevisionRef.current = panelRevision;
163
+ pendingRevisionSyncRef.current = true;
138
164
  onProjectApplied?.({
139
165
  ...record,
140
166
  blueprintDocument,
@@ -148,13 +174,14 @@ export function useWorkspaceProjects({
148
174
  setBlueprintMeta,
149
175
  setProductName,
150
176
  setTitleIconDataUrl,
177
+ panelRevision,
151
178
  t,
152
179
  ]
153
180
  );
154
181
 
155
182
  const persistProject = useCallback(
156
183
  async (options: { id?: string; name: string; createdAt?: number }) => {
157
- const snapshot = buildCurrentSnapshot();
184
+ const snapshot = cloneWorkspaceSnapshot(buildCurrentSnapshot());
158
185
  const now = Date.now();
159
186
  const id = options.id ?? createWorkspaceProjectId();
160
187
  const createdAt = options.createdAt ?? now;
@@ -165,7 +192,7 @@ export function useWorkspaceProjects({
165
192
  name,
166
193
  createdAt,
167
194
  updatedAt: now,
168
- panelState: snapshot.panelState,
195
+ panelState: exportPanelData(),
169
196
  blueprintDocument: snapshot.blueprintDocument,
170
197
  blueprintMeta: snapshot.blueprintMeta,
171
198
  productName: snapshot.productName,
@@ -177,92 +204,131 @@ export function useWorkspaceProjects({
177
204
  broadcastWorkspaceProjectUpdate(id, now);
178
205
  setActiveProjectId(id);
179
206
  setActiveProjectName(name);
180
- syncedSnapshotRef.current = cloneWorkspaceSnapshot(snapshot);
207
+ syncedSnapshotRef.current = snapshot;
208
+ syncedPanelRevisionRef.current = panelRevision;
209
+ pendingRevisionSyncRef.current = false;
181
210
  await refreshProjects();
182
211
  return record;
183
212
  },
184
- [buildCurrentSnapshot, resolveProjectName, refreshProjects]
213
+ [buildCurrentSnapshot, exportPanelData, panelRevision, resolveProjectName, refreshProjects]
185
214
  );
186
215
 
187
216
  const handleCreateProject = useCallback(async () => {
188
- const name = resolveProjectName();
189
- const record = await persistProject({ name });
190
- return { name, id: record.id };
191
- }, [persistProject, resolveProjectName]);
217
+ return runBusyTask(t("common.savingWorkspace"), async () => {
218
+ const name = resolveProjectName();
219
+ const record = await persistProject({ name });
220
+ return { name, id: record.id };
221
+ });
222
+ }, [persistProject, resolveProjectName, t]);
192
223
 
193
224
  const handleSaveProject = useCallback(async () => {
194
225
  if (!activeProjectId) {
195
226
  return handleCreateProject();
196
227
  }
197
- const existing = await getWorkspaceProject(activeProjectId);
198
- const name = resolveProjectName();
199
- const record = await persistProject({
200
- id: activeProjectId,
201
- name,
202
- createdAt: existing?.createdAt,
228
+ return runBusyTask(t("common.savingWorkspace"), async () => {
229
+ const existing = await getWorkspaceProject(activeProjectId);
230
+ const name = resolveProjectName();
231
+ const record = await persistProject({
232
+ id: activeProjectId,
233
+ name,
234
+ createdAt: existing?.createdAt,
235
+ });
236
+ return { name, id: record.id };
203
237
  });
204
- return { name, id: record.id };
205
- }, [activeProjectId, handleCreateProject, persistProject, resolveProjectName]);
238
+ }, [activeProjectId, handleCreateProject, persistProject, resolveProjectName, t]);
206
239
 
207
240
  const handleOpenProject = useCallback(
208
241
  async (id: string) => {
209
- const record = await getWorkspaceProject(id);
210
- if (!record) {
211
- await refreshProjects();
212
- throw new Error(t("panel.messages.workspaceNotFound"));
213
- }
214
- applyProjectRecord(record);
242
+ await runBusyTask(t("common.loadingWorkspace"), async () => {
243
+ const record = await getWorkspaceProject(id);
244
+ if (!record) {
245
+ await refreshProjects();
246
+ throw new Error(t("panel.messages.workspaceNotFound"));
247
+ }
248
+ applyProjectRecord(record);
249
+ });
215
250
  },
216
251
  [applyProjectRecord, refreshProjects, t]
217
252
  );
218
253
 
219
254
  const handleSyncProject = useCallback(async () => {
220
- if (!activeProjectId) {
221
- throw new Error(t("panel.messages.saveWorkspaceFirst"));
222
- }
223
- const existing = await getWorkspaceProject(activeProjectId);
224
- if (!existing) {
225
- setActiveProjectId(null);
226
- setActiveProjectName(null);
227
- syncedSnapshotRef.current = null;
228
- await refreshProjects();
229
- throw new Error(t("panel.messages.workspaceNotFound"));
230
- }
231
- const name = resolveProjectName() || existing.name;
232
- await persistProject({
233
- id: activeProjectId,
234
- name,
235
- createdAt: existing.createdAt,
255
+ return runBusyTask(t("common.syncingWorkspace"), async () => {
256
+ if (!activeProjectId) {
257
+ throw new Error(t("panel.messages.saveWorkspaceFirst"));
258
+ }
259
+ const existing = await getWorkspaceProject(activeProjectId);
260
+ if (!existing) {
261
+ setActiveProjectId(null);
262
+ setActiveProjectName(null);
263
+ syncedSnapshotRef.current = null;
264
+ await refreshProjects();
265
+ throw new Error(t("panel.messages.workspaceNotFound"));
266
+ }
267
+ const name = resolveProjectName() || existing.name;
268
+ await persistProject({
269
+ id: activeProjectId,
270
+ name,
271
+ createdAt: existing.createdAt,
272
+ });
273
+ return name;
236
274
  });
237
- return name;
238
275
  }, [activeProjectId, persistProject, refreshProjects, resolveProjectName, t]);
239
276
 
240
277
  const handleDeleteProject = useCallback(
241
278
  async (id: string) => {
242
- await deleteWorkspaceProject(id);
243
- if (activeProjectId === id) {
244
- setActiveProjectId(null);
245
- setActiveProjectName(null);
246
- syncedSnapshotRef.current = null;
247
- }
248
- await refreshProjects();
279
+ await runBusyTask(t("common.deletingWorkspace"), async () => {
280
+ await deleteWorkspaceProject(id);
281
+ if (activeProjectId === id) {
282
+ setActiveProjectId(null);
283
+ setActiveProjectName(null);
284
+ syncedSnapshotRef.current = null;
285
+ }
286
+ previewWindowsRef.current.delete(id);
287
+ refreshPreviewing();
288
+ await refreshProjects();
289
+ });
249
290
  },
250
- [activeProjectId, refreshProjects]
291
+ [activeProjectId, refreshPreviewing, refreshProjects, t]
251
292
  );
252
293
 
253
294
  const handlePreviewProject = useCallback(
254
295
  async (id: string, options?: { syncFirst?: boolean }) => {
255
- if (options?.syncFirst && activeProjectId === id && dirty) {
256
- await handleSyncProject();
257
- }
258
- const record =
259
- (await getWorkspaceProject(id)) ?? readWorkspacePreviewCache(id);
260
- if (record) {
261
- writeWorkspacePreviewCache(record);
262
- }
263
- window.open(buildOnlinePreviewUrl(id), "_blank", "noopener,noreferrer");
296
+ await runBusyTask(t("common.openingPreview"), async () => {
297
+ if (options?.syncFirst && activeProjectId === id && dirty) {
298
+ await handleSyncProject();
299
+ }
300
+ const record =
301
+ (await getWorkspaceProject(id)) ?? readWorkspacePreviewCache(id);
302
+ if (record) {
303
+ writeWorkspacePreviewCache(record);
304
+ }
305
+ const existing = previewWindowsRef.current.get(id);
306
+ if (existing && !existing.closed) {
307
+ try {
308
+ existing.focus();
309
+ } catch {
310
+ /* ignore */
311
+ }
312
+ refreshPreviewing();
313
+ return;
314
+ }
315
+ const win = window.open(
316
+ buildOnlinePreviewUrl(id),
317
+ `abuilder-preview-${id}`
318
+ );
319
+ if (win) {
320
+ previewWindowsRef.current.set(id, win);
321
+ try {
322
+ win.opener = null;
323
+ win.focus();
324
+ } catch {
325
+ /* ignore */
326
+ }
327
+ }
328
+ refreshPreviewing();
329
+ });
264
330
  },
265
- [activeProjectId, dirty, handleSyncProject]
331
+ [activeProjectId, dirty, handleSyncProject, refreshPreviewing, t]
266
332
  );
267
333
 
268
334
  return {
@@ -270,6 +336,7 @@ export function useWorkspaceProjects({
270
336
  activeProjectId,
271
337
  activeProjectName,
272
338
  dirty,
339
+ previewingProjectIds,
273
340
  handleCreateProject,
274
341
  handleSaveProject,
275
342
  handleOpenProject,
@@ -4,7 +4,11 @@ const CACHE_PREFIX = "arronqzy-workspace-preview:";
4
4
 
5
5
  export function writeWorkspacePreviewCache(record: WorkspaceProjectRecord): void {
6
6
  try {
7
- localStorage.setItem(CACHE_PREFIX + record.id, JSON.stringify(record));
7
+ // 只写轻量标记,避免把含 data URL 的整包再塞进 localStorage(配额小、内存翻倍)
8
+ localStorage.setItem(
9
+ CACHE_PREFIX + record.id,
10
+ JSON.stringify({ id: record.id, updatedAt: record.updatedAt })
11
+ );
8
12
  } catch {
9
13
  // ignore quota / private mode
10
14
  }
@@ -16,7 +20,9 @@ export function readWorkspacePreviewCache(
16
20
  try {
17
21
  const raw = localStorage.getItem(CACHE_PREFIX + projectId);
18
22
  if (!raw) return null;
19
- return JSON.parse(raw) as WorkspaceProjectRecord;
23
+ const parsed = JSON.parse(raw) as Partial<WorkspaceProjectRecord> | null;
24
+ if (!parsed || typeof parsed !== "object" || !parsed.panelState) return null;
25
+ return parsed as WorkspaceProjectRecord;
20
26
  } catch {
21
27
  return null;
22
28
  }
@@ -78,17 +78,35 @@ export function createWorkspaceProjectId(): string {
78
78
  }
79
79
 
80
80
  export async function listWorkspaceProjects(): Promise<WorkspaceProjectListItem[]> {
81
- const records = await runTransaction<WorkspaceProjectRecord[]>("readonly", (store) =>
82
- store.getAll()
83
- );
84
- return records
85
- .map((record) => ({
86
- id: record.id,
87
- name: record.name,
88
- createdAt: record.createdAt,
89
- updatedAt: record.updatedAt,
90
- }))
91
- .sort((a, b) => b.updatedAt - a.updatedAt);
81
+ const db = await openDb();
82
+ return new Promise((resolve, reject) => {
83
+ const tx = db.transaction(STORE_NAME, "readonly");
84
+ const store = tx.objectStore(STORE_NAME);
85
+ const request = store.openCursor();
86
+ const items: WorkspaceProjectListItem[] = [];
87
+ request.onerror = () =>
88
+ reject(request.error ?? new Error("indexeddb-request-failed"));
89
+ request.onsuccess = () => {
90
+ const cursor = request.result;
91
+ if (!cursor) return;
92
+ const record = cursor.value as WorkspaceProjectRecord;
93
+ items.push({
94
+ id: record.id,
95
+ name: record.name,
96
+ createdAt: record.createdAt,
97
+ updatedAt: record.updatedAt,
98
+ });
99
+ cursor.continue();
100
+ };
101
+ tx.oncomplete = () => {
102
+ db.close();
103
+ resolve(items.sort((a, b) => b.updatedAt - a.updatedAt));
104
+ };
105
+ tx.onerror = () => {
106
+ db.close();
107
+ reject(tx.error ?? new Error("indexeddb-transaction-failed"));
108
+ };
109
+ });
92
110
  }
93
111
 
94
112
  export async function getWorkspaceProject(
@@ -1,15 +1,24 @@
1
1
  import { blueprintDocumentsEqual } from "@arronqzy/react-blueprint";
2
2
  import type { BlueprintDocument, BlueprintMetaDraft } from "@arronqzy/react-blueprint";
3
- import type { State } from "@arronqzy/rx-store";
4
3
 
5
4
  export type WorkspaceSnapshot = {
6
- panelState: State;
7
5
  blueprintDocument: BlueprintDocument;
8
6
  blueprintMeta: BlueprintMetaDraft;
9
7
  productName: string;
10
8
  titleIconDataUrl: string;
11
9
  };
12
10
 
11
+ function cloneBlueprintDocument(document: BlueprintDocument): BlueprintDocument {
12
+ if (typeof structuredClone === "function") {
13
+ try {
14
+ return structuredClone(document);
15
+ } catch {
16
+ /* fall through */
17
+ }
18
+ }
19
+ return JSON.parse(JSON.stringify(document)) as BlueprintDocument;
20
+ }
21
+
13
22
  export function workspaceSnapshotsEqual(
14
23
  a: WorkspaceSnapshot,
15
24
  b: WorkspaceSnapshot
@@ -19,9 +28,17 @@ export function workspaceSnapshotsEqual(
19
28
  if (a.blueprintMeta.name !== b.blueprintMeta.name) return false;
20
29
  if ((a.blueprintMeta.remark ?? "") !== (b.blueprintMeta.remark ?? "")) return false;
21
30
  if (!blueprintDocumentsEqual(a.blueprintDocument, b.blueprintDocument)) return false;
22
- return JSON.stringify(a.panelState) === JSON.stringify(b.panelState);
31
+ return true;
23
32
  }
24
33
 
25
34
  export function cloneWorkspaceSnapshot(snapshot: WorkspaceSnapshot): WorkspaceSnapshot {
26
- return JSON.parse(JSON.stringify(snapshot)) as WorkspaceSnapshot;
35
+ return {
36
+ blueprintDocument: cloneBlueprintDocument(snapshot.blueprintDocument),
37
+ blueprintMeta: {
38
+ name: snapshot.blueprintMeta.name,
39
+ remark: snapshot.blueprintMeta.remark ?? "",
40
+ },
41
+ productName: snapshot.productName,
42
+ titleIconDataUrl: snapshot.titleIconDataUrl,
43
+ };
27
44
  }
@@ -20,6 +20,14 @@ export function clearViewElementScopes() {
20
20
  emit();
21
21
  }
22
22
 
23
+ export function removeViewElementScopes(elementIds: readonly string[]) {
24
+ let changed = false;
25
+ for (const id of elementIds) {
26
+ if (scopes.delete(id)) changed = true;
27
+ }
28
+ if (changed) emit();
29
+ }
30
+
23
31
  export function setViewElementScope(elementId: string, scope: unknown) {
24
32
  scopes.set(elementId, scope);
25
33
  emit();
@@ -54,8 +54,8 @@ export type PanelElementStyle = {
54
54
 
55
55
  export type ReferenceCopyMode = "shallow" | "deep";
56
56
 
57
- export type { PanelTableConfig } from "@arronqzy/view-table";
58
57
  import type { PanelTableConfig } from "@arronqzy/view-table";
58
+ import type { Scene3dConfig } from "@arronqzy/view-scene3d";
59
59
 
60
60
  export type PanelElement = {
61
61
  id: string;
@@ -75,8 +75,8 @@ export type PanelElement = {
75
75
  textFontSize?: number;
76
76
  textFontWeight?: string;
77
77
  textColor?: string;
78
- textLineHeight?: number;
79
- textAlign?: "left" | "center" | "right" | "justify";
78
+ textLineHeight?: number | "auto";
79
+ textAlign?: "left" | "center" | "right" | "justify" | "middle";
80
80
  gridRows?: number;
81
81
  gridCols?: number;
82
82
  gridGap?: number;
@@ -101,6 +101,7 @@ export type PanelElement = {
101
101
  geometryColor?: string;
102
102
  geometryScript?: string;
103
103
  geometrySketchDataUrl?: string;
104
+ scene3d?: Scene3dConfig;
104
105
  style?: PanelElementStyle;
105
106
  x: number;
106
107
  y: number;
@@ -0,0 +1,178 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import { resolveLocale, tForLocale } from "@arronqzy/i18n";
3
+
4
+ export type MediaFileKind = "image" | "audio" | "video" | "json" | "model3d";
5
+
6
+ export const FILE_SIZE_LIMITS: Record<MediaFileKind, number> = {
7
+ image: 8 * 1024 * 1024,
8
+ audio: 20 * 1024 * 1024,
9
+ video: 40 * 1024 * 1024,
10
+ json: 24 * 1024 * 1024,
11
+ model3d: 48 * 1024 * 1024,
12
+ };
13
+
14
+ const JSON_WORKER_THRESHOLD = 128 * 1024;
15
+
16
+ let busyMessage: string | null = null;
17
+ let busyDepth = 0;
18
+ const busyListeners = new Set<() => void>();
19
+
20
+ function emitBusy() {
21
+ busyListeners.forEach((listener) => listener());
22
+ }
23
+
24
+ function setBusyMessage(message: string | null) {
25
+ busyMessage = message;
26
+ emitBusy();
27
+ }
28
+
29
+ export function getBusyOverlayMessage() {
30
+ return busyMessage;
31
+ }
32
+
33
+ export function subscribeBusyOverlay(listener: () => void) {
34
+ busyListeners.add(listener);
35
+ return () => busyListeners.delete(listener);
36
+ }
37
+
38
+ export function useBusyOverlayMessage() {
39
+ return useSyncExternalStore(
40
+ subscribeBusyOverlay,
41
+ getBusyOverlayMessage,
42
+ () => null
43
+ );
44
+ }
45
+
46
+ export function yieldToPaint(): Promise<void> {
47
+ return new Promise((resolve) => {
48
+ if (typeof requestAnimationFrame === "undefined") {
49
+ setTimeout(resolve, 0);
50
+ return;
51
+ }
52
+ requestAnimationFrame(() => {
53
+ requestAnimationFrame(() => resolve());
54
+ });
55
+ });
56
+ }
57
+
58
+ export async function runBusyTask<T>(
59
+ message: string,
60
+ task: () => Promise<T> | T
61
+ ): Promise<T> {
62
+ const previous = busyMessage;
63
+ busyDepth += 1;
64
+ setBusyMessage(message);
65
+ await yieldToPaint();
66
+ try {
67
+ return await task();
68
+ } finally {
69
+ busyDepth = Math.max(0, busyDepth - 1);
70
+ setBusyMessage(busyDepth === 0 ? null : previous);
71
+ }
72
+ }
73
+
74
+ function formatFileSize(bytes: number) {
75
+ if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`;
76
+ return `${Math.round(bytes / 1024)} KB`;
77
+ }
78
+
79
+ export function assertFileSize(file: Blob, kind: MediaFileKind) {
80
+ const limit = FILE_SIZE_LIMITS[kind];
81
+ if (file.size <= limit) return;
82
+ const t = tForLocale(resolveLocale());
83
+ throw new Error(
84
+ t("common.fileTooLarge", { max: formatFileSize(limit) })
85
+ );
86
+ }
87
+
88
+ export function readFileAsDataUrl(
89
+ file: Blob,
90
+ errorMessage: string,
91
+ kind: MediaFileKind = "image"
92
+ ): Promise<string> {
93
+ assertFileSize(file, kind);
94
+ const t = tForLocale(resolveLocale());
95
+ return runBusyTask(t("common.uploadingFile"), () => {
96
+ return new Promise<string>((resolve, reject) => {
97
+ const reader = new FileReader();
98
+ reader.onload = () => resolve(String(reader.result ?? ""));
99
+ reader.onerror = () => reject(new Error(errorMessage));
100
+ reader.readAsDataURL(file);
101
+ });
102
+ });
103
+ }
104
+
105
+ let jsonWorker: Worker | null = null;
106
+ let jsonWorkerObjectUrl: string | null = null;
107
+ let jsonRequestId = 0;
108
+ const jsonPending = new Map<
109
+ number,
110
+ { resolve: (value: unknown) => void; reject: (error: Error) => void }
111
+ >();
112
+
113
+ function ensureJsonWorker(): Worker | null {
114
+ if (typeof Worker === "undefined") return null;
115
+ if (jsonWorker) return jsonWorker;
116
+ const source = `
117
+ self.onmessage = (event) => {
118
+ const { id, text } = event.data || {};
119
+ try {
120
+ self.postMessage({ id, ok: true, value: JSON.parse(text) });
121
+ } catch (error) {
122
+ self.postMessage({
123
+ id,
124
+ ok: false,
125
+ error: error instanceof Error ? error.message : String(error),
126
+ });
127
+ }
128
+ };
129
+ `;
130
+ jsonWorkerObjectUrl = URL.createObjectURL(
131
+ new Blob([source], { type: "text/javascript" })
132
+ );
133
+ jsonWorker = new Worker(jsonWorkerObjectUrl);
134
+ jsonWorker.onmessage = (event: MessageEvent) => {
135
+ const { id, ok, value, error } = event.data ?? {};
136
+ const pending = jsonPending.get(id);
137
+ if (!pending) return;
138
+ jsonPending.delete(id);
139
+ if (ok) pending.resolve(value);
140
+ else pending.reject(new Error(String(error ?? "json-parse-failed")));
141
+ };
142
+ jsonWorker.onerror = () => {
143
+ jsonPending.forEach(({ reject }) => reject(new Error("json-worker-failed")));
144
+ jsonPending.clear();
145
+ disposeJsonWorker();
146
+ };
147
+ if (typeof window !== "undefined") {
148
+ window.addEventListener("pagehide", disposeJsonWorker, { once: true });
149
+ }
150
+ return jsonWorker;
151
+ }
152
+
153
+ function disposeJsonWorker() {
154
+ jsonPending.forEach(({ reject }) => reject(new Error("json-worker-disposed")));
155
+ jsonPending.clear();
156
+ jsonWorker?.terminate();
157
+ jsonWorker = null;
158
+ if (jsonWorkerObjectUrl) {
159
+ URL.revokeObjectURL(jsonWorkerObjectUrl);
160
+ jsonWorkerObjectUrl = null;
161
+ }
162
+ }
163
+
164
+ export async function parseJsonText<T>(text: string): Promise<T> {
165
+ if (text.length < JSON_WORKER_THRESHOLD) {
166
+ return JSON.parse(text) as T;
167
+ }
168
+ const worker = ensureJsonWorker();
169
+ if (!worker) return JSON.parse(text) as T;
170
+ const id = ++jsonRequestId;
171
+ return new Promise<T>((resolve, reject) => {
172
+ jsonPending.set(id, {
173
+ resolve: (value) => resolve(value as T),
174
+ reject,
175
+ });
176
+ worker.postMessage({ id, text });
177
+ });
178
+ }
@@ -187,3 +187,32 @@ export function computeSnapPatchForNewElementOnLayer(
187
187
  height: nearest.height,
188
188
  };
189
189
  }
190
+
191
+ function isGridAncestorOf(
192
+ ancestorId: string,
193
+ el: PanelElement,
194
+ byId: Map<string, PanelElement>
195
+ ): boolean {
196
+ let current: PanelElement | undefined = el;
197
+ const seen = new Set<string>();
198
+ while (current?.parentGridId && !seen.has(current.id)) {
199
+ if (current.parentGridId === ancestorId) return true;
200
+ seen.add(current.id);
201
+ current = byId.get(current.parentGridId);
202
+ }
203
+ return false;
204
+ }
205
+
206
+ /** 父网格先于格子内子节点绘制,避免网格占位层盖住内容 */
207
+ export function comparePanelElementsPaintOrder(
208
+ a: PanelElement,
209
+ b: PanelElement,
210
+ byId: Map<string, PanelElement>
211
+ ): number {
212
+ if (isGridAncestorOf(a.id, b, byId)) return -1;
213
+ if (isGridAncestorOf(b.id, a, byId)) return 1;
214
+ const za = a.zIndex ?? 1;
215
+ const zb = b.zIndex ?? 1;
216
+ if (za !== zb) return za - zb;
217
+ return a.id.localeCompare(b.id);
218
+ }