@arronqzy/vue-view 0.1.0

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 (75) hide show
  1. package/README.md +50 -0
  2. package/package.json +49 -0
  3. package/src/env.d.ts +62 -0
  4. package/src/index.ts +4 -0
  5. package/src/panel/VueViewOnlinePreview.vue +276 -0
  6. package/src/panel/VueViewPanel.vue +871 -0
  7. package/src/panel/components/ConfigHintIcon.vue +34 -0
  8. package/src/panel/components/ElementsLayer.vue +165 -0
  9. package/src/panel/components/MaterialPreview.vue +135 -0
  10. package/src/panel/components/MaterialSidebar.vue +526 -0
  11. package/src/panel/components/MaterialSidebarTreeNode.vue +305 -0
  12. package/src/panel/components/MoveableLayer.vue +859 -0
  13. package/src/panel/components/PanelCanvas.vue +630 -0
  14. package/src/panel/components/PanelConfigSidebar.vue +397 -0
  15. package/src/panel/components/PanelRulers.vue +177 -0
  16. package/src/panel/components/SelectLayer.vue +115 -0
  17. package/src/panel/components/ViewElementScopePanel.vue +76 -0
  18. package/src/panel/components/WorkspaceConfigSidebar.vue +147 -0
  19. package/src/panel/components/WorkspaceProjectNav.vue +192 -0
  20. package/src/panel/components/WorkspaceStageSplit.vue +258 -0
  21. package/src/panel/components/config/ConfigColorField.vue +52 -0
  22. package/src/panel/components/config/ConfigFieldGroup.vue +20 -0
  23. package/src/panel/components/config/ConfigSection.vue +50 -0
  24. package/src/panel/components/config/PanelConfigAudioSection.vue +256 -0
  25. package/src/panel/components/config/PanelConfigChartSection.vue +650 -0
  26. package/src/panel/components/config/PanelConfigGeometrySection.vue +209 -0
  27. package/src/panel/components/config/PanelConfigGridChildSpan.vue +68 -0
  28. package/src/panel/components/config/PanelConfigGridSection.vue +103 -0
  29. package/src/panel/components/config/PanelConfigImageSection.vue +136 -0
  30. package/src/panel/components/config/PanelConfigMultiSelect.vue +434 -0
  31. package/src/panel/components/config/PanelConfigNodeInfo.vue +165 -0
  32. package/src/panel/components/config/PanelConfigReferenceSection.vue +77 -0
  33. package/src/panel/components/config/PanelConfigStyleSections.vue +208 -0
  34. package/src/panel/components/config/PanelConfigTextSection.vue +195 -0
  35. package/src/panel/components/config/PanelConfigVideoSection.vue +107 -0
  36. package/src/panel/components/config/shared.ts +74 -0
  37. package/src/panel/components/elementsLayerNodes.ts +830 -0
  38. package/src/panel/components/materialSidebarData.ts +85 -0
  39. package/src/panel/components/scope-config/ScopeConfigProvider.vue +153 -0
  40. package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.vue +234 -0
  41. package/src/panel/components/scope-config/ScopeTemplatePreviewHost.vue +192 -0
  42. package/src/panel/components/scope-config/ScopeTemplatePreviewPanel.vue +42 -0
  43. package/src/panel/components/scope-config/ScopeTemplateUsageHint.vue +20 -0
  44. package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.vue +63 -0
  45. package/src/panel/components/scope-config/scopeConfigContext.ts +17 -0
  46. package/src/panel/components/scope-config/useScopeConfig.ts +11 -0
  47. package/src/panel/constants/messages.ts +34 -0
  48. package/src/panel/constants/zIndex.ts +6 -0
  49. package/src/panel/hooks/usePanelElements.ts +1075 -0
  50. package/src/panel/hooks/useRafThrottledScroll.ts +25 -0
  51. package/src/panel/hooks/useWorkspaceProjects.ts +240 -0
  52. package/src/panel/lib/panel-ruler-canvas.ts +139 -0
  53. package/src/panel/library/workspace-project-cache.ts +23 -0
  54. package/src/panel/library/workspace-project-db.ts +111 -0
  55. package/src/panel/library/workspace-project-sync.ts +41 -0
  56. package/src/panel/library/workspace-snapshot.ts +30 -0
  57. package/src/panel/parseOnlinePreviewSearchParams.ts +13 -0
  58. package/src/panel/scope/view-scope-store.ts +82 -0
  59. package/src/panel/types.ts +127 -0
  60. package/src/panel/utils/chartOptionBuilder.ts +327 -0
  61. package/src/panel/utils/gridPlacement.ts +189 -0
  62. package/src/panel/utils/mappingLayerOps.ts +142 -0
  63. package/src/panel/utils/panelElementDefaults.ts +161 -0
  64. package/src/panel/utils/panelElementNodes.ts +35 -0
  65. package/src/panel/utils/panelStateIO.ts +124 -0
  66. package/src/panel/utils/scope-autocomplete.ts +114 -0
  67. package/src/panel/utils/scope-field-labels.ts +46 -0
  68. package/src/panel/utils/scope-template-chart.ts +92 -0
  69. package/src/panel/utils/scope-template-preview.ts +124 -0
  70. package/src/panel/utils/scope-template-spread.ts +229 -0
  71. package/src/panel/utils/scope-template-warnings.ts +243 -0
  72. package/src/panel/utils/scope-template.ts +97 -0
  73. package/src/panel/utils/updateElementDraft.ts +221 -0
  74. package/src/panel/viewportZoom.ts +26 -0
  75. package/src/tailwind.css +43 -0
@@ -0,0 +1,25 @@
1
+ import { onUnmounted, ref, shallowRef } from "vue";
2
+
3
+ export function useRafThrottledScroll() {
4
+ const scrollRef = shallowRef({ left: 0, top: 0 });
5
+ const scroll = ref({ left: 0, top: 0 });
6
+ let rafRef: number | null = null;
7
+
8
+ function onScrollChange(next: { left: number; top: number }) {
9
+ scrollRef.value = next;
10
+ if (rafRef != null) return;
11
+ rafRef = requestAnimationFrame(() => {
12
+ rafRef = null;
13
+ const latest = scrollRef.value;
14
+ if (scroll.value.left !== latest.left || scroll.value.top !== latest.top) {
15
+ scroll.value = { ...latest };
16
+ }
17
+ });
18
+ }
19
+
20
+ onUnmounted(() => {
21
+ if (rafRef != null) cancelAnimationFrame(rafRef);
22
+ });
23
+
24
+ return { scroll, scrollRef, onScrollChange };
25
+ }
@@ -0,0 +1,240 @@
1
+ import { computed, onMounted, ref, shallowRef, toValue, type MaybeRefOrGetter } from "vue";
2
+ import {
3
+ BlueprintGraph,
4
+ type BlueprintDocument,
5
+ type BlueprintMetaDraft,
6
+ } from "@arronqzy/vue-blueprint";
7
+ import type { State } from "@arronqzy/rx-store";
8
+ import {
9
+ createWorkspaceProjectId,
10
+ deleteWorkspaceProject,
11
+ getWorkspaceProject,
12
+ listWorkspaceProjects,
13
+ putWorkspaceProject,
14
+ type WorkspaceProjectListItem,
15
+ type WorkspaceProjectRecord,
16
+ } from "../library/workspace-project-db";
17
+ import { broadcastWorkspaceProjectUpdate } from "../library/workspace-project-sync";
18
+ import { writeWorkspacePreviewCache, readWorkspacePreviewCache } from "../library/workspace-project-cache";
19
+ import {
20
+ cloneWorkspaceSnapshot,
21
+ workspaceSnapshotsEqual,
22
+ type WorkspaceSnapshot,
23
+ } from "../library/workspace-snapshot";
24
+
25
+ export type UseWorkspaceProjectsOptions = {
26
+ exportPanelData: () => State;
27
+ importPanelData: (state: State) => boolean;
28
+ blueprintDocument: MaybeRefOrGetter<BlueprintDocument>;
29
+ blueprintMeta: MaybeRefOrGetter<BlueprintMetaDraft>;
30
+ setBlueprintGraph: (graph: ReturnType<typeof BlueprintGraph.fromDocument>) => void;
31
+ setBlueprintMeta: (meta: BlueprintMetaDraft) => void;
32
+ productName: MaybeRefOrGetter<string>;
33
+ setProductName: (name: string) => void;
34
+ titleIconDataUrl: MaybeRefOrGetter<string>;
35
+ setTitleIconDataUrl: (url: string) => void;
36
+ /** 视图面板修订号,用于检测未同步修改 */
37
+ panelRevision: MaybeRefOrGetter<unknown>;
38
+ /** 工作区记录应用到编辑器后的回调(用于恢复蓝图面板等 UI 状态) */
39
+ onProjectApplied?: (record: WorkspaceProjectRecord) => void;
40
+ };
41
+
42
+ export function buildOnlinePreviewUrl(projectId: string): string {
43
+ const url = new URL(window.location.href);
44
+ url.search = "";
45
+ url.searchParams.set("preview", "online");
46
+ url.searchParams.set("projectId", projectId);
47
+ const previewInstanceId =
48
+ typeof crypto !== "undefined" && "randomUUID" in crypto
49
+ ? crypto.randomUUID()
50
+ : `pv-${Date.now()}`;
51
+ url.searchParams.set("pid", previewInstanceId);
52
+ return url.toString();
53
+ }
54
+
55
+ export function useWorkspaceProjects(options: UseWorkspaceProjectsOptions) {
56
+ const projects = ref<WorkspaceProjectListItem[]>([]);
57
+ const activeProjectId = ref<string | null>(null);
58
+ const activeProjectName = ref<string | null>(null);
59
+ const syncedSnapshotRef = shallowRef<WorkspaceSnapshot | null>(null);
60
+
61
+ function resolveProjectName() {
62
+ return toValue(options.productName).trim() || "未命名产物";
63
+ }
64
+
65
+ function buildCurrentSnapshot(): WorkspaceSnapshot {
66
+ return {
67
+ panelState: options.exportPanelData(),
68
+ blueprintDocument: toValue(options.blueprintDocument),
69
+ blueprintMeta: { ...toValue(options.blueprintMeta) },
70
+ productName: toValue(options.productName),
71
+ titleIconDataUrl: toValue(options.titleIconDataUrl),
72
+ };
73
+ }
74
+
75
+ async function refreshProjects() {
76
+ projects.value = await listWorkspaceProjects();
77
+ }
78
+
79
+ onMounted(() => {
80
+ void refreshProjects();
81
+ });
82
+
83
+ const dirty = computed(() => {
84
+ if (!activeProjectId.value || !syncedSnapshotRef.value) return false;
85
+ void toValue(options.panelRevision);
86
+ void toValue(options.blueprintDocument);
87
+ void toValue(options.blueprintMeta);
88
+ void toValue(options.productName);
89
+ void toValue(options.titleIconDataUrl);
90
+ return !workspaceSnapshotsEqual(buildCurrentSnapshot(), syncedSnapshotRef.value);
91
+ });
92
+
93
+ function applyProjectRecord(record: WorkspaceProjectRecord) {
94
+ const blueprintDocument =
95
+ record.blueprintDocument ?? BlueprintGraph.empty().document;
96
+ const blueprintMeta: BlueprintMetaDraft = {
97
+ name: record.blueprintMeta?.name ?? "未命名蓝图",
98
+ remark: record.blueprintMeta?.remark ?? "",
99
+ };
100
+
101
+ options.importPanelData(record.panelState);
102
+ options.setBlueprintGraph(BlueprintGraph.fromDocument(blueprintDocument));
103
+ options.setBlueprintMeta(blueprintMeta);
104
+ options.setProductName(record.productName);
105
+ options.setTitleIconDataUrl(record.titleIconDataUrl ?? "");
106
+ activeProjectId.value = record.id;
107
+ activeProjectName.value = record.name;
108
+ syncedSnapshotRef.value = cloneWorkspaceSnapshot({
109
+ panelState: record.panelState,
110
+ blueprintDocument,
111
+ blueprintMeta,
112
+ productName: record.productName,
113
+ titleIconDataUrl: record.titleIconDataUrl ?? "",
114
+ });
115
+ options.onProjectApplied?.({
116
+ ...record,
117
+ blueprintDocument,
118
+ blueprintMeta,
119
+ });
120
+ }
121
+
122
+ async function persistProject(persistOptions: {
123
+ id?: string;
124
+ name: string;
125
+ createdAt?: number;
126
+ }) {
127
+ const snapshot = buildCurrentSnapshot();
128
+ const now = Date.now();
129
+ const id = persistOptions.id ?? createWorkspaceProjectId();
130
+ const createdAt = persistOptions.createdAt ?? now;
131
+ const name = persistOptions.name.trim() || resolveProjectName();
132
+
133
+ const record: WorkspaceProjectRecord = {
134
+ id,
135
+ name,
136
+ createdAt,
137
+ updatedAt: now,
138
+ panelState: snapshot.panelState,
139
+ blueprintDocument: snapshot.blueprintDocument,
140
+ blueprintMeta: snapshot.blueprintMeta,
141
+ productName: snapshot.productName,
142
+ titleIconDataUrl: snapshot.titleIconDataUrl || undefined,
143
+ };
144
+
145
+ await putWorkspaceProject(record);
146
+ writeWorkspacePreviewCache(record);
147
+ broadcastWorkspaceProjectUpdate(id, now);
148
+ activeProjectId.value = id;
149
+ activeProjectName.value = name;
150
+ syncedSnapshotRef.value = cloneWorkspaceSnapshot(snapshot);
151
+ await refreshProjects();
152
+ return record;
153
+ }
154
+
155
+ async function handleCreateProject() {
156
+ const name = resolveProjectName();
157
+ const record = await persistProject({ name });
158
+ return { name, id: record.id };
159
+ }
160
+
161
+ async function handleSaveProject() {
162
+ if (!activeProjectId.value) {
163
+ return handleCreateProject();
164
+ }
165
+ const existing = await getWorkspaceProject(activeProjectId.value);
166
+ const name = resolveProjectName();
167
+ const record = await persistProject({
168
+ id: activeProjectId.value,
169
+ name,
170
+ createdAt: existing?.createdAt,
171
+ });
172
+ return { name, id: record.id };
173
+ }
174
+
175
+ async function handleOpenProject(id: string) {
176
+ const record = await getWorkspaceProject(id);
177
+ if (!record) {
178
+ await refreshProjects();
179
+ throw new Error("工作区不存在或已被删除");
180
+ }
181
+ applyProjectRecord(record);
182
+ }
183
+
184
+ async function handleSyncProject() {
185
+ if (!activeProjectId.value) {
186
+ throw new Error("请先保存工作区");
187
+ }
188
+ const existing = await getWorkspaceProject(activeProjectId.value);
189
+ if (!existing) {
190
+ activeProjectId.value = null;
191
+ activeProjectName.value = null;
192
+ syncedSnapshotRef.value = null;
193
+ await refreshProjects();
194
+ throw new Error("工作区不存在或已被删除");
195
+ }
196
+ const name = resolveProjectName() || existing.name;
197
+ await persistProject({
198
+ id: activeProjectId.value,
199
+ name,
200
+ createdAt: existing.createdAt,
201
+ });
202
+ return name;
203
+ }
204
+
205
+ async function handleDeleteProject(id: string) {
206
+ await deleteWorkspaceProject(id);
207
+ if (activeProjectId.value === id) {
208
+ activeProjectId.value = null;
209
+ activeProjectName.value = null;
210
+ syncedSnapshotRef.value = null;
211
+ }
212
+ await refreshProjects();
213
+ }
214
+
215
+ async function handlePreviewProject(id: string, previewOptions?: { syncFirst?: boolean }) {
216
+ if (previewOptions?.syncFirst && activeProjectId.value === id && dirty.value) {
217
+ await handleSyncProject();
218
+ }
219
+ const record =
220
+ (await getWorkspaceProject(id)) ?? readWorkspacePreviewCache(id);
221
+ if (record) {
222
+ writeWorkspacePreviewCache(record);
223
+ }
224
+ window.open(buildOnlinePreviewUrl(id), "_blank", "noopener,noreferrer");
225
+ }
226
+
227
+ return {
228
+ projects,
229
+ activeProjectId,
230
+ activeProjectName,
231
+ dirty,
232
+ handleCreateProject,
233
+ handleSaveProject,
234
+ handleOpenProject,
235
+ handleSyncProject,
236
+ handleDeleteProject,
237
+ handlePreviewProject,
238
+ refreshProjects,
239
+ };
240
+ }
@@ -0,0 +1,139 @@
1
+ export type RulerType = "horizontal" | "vertical";
2
+
3
+ export type RulerOptions = {
4
+ type?: RulerType;
5
+ zoom?: number;
6
+ unit?: number;
7
+ segment?: number;
8
+ backgroundColor?: string;
9
+ lineColor?: string;
10
+ textColor?: string;
11
+ font?: string;
12
+ longLineSize?: number;
13
+ shortLineSize?: number;
14
+ };
15
+
16
+ /** 轻量标尺实现,API 对齐 @scena/ruler,避免额外原生依赖 */
17
+ export class PanelRuler {
18
+ private canvas: HTMLCanvasElement;
19
+ private ctx: CanvasRenderingContext2D;
20
+ private options: Required<RulerOptions>;
21
+ private scrollPos = 0;
22
+ private ro: ResizeObserver | null = null;
23
+
24
+ constructor(container: HTMLElement, options: RulerOptions = {}) {
25
+ this.canvas = document.createElement("canvas");
26
+ this.canvas.style.display = "block";
27
+ this.canvas.style.width = "100%";
28
+ this.canvas.style.height = "100%";
29
+ container.appendChild(this.canvas);
30
+ const ctx = this.canvas.getContext("2d");
31
+ if (!ctx) throw new Error("Canvas 2D not supported");
32
+ this.ctx = ctx;
33
+ this.options = {
34
+ type: options.type ?? "horizontal",
35
+ zoom: options.zoom ?? 1,
36
+ unit: options.unit ?? 100,
37
+ segment: options.segment ?? 10,
38
+ backgroundColor: options.backgroundColor ?? "rgba(255,255,255,0.98)",
39
+ lineColor: options.lineColor ?? "rgba(15,23,42,0.22)",
40
+ textColor: options.textColor ?? "rgba(15,23,42,0.75)",
41
+ font: options.font ?? "10px system-ui, sans-serif",
42
+ longLineSize: options.longLineSize ?? 8,
43
+ shortLineSize: options.shortLineSize ?? 4,
44
+ };
45
+ this.ro = new ResizeObserver(() => this.resize());
46
+ this.ro.observe(container);
47
+ this.resize();
48
+ }
49
+
50
+ scroll(scrollPos: number) {
51
+ this.scrollPos = scrollPos;
52
+ this.draw();
53
+ }
54
+
55
+ setState(patch: Partial<RulerOptions>) {
56
+ Object.assign(this.options, patch);
57
+ this.draw();
58
+ }
59
+
60
+ resize() {
61
+ const parent = this.canvas.parentElement;
62
+ if (!parent) return;
63
+ const dpr = window.devicePixelRatio || 1;
64
+ const width = parent.clientWidth;
65
+ const height = parent.clientHeight;
66
+ this.canvas.width = Math.max(1, Math.floor(width * dpr));
67
+ this.canvas.height = Math.max(1, Math.floor(height * dpr));
68
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
69
+ this.draw();
70
+ }
71
+
72
+ destroy() {
73
+ this.ro?.disconnect();
74
+ this.canvas.remove();
75
+ }
76
+
77
+ private draw() {
78
+ const {
79
+ type,
80
+ zoom,
81
+ unit,
82
+ segment,
83
+ backgroundColor,
84
+ lineColor,
85
+ textColor,
86
+ font,
87
+ longLineSize,
88
+ shortLineSize,
89
+ } = this.options;
90
+ const width = this.canvas.width / (window.devicePixelRatio || 1);
91
+ const height = this.canvas.height / (window.devicePixelRatio || 1);
92
+ const ctx = this.ctx;
93
+ ctx.clearRect(0, 0, width, height);
94
+ ctx.fillStyle = backgroundColor;
95
+ ctx.fillRect(0, 0, width, height);
96
+
97
+ const step = (unit / segment) * zoom;
98
+ if (step <= 0) return;
99
+
100
+ const isHorizontal = type === "horizontal";
101
+ const span = isHorizontal ? width : height;
102
+ const startWorld = -this.scrollPos;
103
+ const startIndex = Math.floor(startWorld / step) - 1;
104
+ const endIndex = Math.ceil((startWorld + span) / step) + 1;
105
+
106
+ ctx.strokeStyle = lineColor;
107
+ ctx.fillStyle = textColor;
108
+ ctx.font = font;
109
+ ctx.textBaseline = "top";
110
+
111
+ for (let i = startIndex; i <= endIndex; i += 1) {
112
+ const world = i * step;
113
+ const px = world + this.scrollPos;
114
+ if (px < -step || px > span + step) continue;
115
+ const isMajor = i % segment === 0;
116
+ const lineLen = isMajor ? longLineSize : shortLineSize;
117
+ ctx.beginPath();
118
+ if (isHorizontal) {
119
+ ctx.moveTo(px + 0.5, height);
120
+ ctx.lineTo(px + 0.5, height - lineLen);
121
+ if (isMajor) {
122
+ const label = String(Math.round((i * unit) / segment));
123
+ ctx.fillText(label, px + 2, 2);
124
+ }
125
+ } else {
126
+ ctx.moveTo(width, px + 0.5);
127
+ ctx.lineTo(width - lineLen, px + 0.5);
128
+ if (isMajor) {
129
+ const label = String(Math.round((i * unit) / segment));
130
+ ctx.save();
131
+ ctx.translate(2, px + 2);
132
+ ctx.fillText(label, 0, 0);
133
+ ctx.restore();
134
+ }
135
+ }
136
+ ctx.stroke();
137
+ }
138
+ }
139
+ }
@@ -0,0 +1,23 @@
1
+ import type { WorkspaceProjectRecord } from "./workspace-project-db";
2
+
3
+ const CACHE_PREFIX = "arronqzy-workspace-preview:";
4
+
5
+ export function writeWorkspacePreviewCache(record: WorkspaceProjectRecord): void {
6
+ try {
7
+ localStorage.setItem(CACHE_PREFIX + record.id, JSON.stringify(record));
8
+ } catch {
9
+ // ignore quota / private mode
10
+ }
11
+ }
12
+
13
+ export function readWorkspacePreviewCache(
14
+ projectId: string
15
+ ): WorkspaceProjectRecord | null {
16
+ try {
17
+ const raw = localStorage.getItem(CACHE_PREFIX + projectId);
18
+ if (!raw) return null;
19
+ return JSON.parse(raw) as WorkspaceProjectRecord;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
@@ -0,0 +1,111 @@
1
+ import type { BlueprintDocument, BlueprintMetaDraft } from "@arronqzy/vue-blueprint";
2
+ import type { State } from "@arronqzy/rx-store";
3
+
4
+ const DB_NAME = "arronqzy-workspace-projects";
5
+ const DB_VERSION = 1;
6
+ const STORE_NAME = "projects";
7
+
8
+ export type WorkspaceProjectListItem = {
9
+ id: string;
10
+ name: string;
11
+ updatedAt: number;
12
+ createdAt: number;
13
+ };
14
+
15
+ export type WorkspaceProjectRecord = {
16
+ id: string;
17
+ name: string;
18
+ createdAt: number;
19
+ updatedAt: number;
20
+ panelState: State;
21
+ blueprintDocument: BlueprintDocument;
22
+ blueprintMeta: BlueprintMetaDraft;
23
+ productName: string;
24
+ titleIconDataUrl?: string;
25
+ };
26
+
27
+ function openDb(): Promise<IDBDatabase> {
28
+ return new Promise((resolve, reject) => {
29
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
30
+ request.onerror = () => reject(request.error ?? new Error("indexeddb-open-failed"));
31
+ request.onupgradeneeded = () => {
32
+ const db = request.result;
33
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
34
+ const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
35
+ store.createIndex("updatedAt", "updatedAt", { unique: false });
36
+ }
37
+ };
38
+ request.onsuccess = () => resolve(request.result);
39
+ });
40
+ }
41
+
42
+ function runTransaction<T>(
43
+ mode: IDBTransactionMode,
44
+ runner: (store: IDBObjectStore) => IDBRequest<T>
45
+ ): Promise<T> {
46
+ return openDb().then(
47
+ (db) =>
48
+ new Promise<T>((resolve, reject) => {
49
+ const tx = db.transaction(STORE_NAME, mode);
50
+ const store = tx.objectStore(STORE_NAME);
51
+ const request = runner(store);
52
+ let result!: T;
53
+
54
+ request.onerror = () =>
55
+ reject(request.error ?? new Error("indexeddb-request-failed"));
56
+ request.onsuccess = () => {
57
+ result = request.result as T;
58
+ };
59
+
60
+ tx.oncomplete = () => {
61
+ db.close();
62
+ resolve(result);
63
+ };
64
+ tx.onerror = () => {
65
+ db.close();
66
+ reject(tx.error ?? new Error("indexeddb-transaction-failed"));
67
+ };
68
+ })
69
+ );
70
+ }
71
+
72
+ export function createWorkspaceProjectId(): string {
73
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
74
+ return crypto.randomUUID();
75
+ }
76
+ return `ws-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
77
+ }
78
+
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);
91
+ }
92
+
93
+ export async function getWorkspaceProject(
94
+ id: string
95
+ ): Promise<WorkspaceProjectRecord | null> {
96
+ const record = await runTransaction<WorkspaceProjectRecord | undefined>("readonly", (store) =>
97
+ store.get(id)
98
+ );
99
+ return record ?? null;
100
+ }
101
+
102
+ export async function putWorkspaceProject(
103
+ record: WorkspaceProjectRecord
104
+ ): Promise<WorkspaceProjectRecord> {
105
+ await runTransaction<IDBValidKey>("readwrite", (store) => store.put(record));
106
+ return record;
107
+ }
108
+
109
+ export async function deleteWorkspaceProject(id: string): Promise<void> {
110
+ await runTransaction<undefined>("readwrite", (store) => store.delete(id));
111
+ }
@@ -0,0 +1,41 @@
1
+ const CHANNEL_PREFIX = "arronqzy-workspace-project";
2
+
3
+ export type WorkspaceProjectSyncMessage = {
4
+ type: "updated";
5
+ projectId: string;
6
+ updatedAt: number;
7
+ };
8
+
9
+ export function getWorkspaceProjectChannelName(projectId: string): string {
10
+ return `${CHANNEL_PREFIX}:${projectId}`;
11
+ }
12
+
13
+ export function broadcastWorkspaceProjectUpdate(
14
+ projectId: string,
15
+ updatedAt: number
16
+ ): void {
17
+ if (typeof BroadcastChannel === "undefined") return;
18
+ const channel = new BroadcastChannel(getWorkspaceProjectChannelName(projectId));
19
+ channel.postMessage({
20
+ type: "updated",
21
+ projectId,
22
+ updatedAt,
23
+ } satisfies WorkspaceProjectSyncMessage);
24
+ channel.close();
25
+ }
26
+
27
+ export function subscribeWorkspaceProjectUpdates(
28
+ projectId: string,
29
+ onUpdate: (message: WorkspaceProjectSyncMessage) => void
30
+ ): () => void {
31
+ if (typeof BroadcastChannel === "undefined") {
32
+ return () => {};
33
+ }
34
+ const channel = new BroadcastChannel(getWorkspaceProjectChannelName(projectId));
35
+ channel.onmessage = (event: MessageEvent<WorkspaceProjectSyncMessage>) => {
36
+ const data = event.data;
37
+ if (!data || data.type !== "updated" || data.projectId !== projectId) return;
38
+ onUpdate(data);
39
+ };
40
+ return () => channel.close();
41
+ }
@@ -0,0 +1,30 @@
1
+ import {
2
+ blueprintDocumentsEqual,
3
+ type BlueprintDocument,
4
+ type BlueprintMetaDraft,
5
+ } from "@arronqzy/vue-blueprint";
6
+ import type { State } from "@arronqzy/rx-store";
7
+
8
+ export type WorkspaceSnapshot = {
9
+ panelState: State;
10
+ blueprintDocument: BlueprintDocument;
11
+ blueprintMeta: BlueprintMetaDraft;
12
+ productName: string;
13
+ titleIconDataUrl: string;
14
+ };
15
+
16
+ export function workspaceSnapshotsEqual(
17
+ a: WorkspaceSnapshot,
18
+ b: WorkspaceSnapshot
19
+ ): boolean {
20
+ if (a.productName !== b.productName) return false;
21
+ if (a.titleIconDataUrl !== b.titleIconDataUrl) return false;
22
+ if (a.blueprintMeta.name !== b.blueprintMeta.name) return false;
23
+ if ((a.blueprintMeta.remark ?? "") !== (b.blueprintMeta.remark ?? "")) return false;
24
+ if (!blueprintDocumentsEqual(a.blueprintDocument, b.blueprintDocument)) return false;
25
+ return JSON.stringify(a.panelState) === JSON.stringify(b.panelState);
26
+ }
27
+
28
+ export function cloneWorkspaceSnapshot(snapshot: WorkspaceSnapshot): WorkspaceSnapshot {
29
+ return JSON.parse(JSON.stringify(snapshot)) as WorkspaceSnapshot;
30
+ }
@@ -0,0 +1,13 @@
1
+ export function parseOnlinePreviewSearchParams(search: string): {
2
+ projectId: string;
3
+ previewInstanceId?: string;
4
+ } | null {
5
+ const params = new URLSearchParams(search);
6
+ if (params.get("preview") !== "online") return null;
7
+ const projectId = params.get("projectId") ?? params.get("id");
8
+ if (!projectId) return null;
9
+ return {
10
+ projectId,
11
+ previewInstanceId: params.get("pid") ?? undefined,
12
+ };
13
+ }