@arronqzy/vue-view 0.1.6 → 0.1.7

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 (34) hide show
  1. package/package.json +7 -6
  2. package/src/panel/VueViewOnlinePreview.vue +28 -2
  3. package/src/panel/VueViewPanel.vue +120 -66
  4. package/src/panel/components/BusyOverlay.vue +24 -0
  5. package/src/panel/components/ConfigHintIcon.vue +10 -10
  6. package/src/panel/components/ElementsLayer.vue +51 -8
  7. package/src/panel/components/MaterialPreview.vue +8 -0
  8. package/src/panel/components/PanelConfigSidebar.vue +11 -0
  9. package/src/panel/components/WorkspaceConfigSidebar.vue +1 -0
  10. package/src/panel/components/WorkspaceProjectNav.vue +36 -8
  11. package/src/panel/components/config/ConfigColorField.vue +33 -9
  12. package/src/panel/components/config/PanelConfigAudioSection.vue +32 -18
  13. package/src/panel/components/config/PanelConfigImageSection.vue +13 -9
  14. package/src/panel/components/config/PanelConfigScene3dSection.vue +505 -0
  15. package/src/panel/components/config/PanelConfigStyleSections.vue +13 -9
  16. package/src/panel/components/config/PanelConfigTextSection.vue +20 -6
  17. package/src/panel/components/config/PanelConfigVideoSection.vue +13 -9
  18. package/src/panel/components/config/shared.ts +16 -21
  19. package/src/panel/components/elementsLayerNodes.ts +64 -59
  20. package/src/panel/components/materialSidebarData.ts +2 -0
  21. package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.vue +1 -1
  22. package/src/panel/components/table/PanelConfigTableSection.vue +39 -25
  23. package/src/panel/components/table/TableNodeContent.vue +36 -15
  24. package/src/panel/hooks/usePanelElements.ts +13 -1
  25. package/src/panel/hooks/useWorkspaceProjects.ts +128 -57
  26. package/src/panel/library/workspace-project-cache.ts +7 -2
  27. package/src/panel/library/workspace-project-db.ts +29 -11
  28. package/src/panel/library/workspace-snapshot.ts +21 -4
  29. package/src/panel/scope/view-scope-store.ts +8 -0
  30. package/src/panel/types.ts +4 -2
  31. package/src/panel/utils/async-work.ts +153 -0
  32. package/src/panel/utils/gridPlacement.ts +29 -0
  33. package/src/panel/utils/panelElementDefaults.ts +37 -1
  34. package/src/panel/utils/scope-autocomplete.ts +0 -114
@@ -10,12 +10,14 @@ import type {
10
10
  ReferenceCopyMode,
11
11
  } from "../types";
12
12
  import { getPanelMessages } from "../constants/messages";
13
+ import { removeViewElementScopes } from "../scope/view-scope-store";
13
14
  import {
14
15
  DEFAULT_LAYER,
15
16
  DEFAULT_LAYER_ID,
16
17
  getDefaultChartConfig,
17
18
  getDefaultGridConfig,
18
19
  getDefaultNodeName,
20
+ getDefaultScene3dConfigForMaterial,
19
21
  getDefaultSizeByMaterial,
20
22
  getDefaultTableConfig,
21
23
  getDefaultTextContent,
@@ -385,6 +387,7 @@ export function usePanelElements() {
385
387
  });
386
388
  const idSet = new Set(unlockedIds);
387
389
  if (idSet.size === 0) return;
390
+ const familyIds = new Set<string>();
388
391
  store.update(
389
392
  (draft) => {
390
393
  const deletedSourceIds = new Set<string>();
@@ -394,7 +397,7 @@ export function usePanelElements() {
394
397
  const props = n.props as PanelElement;
395
398
  deletedSourceIds.add(props.mappingSourceNodeId ?? props.id);
396
399
  });
397
- const familyIds = new Set<string>();
400
+ familyIds.clear();
398
401
  (draft.root.children ?? []).forEach((n) => {
399
402
  if (!isPanelElementNode(n) || !n.props) return;
400
403
  const props = n.props as PanelElement;
@@ -407,6 +410,7 @@ export function usePanelElements() {
407
410
  },
408
411
  { meta: { type: "node.batch-delete", ids: Array.from(idSet) } }
409
412
  );
413
+ removeViewElementScopes([...familyIds]);
410
414
  }
411
415
 
412
416
  function bringElementsToFront(ids: string[]) {
@@ -613,6 +617,7 @@ export function usePanelElements() {
613
617
  geometryShape: materialType === "geometry" ? "rect" : undefined,
614
618
  geometryColor: materialType === "geometry" ? "#3b82f6" : undefined,
615
619
  geometryScript: undefined,
620
+ ...getDefaultScene3dConfigForMaterial(materialType),
616
621
  x: Math.round(x - size.width / 2),
617
622
  y: Math.round(y - size.height / 2),
618
623
  width: size.width,
@@ -1029,6 +1034,13 @@ export function usePanelElements() {
1029
1034
 
1030
1035
  function exportPanelData() {
1031
1036
  const current = store.getState();
1037
+ if (typeof structuredClone === "function") {
1038
+ try {
1039
+ return structuredClone(current) as State;
1040
+ } catch {
1041
+ /* fall through */
1042
+ }
1043
+ }
1032
1044
  return JSON.parse(JSON.stringify(current)) as State;
1033
1045
  }
1034
1046
 
@@ -1,4 +1,4 @@
1
- import { computed, onMounted, ref, shallowRef, toValue, type MaybeRefOrGetter } from "vue";
1
+ import { computed, onMounted, onUnmounted, ref, shallowRef, toValue, watch, type MaybeRefOrGetter } from "vue";
2
2
  import {
3
3
  BlueprintGraph,
4
4
  type BlueprintDocument,
@@ -22,6 +22,7 @@ import {
22
22
  workspaceSnapshotsEqual,
23
23
  type WorkspaceSnapshot,
24
24
  } from "../library/workspace-snapshot";
25
+ import { runBusyTask } from "../utils/async-work";
25
26
 
26
27
  export type UseWorkspaceProjectsOptions = {
27
28
  exportPanelData: () => State;
@@ -59,6 +60,20 @@ export function useWorkspaceProjects(options: UseWorkspaceProjectsOptions) {
59
60
  const activeProjectId = ref<string | null>(null);
60
61
  const activeProjectName = ref<string | null>(null);
61
62
  const syncedSnapshotRef = shallowRef<WorkspaceSnapshot | null>(null);
63
+ let syncedPanelRevision: unknown = null;
64
+ let pendingRevisionSync = false;
65
+ const previewWindows = new Map<string, Window>();
66
+ const previewingProjectIds = ref<string[]>([]);
67
+ let previewPollTimer: number | undefined;
68
+
69
+ function refreshPreviewing() {
70
+ const ids: string[] = [];
71
+ for (const [id, win] of previewWindows) {
72
+ if (!win.closed) ids.push(id);
73
+ else previewWindows.delete(id);
74
+ }
75
+ previewingProjectIds.value = ids;
76
+ }
62
77
 
63
78
  function resolveProjectName() {
64
79
  void locale.value;
@@ -67,7 +82,6 @@ export function useWorkspaceProjects(options: UseWorkspaceProjectsOptions) {
67
82
 
68
83
  function buildCurrentSnapshot(): WorkspaceSnapshot {
69
84
  return {
70
- panelState: options.exportPanelData(),
71
85
  blueprintDocument: toValue(options.blueprintDocument),
72
86
  blueprintMeta: { ...toValue(options.blueprintMeta) },
73
87
  productName: toValue(options.productName),
@@ -81,15 +95,30 @@ export function useWorkspaceProjects(options: UseWorkspaceProjectsOptions) {
81
95
 
82
96
  onMounted(() => {
83
97
  void refreshProjects();
98
+ previewPollTimer = window.setInterval(refreshPreviewing, 2000);
84
99
  });
85
100
 
101
+ onUnmounted(() => {
102
+ if (previewPollTimer !== undefined) window.clearInterval(previewPollTimer);
103
+ });
104
+
105
+ watch(
106
+ () => toValue(options.panelRevision),
107
+ (revision) => {
108
+ if (!pendingRevisionSync) return;
109
+ syncedPanelRevision = revision;
110
+ pendingRevisionSync = false;
111
+ }
112
+ );
113
+
86
114
  const dirty = computed(() => {
87
115
  if (!activeProjectId.value || !syncedSnapshotRef.value) return false;
88
- void toValue(options.panelRevision);
116
+ if (pendingRevisionSync) return false;
89
117
  void toValue(options.blueprintDocument);
90
118
  void toValue(options.blueprintMeta);
91
119
  void toValue(options.productName);
92
120
  void toValue(options.titleIconDataUrl);
121
+ if (toValue(options.panelRevision) !== syncedPanelRevision) return true;
93
122
  return !workspaceSnapshotsEqual(buildCurrentSnapshot(), syncedSnapshotRef.value);
94
123
  });
95
124
 
@@ -109,12 +138,13 @@ export function useWorkspaceProjects(options: UseWorkspaceProjectsOptions) {
109
138
  activeProjectId.value = record.id;
110
139
  activeProjectName.value = record.name;
111
140
  syncedSnapshotRef.value = cloneWorkspaceSnapshot({
112
- panelState: record.panelState,
113
141
  blueprintDocument,
114
142
  blueprintMeta,
115
143
  productName: record.productName,
116
144
  titleIconDataUrl: record.titleIconDataUrl ?? "",
117
145
  });
146
+ syncedPanelRevision = toValue(options.panelRevision);
147
+ pendingRevisionSync = true;
118
148
  options.onProjectApplied?.({
119
149
  ...record,
120
150
  blueprintDocument,
@@ -127,7 +157,7 @@ export function useWorkspaceProjects(options: UseWorkspaceProjectsOptions) {
127
157
  name: string;
128
158
  createdAt?: number;
129
159
  }) {
130
- const snapshot = buildCurrentSnapshot();
160
+ const snapshot = cloneWorkspaceSnapshot(buildCurrentSnapshot());
131
161
  const now = Date.now();
132
162
  const id = persistOptions.id ?? createWorkspaceProjectId();
133
163
  const createdAt = persistOptions.createdAt ?? now;
@@ -138,7 +168,7 @@ export function useWorkspaceProjects(options: UseWorkspaceProjectsOptions) {
138
168
  name,
139
169
  createdAt,
140
170
  updatedAt: now,
141
- panelState: snapshot.panelState,
171
+ panelState: options.exportPanelData(),
142
172
  blueprintDocument: snapshot.blueprintDocument,
143
173
  blueprintMeta: snapshot.blueprintMeta,
144
174
  productName: snapshot.productName,
@@ -150,81 +180,121 @@ export function useWorkspaceProjects(options: UseWorkspaceProjectsOptions) {
150
180
  broadcastWorkspaceProjectUpdate(id, now);
151
181
  activeProjectId.value = id;
152
182
  activeProjectName.value = name;
153
- syncedSnapshotRef.value = cloneWorkspaceSnapshot(snapshot);
183
+ syncedSnapshotRef.value = snapshot;
184
+ syncedPanelRevision = toValue(options.panelRevision);
185
+ pendingRevisionSync = false;
154
186
  await refreshProjects();
155
187
  return record;
156
188
  }
157
189
 
158
190
  async function handleCreateProject() {
159
- const name = resolveProjectName();
160
- const record = await persistProject({ name });
161
- return { name, id: record.id };
191
+ return runBusyTask(t("common.savingWorkspace"), async () => {
192
+ const name = resolveProjectName();
193
+ const record = await persistProject({ name });
194
+ return { name, id: record.id };
195
+ });
162
196
  }
163
197
 
164
198
  async function handleSaveProject() {
165
- if (!activeProjectId.value) {
199
+ const projectId = activeProjectId.value;
200
+ if (!projectId) {
166
201
  return handleCreateProject();
167
202
  }
168
- const existing = await getWorkspaceProject(activeProjectId.value);
169
- const name = resolveProjectName();
170
- const record = await persistProject({
171
- id: activeProjectId.value,
172
- name,
173
- createdAt: existing?.createdAt,
203
+ return runBusyTask(t("common.savingWorkspace"), async () => {
204
+ const existing = await getWorkspaceProject(projectId);
205
+ const name = resolveProjectName();
206
+ const record = await persistProject({
207
+ id: projectId,
208
+ name,
209
+ createdAt: existing?.createdAt,
210
+ });
211
+ return { name, id: record.id };
174
212
  });
175
- return { name, id: record.id };
176
213
  }
177
214
 
178
215
  async function handleOpenProject(id: string) {
179
- const record = await getWorkspaceProject(id);
180
- if (!record) {
181
- await refreshProjects();
182
- throw new Error(t("panel.messages.workspaceNotFound"));
183
- }
184
- applyProjectRecord(record);
216
+ await runBusyTask(t("common.loadingWorkspace"), async () => {
217
+ const record = await getWorkspaceProject(id);
218
+ if (!record) {
219
+ await refreshProjects();
220
+ throw new Error(t("panel.messages.workspaceNotFound"));
221
+ }
222
+ applyProjectRecord(record);
223
+ });
185
224
  }
186
225
 
187
226
  async function handleSyncProject() {
188
- if (!activeProjectId.value) {
189
- throw new Error(t("panel.messages.saveWorkspaceFirst"));
190
- }
191
- const existing = await getWorkspaceProject(activeProjectId.value);
192
- if (!existing) {
193
- activeProjectId.value = null;
194
- activeProjectName.value = null;
195
- syncedSnapshotRef.value = null;
196
- await refreshProjects();
197
- throw new Error(t("panel.messages.workspaceNotFound"));
198
- }
199
- const name = resolveProjectName() || existing.name;
200
- await persistProject({
201
- id: activeProjectId.value,
202
- name,
203
- createdAt: existing.createdAt,
227
+ return runBusyTask(t("common.syncingWorkspace"), async () => {
228
+ if (!activeProjectId.value) {
229
+ throw new Error(t("panel.messages.saveWorkspaceFirst"));
230
+ }
231
+ const existing = await getWorkspaceProject(activeProjectId.value);
232
+ if (!existing) {
233
+ activeProjectId.value = null;
234
+ activeProjectName.value = null;
235
+ syncedSnapshotRef.value = null;
236
+ await refreshProjects();
237
+ throw new Error(t("panel.messages.workspaceNotFound"));
238
+ }
239
+ const name = resolveProjectName() || existing.name;
240
+ await persistProject({
241
+ id: activeProjectId.value,
242
+ name,
243
+ createdAt: existing.createdAt,
244
+ });
245
+ return name;
204
246
  });
205
- return name;
206
247
  }
207
248
 
208
249
  async function handleDeleteProject(id: string) {
209
- await deleteWorkspaceProject(id);
210
- if (activeProjectId.value === id) {
211
- activeProjectId.value = null;
212
- activeProjectName.value = null;
213
- syncedSnapshotRef.value = null;
214
- }
215
- await refreshProjects();
250
+ await runBusyTask(t("common.deletingWorkspace"), async () => {
251
+ await deleteWorkspaceProject(id);
252
+ if (activeProjectId.value === id) {
253
+ activeProjectId.value = null;
254
+ activeProjectName.value = null;
255
+ syncedSnapshotRef.value = null;
256
+ }
257
+ previewWindows.delete(id);
258
+ refreshPreviewing();
259
+ await refreshProjects();
260
+ });
216
261
  }
217
262
 
218
263
  async function handlePreviewProject(id: string, previewOptions?: { syncFirst?: boolean }) {
219
- if (previewOptions?.syncFirst && activeProjectId.value === id && dirty.value) {
220
- await handleSyncProject();
221
- }
222
- const record =
223
- (await getWorkspaceProject(id)) ?? readWorkspacePreviewCache(id);
224
- if (record) {
225
- writeWorkspacePreviewCache(record);
226
- }
227
- window.open(buildOnlinePreviewUrl(id), "_blank", "noopener,noreferrer");
264
+ await runBusyTask(t("common.openingPreview"), async () => {
265
+ if (previewOptions?.syncFirst && activeProjectId.value === id && dirty.value) {
266
+ await handleSyncProject();
267
+ }
268
+ const record =
269
+ (await getWorkspaceProject(id)) ?? readWorkspacePreviewCache(id);
270
+ if (record) {
271
+ writeWorkspacePreviewCache(record);
272
+ }
273
+ const existing = previewWindows.get(id);
274
+ if (existing && !existing.closed) {
275
+ try {
276
+ existing.focus();
277
+ } catch {
278
+ /* ignore */
279
+ }
280
+ refreshPreviewing();
281
+ return;
282
+ }
283
+ const win = window.open(
284
+ buildOnlinePreviewUrl(id),
285
+ `abuilder-preview-${id}`
286
+ );
287
+ if (win) {
288
+ previewWindows.set(id, win);
289
+ try {
290
+ win.opener = null;
291
+ win.focus();
292
+ } catch {
293
+ /* ignore */
294
+ }
295
+ }
296
+ refreshPreviewing();
297
+ });
228
298
  }
229
299
 
230
300
  return {
@@ -232,6 +302,7 @@ export function useWorkspaceProjects(options: UseWorkspaceProjectsOptions) {
232
302
  activeProjectId,
233
303
  activeProjectName,
234
304
  dirty,
305
+ previewingProjectIds,
235
306
  handleCreateProject,
236
307
  handleSaveProject,
237
308
  handleOpenProject,
@@ -4,7 +4,10 @@ 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
+ localStorage.setItem(
8
+ CACHE_PREFIX + record.id,
9
+ JSON.stringify({ id: record.id, updatedAt: record.updatedAt })
10
+ );
8
11
  } catch {
9
12
  // ignore quota / private mode
10
13
  }
@@ -16,7 +19,9 @@ export function readWorkspacePreviewCache(
16
19
  try {
17
20
  const raw = localStorage.getItem(CACHE_PREFIX + projectId);
18
21
  if (!raw) return null;
19
- return JSON.parse(raw) as WorkspaceProjectRecord;
22
+ const parsed = JSON.parse(raw) as Partial<WorkspaceProjectRecord> | null;
23
+ if (!parsed || typeof parsed !== "object" || !parsed.panelState) return null;
24
+ return parsed as WorkspaceProjectRecord;
20
25
  } catch {
21
26
  return null;
22
27
  }
@@ -77,17 +77,35 @@ export function createWorkspaceProjectId(): string {
77
77
  }
78
78
 
79
79
  export async function listWorkspaceProjects(): Promise<WorkspaceProjectListItem[]> {
80
- const records = await runTransaction<WorkspaceProjectRecord[]>("readonly", (store) =>
81
- store.getAll()
82
- );
83
- return records
84
- .map((record) => ({
85
- id: record.id,
86
- name: record.name,
87
- createdAt: record.createdAt,
88
- updatedAt: record.updatedAt,
89
- }))
90
- .sort((a, b) => b.updatedAt - a.updatedAt);
80
+ const db = await openDb();
81
+ return new Promise((resolve, reject) => {
82
+ const tx = db.transaction(STORE_NAME, "readonly");
83
+ const store = tx.objectStore(STORE_NAME);
84
+ const request = store.openCursor();
85
+ const items: WorkspaceProjectListItem[] = [];
86
+ request.onerror = () =>
87
+ reject(request.error ?? new Error("indexeddb-request-failed"));
88
+ request.onsuccess = () => {
89
+ const cursor = request.result;
90
+ if (!cursor) return;
91
+ const record = cursor.value as WorkspaceProjectRecord;
92
+ items.push({
93
+ id: record.id,
94
+ name: record.name,
95
+ createdAt: record.createdAt,
96
+ updatedAt: record.updatedAt,
97
+ });
98
+ cursor.continue();
99
+ };
100
+ tx.oncomplete = () => {
101
+ db.close();
102
+ resolve(items.sort((a, b) => b.updatedAt - a.updatedAt));
103
+ };
104
+ tx.onerror = () => {
105
+ db.close();
106
+ reject(tx.error ?? new Error("indexeddb-transaction-failed"));
107
+ };
108
+ });
91
109
  }
92
110
 
93
111
  export async function getWorkspaceProject(
@@ -3,16 +3,25 @@ import {
3
3
  type BlueprintDocument,
4
4
  type BlueprintMetaDraft,
5
5
  } from "@arronqzy/vue-blueprint";
6
- import type { State } from "@arronqzy/rx-store";
7
6
 
8
7
  export type WorkspaceSnapshot = {
9
- panelState: State;
10
8
  blueprintDocument: BlueprintDocument;
11
9
  blueprintMeta: BlueprintMetaDraft;
12
10
  productName: string;
13
11
  titleIconDataUrl: string;
14
12
  };
15
13
 
14
+ function cloneBlueprintDocument(document: BlueprintDocument): BlueprintDocument {
15
+ if (typeof structuredClone === "function") {
16
+ try {
17
+ return structuredClone(document);
18
+ } catch {
19
+ /* fall through */
20
+ }
21
+ }
22
+ return JSON.parse(JSON.stringify(document)) as BlueprintDocument;
23
+ }
24
+
16
25
  export function workspaceSnapshotsEqual(
17
26
  a: WorkspaceSnapshot,
18
27
  b: WorkspaceSnapshot
@@ -22,9 +31,17 @@ export function workspaceSnapshotsEqual(
22
31
  if (a.blueprintMeta.name !== b.blueprintMeta.name) return false;
23
32
  if ((a.blueprintMeta.remark ?? "") !== (b.blueprintMeta.remark ?? "")) return false;
24
33
  if (!blueprintDocumentsEqual(a.blueprintDocument, b.blueprintDocument)) return false;
25
- return JSON.stringify(a.panelState) === JSON.stringify(b.panelState);
34
+ return true;
26
35
  }
27
36
 
28
37
  export function cloneWorkspaceSnapshot(snapshot: WorkspaceSnapshot): WorkspaceSnapshot {
29
- return JSON.parse(JSON.stringify(snapshot)) as WorkspaceSnapshot;
38
+ return {
39
+ blueprintDocument: cloneBlueprintDocument(snapshot.blueprintDocument),
40
+ blueprintMeta: {
41
+ name: snapshot.blueprintMeta.name,
42
+ remark: snapshot.blueprintMeta.remark ?? "",
43
+ },
44
+ productName: snapshot.productName,
45
+ titleIconDataUrl: snapshot.titleIconDataUrl,
46
+ };
30
47
  }
@@ -28,6 +28,14 @@ export function clearViewElementScopes() {
28
28
  emit();
29
29
  }
30
30
 
31
+ export function removeViewElementScopes(elementIds: readonly string[]) {
32
+ let changed = false;
33
+ for (const id of elementIds) {
34
+ if (scopes.delete(id)) changed = true;
35
+ }
36
+ if (changed) emit();
37
+ }
38
+
31
39
  export function setViewElementScope(elementId: string, scope: unknown) {
32
40
  scopes.set(elementId, scope);
33
41
  emit();
@@ -56,6 +56,7 @@ export type ReferenceCopyMode = "shallow" | "deep";
56
56
 
57
57
  export type { PanelTableConfig } from "@arronqzy/view-table";
58
58
  import type { PanelTableConfig } from "@arronqzy/view-table";
59
+ import type { Scene3dConfig } from "@arronqzy/view-scene3d";
59
60
 
60
61
  export type PanelElement = {
61
62
  id: string;
@@ -75,8 +76,8 @@ export type PanelElement = {
75
76
  textFontSize?: number;
76
77
  textFontWeight?: string;
77
78
  textColor?: string;
78
- textLineHeight?: number;
79
- textAlign?: "left" | "center" | "right" | "justify";
79
+ textLineHeight?: number | "auto";
80
+ textAlign?: "left" | "center" | "right" | "justify" | "middle";
80
81
  gridRows?: number;
81
82
  gridCols?: number;
82
83
  gridGap?: number;
@@ -101,6 +102,7 @@ export type PanelElement = {
101
102
  geometryColor?: string;
102
103
  geometryScript?: string;
103
104
  geometrySketchDataUrl?: string;
105
+ scene3d?: Scene3dConfig;
104
106
  style?: PanelElementStyle;
105
107
  x: number;
106
108
  y: number;
@@ -0,0 +1,153 @@
1
+ import { shallowRef } from "vue";
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
+ export const busyOverlayMessage = shallowRef<string | null>(null);
17
+ let busyDepth = 0;
18
+
19
+ function setBusyMessage(message: string | null) {
20
+ busyOverlayMessage.value = message;
21
+ }
22
+
23
+ export function yieldToPaint(): Promise<void> {
24
+ return new Promise((resolve) => {
25
+ if (typeof requestAnimationFrame === "undefined") {
26
+ setTimeout(resolve, 0);
27
+ return;
28
+ }
29
+ requestAnimationFrame(() => {
30
+ requestAnimationFrame(() => resolve());
31
+ });
32
+ });
33
+ }
34
+
35
+ export async function runBusyTask<T>(
36
+ message: string,
37
+ task: () => Promise<T> | T
38
+ ): Promise<T> {
39
+ const previous = busyOverlayMessage.value;
40
+ busyDepth += 1;
41
+ setBusyMessage(message);
42
+ await yieldToPaint();
43
+ try {
44
+ return await task();
45
+ } finally {
46
+ busyDepth = Math.max(0, busyDepth - 1);
47
+ setBusyMessage(busyDepth === 0 ? null : previous);
48
+ }
49
+ }
50
+
51
+ function formatFileSize(bytes: number) {
52
+ if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`;
53
+ return `${Math.round(bytes / 1024)} KB`;
54
+ }
55
+
56
+ export function assertFileSize(file: Blob, kind: MediaFileKind) {
57
+ const limit = FILE_SIZE_LIMITS[kind];
58
+ if (file.size <= limit) return;
59
+ const t = tForLocale(resolveLocale());
60
+ throw new Error(t("common.fileTooLarge", { max: formatFileSize(limit) }));
61
+ }
62
+
63
+ export function readFileAsDataUrl(
64
+ file: Blob,
65
+ errorMessage: string,
66
+ kind: MediaFileKind = "image"
67
+ ): Promise<string> {
68
+ assertFileSize(file, kind);
69
+ const t = tForLocale(resolveLocale());
70
+ return runBusyTask(t("common.uploadingFile"), () => {
71
+ return new Promise<string>((resolve, reject) => {
72
+ const reader = new FileReader();
73
+ reader.onload = () => resolve(String(reader.result ?? ""));
74
+ reader.onerror = () => reject(new Error(errorMessage));
75
+ reader.readAsDataURL(file);
76
+ });
77
+ });
78
+ }
79
+
80
+ let jsonWorker: Worker | null = null;
81
+ let jsonWorkerObjectUrl: string | null = null;
82
+ let jsonRequestId = 0;
83
+ const jsonPending = new Map<
84
+ number,
85
+ { resolve: (value: unknown) => void; reject: (error: Error) => void }
86
+ >();
87
+
88
+ function ensureJsonWorker(): Worker | null {
89
+ if (typeof Worker === "undefined") return null;
90
+ if (jsonWorker) return jsonWorker;
91
+ const source = `
92
+ self.onmessage = (event) => {
93
+ const { id, text } = event.data || {};
94
+ try {
95
+ self.postMessage({ id, ok: true, value: JSON.parse(text) });
96
+ } catch (error) {
97
+ self.postMessage({
98
+ id,
99
+ ok: false,
100
+ error: error instanceof Error ? error.message : String(error),
101
+ });
102
+ }
103
+ };
104
+ `;
105
+ jsonWorkerObjectUrl = URL.createObjectURL(
106
+ new Blob([source], { type: "text/javascript" })
107
+ );
108
+ jsonWorker = new Worker(jsonWorkerObjectUrl);
109
+ jsonWorker.onmessage = (event: MessageEvent) => {
110
+ const { id, ok, value, error } = event.data ?? {};
111
+ const pending = jsonPending.get(id);
112
+ if (!pending) return;
113
+ jsonPending.delete(id);
114
+ if (ok) pending.resolve(value);
115
+ else pending.reject(new Error(String(error ?? "json-parse-failed")));
116
+ };
117
+ jsonWorker.onerror = () => {
118
+ jsonPending.forEach(({ reject }) => reject(new Error("json-worker-failed")));
119
+ jsonPending.clear();
120
+ disposeJsonWorker();
121
+ };
122
+ if (typeof window !== "undefined") {
123
+ window.addEventListener("pagehide", disposeJsonWorker, { once: true });
124
+ }
125
+ return jsonWorker;
126
+ }
127
+
128
+ function disposeJsonWorker() {
129
+ jsonPending.forEach(({ reject }) => reject(new Error("json-worker-disposed")));
130
+ jsonPending.clear();
131
+ jsonWorker?.terminate();
132
+ jsonWorker = null;
133
+ if (jsonWorkerObjectUrl) {
134
+ URL.revokeObjectURL(jsonWorkerObjectUrl);
135
+ jsonWorkerObjectUrl = null;
136
+ }
137
+ }
138
+
139
+ export async function parseJsonText<T>(text: string): Promise<T> {
140
+ if (text.length < JSON_WORKER_THRESHOLD) {
141
+ return JSON.parse(text) as T;
142
+ }
143
+ const worker = ensureJsonWorker();
144
+ if (!worker) return JSON.parse(text) as T;
145
+ const id = ++jsonRequestId;
146
+ return new Promise<T>((resolve, reject) => {
147
+ jsonPending.set(id, {
148
+ resolve: (value) => resolve(value as T),
149
+ reject,
150
+ });
151
+ worker.postMessage({ id, text });
152
+ });
153
+ }