@arronqzy/react-view 1.0.14 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arronqzy/react-view",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "files": [
5
5
  "src",
6
6
  "dist",
@@ -30,14 +30,14 @@
30
30
  "@codemirror/lang-json": "^6.0.1",
31
31
  "@codemirror/theme-one-dark": "^6.1.2",
32
32
  "@codemirror/view": "^6.36.2",
33
- "@arronqzy/ui": "1.0.5",
34
- "@arronqzy/blueprint-dsl": "1.0.7",
35
- "@arronqzy/i18n": "0.1.6",
36
- "@arronqzy/react-blueprint": "1.0.8",
37
- "@arronqzy/rx-store": "1.0.4",
38
- "@arronqzy/view-scene3d": "0.1.1",
39
- "@arronqzy/view-table": "0.1.4",
40
- "@arronqzy/webllm-assistant": "0.1.8"
33
+ "@arronqzy/ui": "1.0.6",
34
+ "@arronqzy/blueprint-dsl": "1.0.8",
35
+ "@arronqzy/i18n": "0.1.7",
36
+ "@arronqzy/react-blueprint": "1.0.9",
37
+ "@arronqzy/rx-store": "1.0.5",
38
+ "@arronqzy/webllm-assistant": "0.1.9",
39
+ "@arronqzy/view-table": "0.1.5",
40
+ "@arronqzy/view-scene3d": "0.1.2"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/react": "^19.0.1",
@@ -46,8 +46,8 @@
46
46
  "eslint": "^8.57.0",
47
47
  "typescript": "^5.5.4",
48
48
  "@arronqzy/eslint-config": "0.0.0",
49
- "@arronqzy/typescript-config": "1.0.0",
50
- "@arronqzy/tailwind": "1.0.2"
49
+ "@arronqzy/tailwind": "1.0.2",
50
+ "@arronqzy/typescript-config": "1.0.0"
51
51
  },
52
52
  "scripts": {
53
53
  "lint": "eslint \"src/**/*.{ts,tsx}\"",
package/readme.md CHANGED
@@ -10,6 +10,7 @@ Abuilder 的**视图编辑器** React 包,提供可视化画布、图层、物
10
10
  - **Scope 模版**:节点属性支持 `{scope.xxx}` 表达式与解析预览
11
11
  - **工作区**:IndexedDB 持久化、多项目切换、同步与在线预览
12
12
  - **蓝图联动**:与 `@arronqzy/react-blueprint` 分屏编辑,运行时 Scope 回写视图
13
+ - **宿主集成**:工作区事件订阅、预览快照、外部工作区数据加载
13
14
 
14
15
  ## 安装
15
16
 
@@ -29,21 +30,42 @@ import "@arronqzy/react-view/styles.css";
29
30
 
30
31
  ```tsx
31
32
  import { ReactViewPanel } from "@arronqzy/react-view";
33
+ import type { WorkspaceProjectRecord } from "@arronqzy/react-view";
32
34
  import "@arronqzy/react-view/styles.css";
33
35
 
34
- export function Editor() {
35
- return <ReactViewPanel className="h-screen" initialZoom={1} />;
36
+ export function Editor({ workspace }: { workspace?: WorkspaceProjectRecord | null }) {
37
+ return (
38
+ <ReactViewPanel
39
+ className="h-screen"
40
+ initialZoom={1}
41
+ initialWorkspace={workspace}
42
+ />
43
+ );
36
44
  }
37
45
  ```
38
46
 
47
+ | `ReactViewPanel` 参数 | 说明 | 默认值 |
48
+ |----------------------|------|--------|
49
+ | `className` | 根节点 class | — |
50
+ | `initialZoom` | 初始缩放 | `1` |
51
+ | `initialWorkspace` | 外部传入的完整工作区,挂载后自动加载 | — |
52
+
39
53
  ### 在线预览
40
54
 
55
+ 按工作区 ID 从 IndexedDB / 缓存加载:
56
+
41
57
  ```tsx
42
58
  import { ReactViewOnlinePreview } from "@arronqzy/react-view";
43
59
 
44
60
  <ReactViewOnlinePreview projectId="your-project-id" />
45
61
  ```
46
62
 
63
+ 直接传入工作区数据(无需 IndexedDB):
64
+
65
+ ```tsx
66
+ <ReactViewOnlinePreview workspace={savedWorkspace} />
67
+ ```
68
+
47
69
  URL 参数解析:
48
70
 
49
71
  ```ts
@@ -52,6 +74,70 @@ import { parseOnlinePreviewSearchParams } from "@arronqzy/react-view";
52
74
  const params = parseOnlinePreviewSearchParams("?preview=online&projectId=xxx");
53
75
  ```
54
76
 
77
+ | `ReactViewOnlinePreview` 参数 | 说明 |
78
+ |------------------------------|------|
79
+ | `projectId` | 从 IndexedDB 加载的工作区 ID |
80
+ | `workspace` | 直接传入的完整工作区数据(优先于 `projectId`) |
81
+ | `previewInstanceId` | 预览实例 ID(可选) |
82
+
83
+ ### 工作区事件订阅
84
+
85
+ 监听工作区创建与同步,回调携带**完整工作区数据**(面板 + 蓝图),便于持久化到自有后端:
86
+
87
+ ```ts
88
+ import {
89
+ addEventSubscription,
90
+ AbuilderEvents,
91
+ type WorkspaceData,
92
+ } from "@arronqzy/react-view";
93
+
94
+ const addSub = addEventSubscription(
95
+ AbuilderEvents.workspaceAdd,
96
+ async (workspace: WorkspaceData) => {
97
+ await saveToServer(workspace);
98
+ }
99
+ );
100
+
101
+ const syncSub = addEventSubscription(
102
+ AbuilderEvents.workspaceSync,
103
+ async (workspace) => {
104
+ await saveToServer(workspace);
105
+ }
106
+ );
107
+
108
+ addSub.unsubscribe();
109
+ syncSub.unsubscribe();
110
+ ```
111
+
112
+ | 事件 | 常量 | 触发时机 |
113
+ |------|------|----------|
114
+ | `workspace:add` | `AbuilderEvents.workspaceAdd` | 创建工作区成功 |
115
+ | `workspace:sync` | `AbuilderEvents.workspaceSync` | 同步工作区成功 |
116
+
117
+ 回调参数类型为 `WorkspaceData`(即 `WorkspaceProjectRecord`),字段包括:`id`、`name`、`createdAt`、`updatedAt`、`panelState`、`blueprintDocument`、`blueprintMeta`、`productName`、`titleIconDataUrl`。
118
+
119
+ ### 获取预览快照
120
+
121
+ 在编辑器或在线预览页已挂载时,截取当前预览画面为 base64(data URL),可用于缩略图:
122
+
123
+ ```ts
124
+ import { getPreviewSnapshot } from "@arronqzy/react-view";
125
+
126
+ const dataUrl = await getPreviewSnapshot({
127
+ maxWidth: 320,
128
+ maxHeight: 180,
129
+ mimeType: "image/jpeg",
130
+ quality: 0.85,
131
+ });
132
+ ```
133
+
134
+ | 参数 | 说明 | 默认值 |
135
+ |------|------|--------|
136
+ | `maxWidth` / `maxHeight` | 等比缩放上限 | 不限制 |
137
+ | `mimeType` | `"image/png"` 或 `"image/jpeg"` | `"image/png"` |
138
+ | `quality` | JPEG 质量 0–1 | `0.92` |
139
+ | `backgroundColor` | 背景色 | `"#ffffff"` |
140
+
55
141
  ## 核心导出
56
142
 
57
143
  | 导出 | 说明 |
@@ -59,8 +145,14 @@ const params = parseOnlinePreviewSearchParams("?preview=online&projectId=xxx");
59
145
  | `ReactViewPanel` | 主编辑面板(画布 + 配置侧栏 + 蓝图分屏) |
60
146
  | `ReactViewOnlinePreview` | 只读在线预览页 |
61
147
  | `parseOnlinePreviewSearchParams` | 解析预览 URL 参数 |
148
+ | `addEventSubscription` | 订阅工作区事件,返回 `{ unsubscribe }` |
149
+ | `AbuilderEvents` | 工作区事件名常量 |
150
+ | `getPreviewSnapshot` | 获取预览画面 base64 缩略图 |
151
+ | `WorkspaceData` / `WorkspaceProjectRecord` | 完整工作区数据类型 |
62
152
  | `createView` / `createAction` 等 | 视图 DSL 与指令扩展(`src/core`) |
63
153
 
154
+ 相关类型:`AbuilderEventName`、`AbuilderEventPayloadMap`、`WorkspaceAddEventPayload`、`WorkspaceSyncEventPayload`、`GetPreviewSnapshotOptions`。
155
+
64
156
  ## 样式与 Tailwind
65
157
 
66
158
  本包内置 Tailwind 配置,构建产物为 `dist/styles.css`:
package/src/index.ts CHANGED
@@ -4,4 +4,18 @@ export * from "./core/instructions";
4
4
  export * from "./core/nodePool";
5
5
  export * from "./core/signal";
6
6
  export * from "./core/template";
7
- export * from "./panel";
7
+ export * from "./panel";
8
+ export {
9
+ addEventSubscription,
10
+ AbuilderEvents,
11
+ } from "./panel/library/event-subscription";
12
+ export type {
13
+ AbuilderEventName,
14
+ AbuilderEventPayloadMap,
15
+ WorkspaceAddEventPayload,
16
+ WorkspaceSyncEventPayload,
17
+ WorkspaceData,
18
+ } from "./panel/library/event-subscription";
19
+ export type { WorkspaceProjectRecord } from "./panel/library/workspace-project-db";
20
+ export { getPreviewSnapshot } from "./panel/library/preview-snapshot";
21
+ export type { GetPreviewSnapshotOptions } from "./panel/library/preview-snapshot";
@@ -39,12 +39,17 @@ import {
39
39
  resolvePreviewLayerElements,
40
40
  } from "./utils/panelStateIO";
41
41
  import { applyPreviewSceneFill, readOutputScale } from "./utils/outputScale";
42
+ import { capturePreviewSceneElement } from "./library/capture-preview-snapshot";
43
+ import { registerPreviewSnapshotProvider } from "./library/preview-snapshot";
42
44
 
43
45
  const PREVIEW_BOOT_PHASES: PageLifecyclePhase[] = ["mounted"];
44
46
 
45
47
  export type ReactViewOnlinePreviewProps = {
46
- projectId: string;
48
+ /** 从 IndexedDB / 缓存按 ID 加载工作区;与 `workspace` 二选一,优先使用 `workspace` */
49
+ projectId?: string;
47
50
  previewInstanceId?: string;
51
+ /** 直接传入完整工作区数据,用于外部持久化后预览 */
52
+ workspace?: WorkspaceProjectRecord | null;
48
53
  };
49
54
 
50
55
  function applyTitleIcon(titleIconDataUrl?: string) {
@@ -68,6 +73,7 @@ async function loadWorkspaceRecord(projectId: string): Promise<WorkspaceProjectR
68
73
  export function ReactViewOnlinePreview({
69
74
  projectId,
70
75
  previewInstanceId,
76
+ workspace = null,
71
77
  }: ReactViewOnlinePreviewProps) {
72
78
  const { t } = useI18n();
73
79
 
@@ -84,53 +90,74 @@ export function ReactViewOnlinePreview({
84
90
  const [fillScale, setFillScale] = useState({ scaleX: 1, scaleY: 1 });
85
91
 
86
92
  const sceneRef = useRef<HTMLDivElement | null>(null);
87
-
88
- const layers = useMemo(
89
- () => (panelState ? parsePanelLayers(panelState) : []),
90
- [panelState]
91
- );
92
- const activeLayerId = useMemo(
93
- () => (panelState ? getActiveLayerId(panelState) : "layer-1"),
94
- [panelState]
95
- );
96
- const allElements = useMemo(
97
- () => (panelState ? parseAllPanelElements(panelState) : []),
98
- [panelState]
93
+ const resolvedProjectId = workspace?.id ?? projectId ?? "";
94
+
95
+ const applyWorkspaceRecord = useCallback(
96
+ (record: WorkspaceProjectRecord) => {
97
+ const normalized = normalizeImportedPanelState(record.panelState);
98
+ if (!normalized) {
99
+ setLoadError(t("panel.messages.workspaceDataInvalid"));
100
+ setPanelState(null);
101
+ return false;
102
+ }
103
+
104
+ setLoadError(null);
105
+ clearViewElementScopes();
106
+ setPanelState(normalized);
107
+ setBlueprintGraph(BlueprintGraph.fromDocument(record.blueprintDocument));
108
+ document.title =
109
+ record.productName.trim() || record.name || t("panel.workspace.previewDocTitle");
110
+ applyTitleIcon(record.titleIconDataUrl);
111
+ setProjectRevision((v) => v + 1);
112
+ return true;
113
+ },
114
+ [t]
99
115
  );
100
116
 
101
117
  const loadProject = useCallback(async () => {
102
- const record = await loadWorkspaceRecord(projectId);
103
- if (!record) {
118
+ if (workspace) {
119
+ applyWorkspaceRecord(workspace);
120
+ return;
121
+ }
122
+ if (!projectId) {
104
123
  setLoadError(t("panel.messages.workspaceNotFound"));
105
124
  setPanelState(null);
106
125
  return;
107
126
  }
108
127
 
109
- const normalized = normalizeImportedPanelState(record.panelState);
110
- if (!normalized) {
111
- setLoadError(t("panel.messages.workspaceDataInvalid"));
128
+ const record = await loadWorkspaceRecord(projectId);
129
+ if (!record) {
130
+ setLoadError(t("panel.messages.workspaceNotFound"));
112
131
  setPanelState(null);
113
132
  return;
114
133
  }
115
134
 
116
- setLoadError(null);
117
- clearViewElementScopes();
118
- setPanelState(normalized);
119
- setBlueprintGraph(BlueprintGraph.fromDocument(record.blueprintDocument));
120
- document.title = record.productName.trim() || record.name || t("panel.workspace.previewDocTitle");
121
- applyTitleIcon(record.titleIconDataUrl);
122
- setProjectRevision((v) => v + 1);
123
- }, [projectId]);
135
+ applyWorkspaceRecord(record);
136
+ }, [applyWorkspaceRecord, projectId, t, workspace]);
124
137
 
125
138
  useEffect(() => {
126
139
  void loadProject();
127
140
  }, [loadProject]);
128
141
 
129
142
  useEffect(() => {
143
+ if (workspace || !projectId) return;
130
144
  return subscribeWorkspaceProjectUpdates(projectId, () => {
131
145
  void loadProject();
132
146
  });
133
- }, [loadProject, projectId]);
147
+ }, [loadProject, projectId, workspace]);
148
+
149
+ const layers = useMemo(
150
+ () => (panelState ? parsePanelLayers(panelState) : []),
151
+ [panelState]
152
+ );
153
+ const activeLayerId = useMemo(
154
+ () => (panelState ? getActiveLayerId(panelState) : "layer-1"),
155
+ [panelState]
156
+ );
157
+ const allElements = useMemo(
158
+ () => (panelState ? parseAllPanelElements(panelState) : []),
159
+ [panelState]
160
+ );
134
161
 
135
162
  useEffect(() => {
136
163
  let cancelled = false;
@@ -270,6 +297,33 @@ export function ReactViewOnlinePreview({
270
297
  return () => window.removeEventListener("resize", applySceneFit);
271
298
  }, [applySceneFit]);
272
299
 
300
+ useEffect(() => {
301
+ if (!layoutReady || !sceneRef.current) {
302
+ registerPreviewSnapshotProvider(null);
303
+ return;
304
+ }
305
+ registerPreviewSnapshotProvider(async () => {
306
+ const scene = sceneRef.current;
307
+ if (!scene) return null;
308
+ const width = outputScale
309
+ ? sceneBounds.width
310
+ : sceneBounds.width * fillScale.scaleX;
311
+ const height = outputScale
312
+ ? sceneBounds.height
313
+ : sceneBounds.height * fillScale.scaleY;
314
+ return capturePreviewSceneElement(scene, width, height);
315
+ });
316
+ return () => registerPreviewSnapshotProvider(null);
317
+ }, [
318
+ fillScale.scaleX,
319
+ fillScale.scaleY,
320
+ layoutReady,
321
+ layoutRevision,
322
+ outputScale,
323
+ sceneBounds.height,
324
+ sceneBounds.width,
325
+ ]);
326
+
273
327
  const noopUpdate = useCallback((_id: string, _patch: Partial<PanelElement>) => {}, []);
274
328
  const noopSelect = useCallback((_ids: string[]) => {}, []);
275
329
  const onTableCellAction = useCallback<TableCellActionHandler>(
@@ -333,7 +387,7 @@ export function ReactViewOnlinePreview({
333
387
  <div
334
388
  className="min-h-[100vh] w-full overflow-hidden bg-white text-gray-900"
335
389
  data-preview-mode="online"
336
- data-project-id={projectId}
390
+ data-project-id={resolvedProjectId}
337
391
  data-preview-instance-id={previewInstanceId ?? ""}
338
392
  data-preview-node-count={String(displayElements.length)}
339
393
  >
@@ -16,6 +16,10 @@ import { SelectLayer } from "./components/SelectLayer";
16
16
  import { MoveableLayer } from "./components/MoveableLayer";
17
17
  import { buildChartOption, CHART_TYPES } from "./utils/chartOptionBuilder";
18
18
  import { readOutputScale, writeOutputScale } from "./utils/outputScale";
19
+ import {
20
+ captureEditorPreviewSnapshot,
21
+ registerPreviewSnapshotProvider,
22
+ } from "./library/preview-snapshot";
19
23
  import {
20
24
  assertFileSize,
21
25
  parseJsonText,
@@ -356,11 +360,14 @@ function shouldClearSelectionOnBlank(target: HTMLElement | null) {
356
360
  export type ReactViewPanelProps = {
357
361
  initialZoom?: number;
358
362
  className?: string;
363
+ /** 外部传入的完整工作区数据,挂载后自动渲染 */
364
+ initialWorkspace?: WorkspaceProjectRecord | null;
359
365
  };
360
366
 
361
367
  export function ReactViewPanel({
362
368
  initialZoom = 1,
363
369
  className,
370
+ initialWorkspace = null,
364
371
  }: ReactViewPanelProps) {
365
372
  const { t, locale, setLocale } = useI18n();
366
373
  const messages = useMemo(() => getPanelMessages(t), [t]);
@@ -1253,8 +1260,22 @@ export function ReactViewPanel({
1253
1260
  setTitleIconDataUrl,
1254
1261
  panelRevision: `${historyCursor}|${allElements.length}`,
1255
1262
  onProjectApplied: handleWorkspaceProjectApplied,
1263
+ initialWorkspace,
1256
1264
  });
1257
1265
 
1266
+ useEffect(() => {
1267
+ registerPreviewSnapshotProvider(async () => {
1268
+ return captureEditorPreviewSnapshot({
1269
+ canvasRoot: canvasRef.current,
1270
+ allElements,
1271
+ layers,
1272
+ activeLayerId,
1273
+ outputScale: readOutputScale(),
1274
+ });
1275
+ });
1276
+ return () => registerPreviewSnapshotProvider(null);
1277
+ }, [activeLayerId, allElements, canvasEl, layers]);
1278
+
1258
1279
  const { triggerBlueprintNode, emitViewEvent, firedLifecyclePhases } = useBlueprintPageLifecycle({
1259
1280
  graph: blueprintGraph,
1260
1281
  active: blueprintOpen,
@@ -20,6 +20,7 @@ import {
20
20
  type WorkspaceSnapshot,
21
21
  } from "../library/workspace-snapshot";
22
22
  import { runBusyTask } from "../utils/async-work";
23
+ import { AbuilderEvents, emitAbuilderEvent } from "../library/event-subscription";
23
24
 
24
25
  export type UseWorkspaceProjectsOptions = {
25
26
  exportPanelData: () => State;
@@ -36,6 +37,8 @@ export type UseWorkspaceProjectsOptions = {
36
37
  panelRevision: unknown;
37
38
  /** 工作区记录应用到编辑器后的回调(用于恢复蓝图面板等 UI 状态) */
38
39
  onProjectApplied?: (record: WorkspaceProjectRecord) => void;
40
+ /** 外部传入的初始工作区,挂载后自动加载(不触发 onProjectApplied) */
41
+ initialWorkspace?: WorkspaceProjectRecord | null;
39
42
  };
40
43
 
41
44
  export function buildOnlinePreviewUrl(projectId: string): string {
@@ -64,6 +67,7 @@ export function useWorkspaceProjects({
64
67
  setTitleIconDataUrl,
65
68
  panelRevision,
66
69
  onProjectApplied,
70
+ initialWorkspace,
67
71
  }: UseWorkspaceProjectsOptions) {
68
72
  const { t } = useI18nOptional();
69
73
  const [projects, setProjects] = useState<WorkspaceProjectListItem[]>([]);
@@ -138,7 +142,7 @@ export function useWorkspaceProjects({
138
142
  ]);
139
143
 
140
144
  const applyProjectRecord = useCallback(
141
- (record: WorkspaceProjectRecord) => {
145
+ (record: WorkspaceProjectRecord, options?: { notifyApplied?: boolean }) => {
142
146
  const blueprintDocument =
143
147
  record.blueprintDocument ?? BlueprintGraph.empty().document;
144
148
  const blueprintMeta: BlueprintMetaDraft = {
@@ -161,11 +165,13 @@ export function useWorkspaceProjects({
161
165
  });
162
166
  syncedPanelRevisionRef.current = panelRevision;
163
167
  pendingRevisionSyncRef.current = true;
164
- onProjectApplied?.({
165
- ...record,
166
- blueprintDocument,
167
- blueprintMeta,
168
- });
168
+ if (options?.notifyApplied !== false) {
169
+ onProjectApplied?.({
170
+ ...record,
171
+ blueprintDocument,
172
+ blueprintMeta,
173
+ });
174
+ }
169
175
  },
170
176
  [
171
177
  importPanelData,
@@ -179,6 +185,15 @@ export function useWorkspaceProjects({
179
185
  ]
180
186
  );
181
187
 
188
+ const initialWorkspaceAppliedKeyRef = useRef<string | null>(null);
189
+ useEffect(() => {
190
+ if (!initialWorkspace?.id) return;
191
+ const key = `${initialWorkspace.id}:${initialWorkspace.updatedAt}`;
192
+ if (initialWorkspaceAppliedKeyRef.current === key) return;
193
+ initialWorkspaceAppliedKeyRef.current = key;
194
+ applyProjectRecord(initialWorkspace, { notifyApplied: false });
195
+ }, [applyProjectRecord, initialWorkspace]);
196
+
182
197
  const persistProject = useCallback(
183
198
  async (options: { id?: string; name: string; createdAt?: number }) => {
184
199
  const snapshot = cloneWorkspaceSnapshot(buildCurrentSnapshot());
@@ -217,6 +232,7 @@ export function useWorkspaceProjects({
217
232
  return runBusyTask(t("common.savingWorkspace"), async () => {
218
233
  const name = resolveProjectName();
219
234
  const record = await persistProject({ name });
235
+ emitAbuilderEvent(AbuilderEvents.workspaceAdd, record);
220
236
  return { name, id: record.id };
221
237
  });
222
238
  }, [persistProject, resolveProjectName, t]);
@@ -265,11 +281,12 @@ export function useWorkspaceProjects({
265
281
  throw new Error(t("panel.messages.workspaceNotFound"));
266
282
  }
267
283
  const name = resolveProjectName() || existing.name;
268
- await persistProject({
284
+ const record = await persistProject({
269
285
  id: activeProjectId,
270
286
  name,
271
287
  createdAt: existing.createdAt,
272
288
  });
289
+ emitAbuilderEvent(AbuilderEvents.workspaceSync, record);
273
290
  return name;
274
291
  });
275
292
  }, [activeProjectId, persistProject, refreshProjects, resolveProjectName, t]);
@@ -0,0 +1,300 @@
1
+ const STYLE_PROPS = [
2
+ "display",
3
+ "position",
4
+ "left",
5
+ "top",
6
+ "width",
7
+ "height",
8
+ "margin",
9
+ "padding",
10
+ "border",
11
+ "borderRadius",
12
+ "background",
13
+ "backgroundColor",
14
+ "backgroundImage",
15
+ "backgroundSize",
16
+ "backgroundPosition",
17
+ "backgroundRepeat",
18
+ "color",
19
+ "fontFamily",
20
+ "fontSize",
21
+ "fontWeight",
22
+ "fontStyle",
23
+ "lineHeight",
24
+ "textAlign",
25
+ "textDecoration",
26
+ "letterSpacing",
27
+ "wordBreak",
28
+ "overflow",
29
+ "opacity",
30
+ "boxSizing",
31
+ "flexDirection",
32
+ "alignItems",
33
+ "justifyContent",
34
+ "gap",
35
+ "gridTemplateColumns",
36
+ "gridTemplateRows",
37
+ "objectFit",
38
+ "filter",
39
+ "boxShadow",
40
+ "transform",
41
+ "transformOrigin",
42
+ "zIndex",
43
+ ] as const;
44
+
45
+ function loadImage(src: string): Promise<HTMLImageElement> {
46
+ return new Promise((resolve, reject) => {
47
+ const img = new Image();
48
+ img.onload = () => resolve(img);
49
+ img.onerror = () => reject(new Error("image-load-failed"));
50
+ img.src = src;
51
+ });
52
+ }
53
+
54
+ function inlineComputedStyles(source: Element, target: Element) {
55
+ if (!(source instanceof HTMLElement) || !(target instanceof HTMLElement)) return;
56
+ const computed = window.getComputedStyle(source);
57
+ for (const prop of STYLE_PROPS) {
58
+ const value = computed.getPropertyValue(
59
+ prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)
60
+ );
61
+ if (value) {
62
+ target.style.setProperty(
63
+ prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`),
64
+ value
65
+ );
66
+ }
67
+ }
68
+ const sourceChildren = Array.from(source.children);
69
+ const targetChildren = Array.from(target.children);
70
+ sourceChildren.forEach((child, index) => {
71
+ const targetChild = targetChildren[index];
72
+ if (child && targetChild) inlineComputedStyles(child, targetChild);
73
+ });
74
+ }
75
+
76
+ function replaceCanvasesInClone(source: Element, clone: Element) {
77
+ const sourceCanvases = Array.from(source.querySelectorAll("canvas"));
78
+ const cloneCanvases = Array.from(clone.querySelectorAll("canvas"));
79
+ sourceCanvases.forEach((srcCanvas, index) => {
80
+ const clonedCanvas = cloneCanvases[index];
81
+ if (!clonedCanvas) return;
82
+ try {
83
+ const dataUrl = srcCanvas.toDataURL("image/png");
84
+ const img = document.createElement("img");
85
+ img.src = dataUrl;
86
+ img.style.width = "100%";
87
+ img.style.height = "100%";
88
+ img.style.objectFit = "fill";
89
+ img.style.display = "block";
90
+ clonedCanvas.replaceWith(img);
91
+ } catch {
92
+ clonedCanvas.remove();
93
+ }
94
+ });
95
+ }
96
+
97
+ async function nodeToDataUrlViaSvg(node: HTMLElement, width: number, height: number): Promise<string> {
98
+ const clone = node.cloneNode(true) as HTMLElement;
99
+ inlineComputedStyles(node, clone);
100
+ replaceCanvasesInClone(node, clone);
101
+ clone.style.width = `${width}px`;
102
+ clone.style.height = `${height}px`;
103
+ clone.style.margin = "0";
104
+ clone.style.position = "relative";
105
+ clone.style.left = "0";
106
+ clone.style.top = "0";
107
+ clone.style.transform = "none";
108
+
109
+ const wrapper = document.createElement("div");
110
+ wrapper.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
111
+ wrapper.style.width = `${width}px`;
112
+ wrapper.style.height = `${height}px`;
113
+ wrapper.style.overflow = "hidden";
114
+ wrapper.style.background = "#ffffff";
115
+ wrapper.appendChild(clone);
116
+
117
+ const serialized = new XMLSerializer().serializeToString(wrapper);
118
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
119
+ <foreignObject width="100%" height="100%">
120
+ ${serialized}
121
+ </foreignObject>
122
+ </svg>`;
123
+ const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
124
+ const url = URL.createObjectURL(blob);
125
+ try {
126
+ const img = await loadImage(url);
127
+ const canvas = document.createElement("canvas");
128
+ canvas.width = width;
129
+ canvas.height = height;
130
+ const ctx = canvas.getContext("2d");
131
+ if (!ctx) throw new Error("canvas-context-failed");
132
+ ctx.drawImage(img, 0, 0);
133
+ return canvas.toDataURL("image/png");
134
+ } finally {
135
+ URL.revokeObjectURL(url);
136
+ }
137
+ }
138
+
139
+ async function drawNodeOntoCanvas(
140
+ ctx: CanvasRenderingContext2D,
141
+ node: HTMLElement,
142
+ x: number,
143
+ y: number,
144
+ width: number,
145
+ height: number,
146
+ rotateDeg: number
147
+ ) {
148
+ const w = Math.max(1, width);
149
+ const h = Math.max(1, height);
150
+ let source: CanvasImageSource | null = null;
151
+
152
+ const canvases = Array.from(node.querySelectorAll("canvas"));
153
+ if (canvases.length === 1) {
154
+ source = canvases[0]!;
155
+ } else if (canvases.length > 1) {
156
+ const merged = document.createElement("canvas");
157
+ merged.width = w;
158
+ merged.height = h;
159
+ const mctx = merged.getContext("2d");
160
+ if (mctx) {
161
+ for (const canvas of canvases) {
162
+ try {
163
+ mctx.drawImage(canvas, 0, 0, w, h);
164
+ } catch {
165
+ // ignore tainted canvas
166
+ }
167
+ }
168
+ source = merged;
169
+ }
170
+ } else {
171
+ const imgs = Array.from(node.querySelectorAll("img"));
172
+ if (imgs.length === 1) {
173
+ source = imgs[0]!;
174
+ } else if (imgs.length > 1) {
175
+ const merged = document.createElement("canvas");
176
+ merged.width = w;
177
+ merged.height = h;
178
+ const mctx = merged.getContext("2d");
179
+ if (mctx) {
180
+ for (const img of imgs) {
181
+ try {
182
+ mctx.drawImage(img, 0, 0, w, h);
183
+ } catch {
184
+ // ignore
185
+ }
186
+ }
187
+ source = merged;
188
+ }
189
+ }
190
+ }
191
+
192
+ let image: HTMLImageElement;
193
+ if (source) {
194
+ if (source instanceof HTMLImageElement) {
195
+ image = source;
196
+ if (!image.complete) await loadImage(image.src);
197
+ } else {
198
+ const dataUrl =
199
+ source instanceof HTMLCanvasElement
200
+ ? source.toDataURL("image/png")
201
+ : "";
202
+ image = await loadImage(dataUrl);
203
+ }
204
+ } else {
205
+ const dataUrl = await nodeToDataUrlViaSvg(node, w, h);
206
+ image = await loadImage(dataUrl);
207
+ }
208
+
209
+ ctx.save();
210
+ ctx.translate(x + w / 2, y + h / 2);
211
+ ctx.rotate((rotateDeg * Math.PI) / 180);
212
+ ctx.drawImage(image, -w / 2, -h / 2, w, h);
213
+ ctx.restore();
214
+ }
215
+
216
+ export type CapturePreviewSceneOptions = {
217
+ backgroundColor?: string;
218
+ maxWidth?: number;
219
+ maxHeight?: number;
220
+ mimeType?: "image/png" | "image/jpeg";
221
+ quality?: number;
222
+ };
223
+
224
+ export async function capturePreviewSceneElement(
225
+ scene: HTMLElement,
226
+ sceneWidth: number,
227
+ sceneHeight: number,
228
+ options?: CapturePreviewSceneOptions
229
+ ): Promise<string> {
230
+ const width = Math.max(1, Math.round(sceneWidth));
231
+ const height = Math.max(1, Math.round(sceneHeight));
232
+ const canvas = document.createElement("canvas");
233
+ canvas.width = width;
234
+ canvas.height = height;
235
+ const ctx = canvas.getContext("2d");
236
+ if (!ctx) throw new Error("canvas-context-failed");
237
+
238
+ ctx.fillStyle = options?.backgroundColor ?? "#ffffff";
239
+ ctx.fillRect(0, 0, width, height);
240
+
241
+ const nodes = Array.from(scene.querySelectorAll<HTMLElement>("[data-element-id]"));
242
+ const sorted = nodes
243
+ .map((node) => {
244
+ const zIndex = Number.parseInt(window.getComputedStyle(node).zIndex || "1", 10);
245
+ return { node, zIndex: Number.isFinite(zIndex) ? zIndex : 1 };
246
+ })
247
+ .sort((a, b) => a.zIndex - b.zIndex);
248
+
249
+ for (const { node } of sorted) {
250
+ const left = Number.parseFloat(node.style.left || "0") || 0;
251
+ const top = Number.parseFloat(node.style.top || "0") || 0;
252
+ const nodeWidth = Number.parseFloat(node.style.width || "") || node.offsetWidth || 1;
253
+ const nodeHeight = Number.parseFloat(node.style.height || "") || node.offsetHeight || 1;
254
+ const transform = node.style.transform || "";
255
+ const rotateMatch = /rotate\(([-\d.]+)deg\)/.exec(transform);
256
+ const rotateDeg = rotateMatch ? Number.parseFloat(rotateMatch[1]!) : 0;
257
+ await drawNodeOntoCanvas(ctx, node, left, top, nodeWidth, nodeHeight, rotateDeg);
258
+ }
259
+
260
+ const mimeType = options?.mimeType ?? "image/png";
261
+ const quality = options?.quality ?? 0.92;
262
+ let dataUrl = canvas.toDataURL(mimeType, quality);
263
+
264
+ const maxWidth = options?.maxWidth;
265
+ const maxHeight = options?.maxHeight;
266
+ if (maxWidth || maxHeight) {
267
+ dataUrl = await scaleDataUrl(dataUrl, maxWidth, maxHeight, mimeType, quality);
268
+ }
269
+ return dataUrl;
270
+ }
271
+
272
+ export async function scaleDataUrl(
273
+ dataUrl: string,
274
+ maxWidth?: number,
275
+ maxHeight?: number,
276
+ mimeType: "image/png" | "image/jpeg" = "image/png",
277
+ quality = 0.92
278
+ ): Promise<string> {
279
+ if (!maxWidth && !maxHeight) return dataUrl;
280
+ const img = await loadImage(dataUrl);
281
+ const ratio = img.width / img.height;
282
+ let targetWidth = img.width;
283
+ let targetHeight = img.height;
284
+ if (maxWidth && targetWidth > maxWidth) {
285
+ targetWidth = maxWidth;
286
+ targetHeight = Math.round(targetWidth / ratio);
287
+ }
288
+ if (maxHeight && targetHeight > maxHeight) {
289
+ targetHeight = maxHeight;
290
+ targetWidth = Math.round(targetHeight * ratio);
291
+ }
292
+ if (targetWidth === img.width && targetHeight === img.height) return dataUrl;
293
+ const canvas = document.createElement("canvas");
294
+ canvas.width = targetWidth;
295
+ canvas.height = targetHeight;
296
+ const ctx = canvas.getContext("2d");
297
+ if (!ctx) return dataUrl;
298
+ ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
299
+ return canvas.toDataURL(mimeType, quality);
300
+ }
@@ -0,0 +1,60 @@
1
+ import type { WorkspaceProjectRecord } from "./workspace-project-db";
2
+
3
+ export const AbuilderEvents = {
4
+ /** 添加工作区 */
5
+ workspaceAdd: "workspace:add",
6
+ /** 同步工作区 */
7
+ workspaceSync: "workspace:sync",
8
+ } as const;
9
+
10
+ export type AbuilderEventName = (typeof AbuilderEvents)[keyof typeof AbuilderEvents];
11
+
12
+ /** 工作区完整数据(面板 + 蓝图),可用于外部持久化与再次加载 */
13
+ export type WorkspaceData = WorkspaceProjectRecord;
14
+
15
+ export type WorkspaceAddEventPayload = WorkspaceData;
16
+ export type WorkspaceSyncEventPayload = WorkspaceData;
17
+
18
+ export type AbuilderEventPayloadMap = {
19
+ [AbuilderEvents.workspaceAdd]: WorkspaceAddEventPayload;
20
+ [AbuilderEvents.workspaceSync]: WorkspaceSyncEventPayload;
21
+ };
22
+
23
+ type EventCallback = (payload: unknown) => void;
24
+
25
+ const listeners = new Map<string, Set<EventCallback>>();
26
+
27
+ export function emitAbuilderEvent<T extends AbuilderEventName>(
28
+ eventName: T,
29
+ payload: AbuilderEventPayloadMap[T]
30
+ ): void {
31
+ const set = listeners.get(eventName);
32
+ if (!set) return;
33
+ for (const callback of set) {
34
+ try {
35
+ callback(payload);
36
+ } catch (error) {
37
+ console.error(`[abuilder] event handler error (${eventName}):`, error);
38
+ }
39
+ }
40
+ }
41
+
42
+ export function addEventSubscription<T extends AbuilderEventName>(
43
+ eventName: T,
44
+ callback: (payload: AbuilderEventPayloadMap[T]) => void
45
+ ): { unsubscribe: () => void } {
46
+ let set = listeners.get(eventName);
47
+ if (!set) {
48
+ set = new Set();
49
+ listeners.set(eventName, set);
50
+ }
51
+ set.add(callback as EventCallback);
52
+ return {
53
+ unsubscribe: () => {
54
+ set.delete(callback as EventCallback);
55
+ if (set.size === 0) {
56
+ listeners.delete(eventName);
57
+ }
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,238 @@
1
+ import type { PanelElement, PanelLayer } from "../types";
2
+ import { CHART_TYPES, buildChartOption } from "../utils/chartOptionBuilder";
3
+ import {
4
+ computePanelSceneBounds,
5
+ resolvePreviewLayerElements,
6
+ } from "../utils/panelStateIO";
7
+ import {
8
+ capturePreviewSceneElement,
9
+ scaleDataUrl,
10
+ type CapturePreviewSceneOptions,
11
+ } from "./capture-preview-snapshot";
12
+
13
+ export type GetPreviewSnapshotOptions = CapturePreviewSceneOptions;
14
+
15
+ type PreviewSnapshotProvider = () => Promise<string | null>;
16
+
17
+ let previewSnapshotProvider: PreviewSnapshotProvider | null = null;
18
+
19
+ export function registerPreviewSnapshotProvider(provider: PreviewSnapshotProvider | null) {
20
+ previewSnapshotProvider = provider;
21
+ }
22
+
23
+ function serializeNodeDom(
24
+ sourceNode: HTMLElement,
25
+ sourceElement: PanelElement,
26
+ minX: number,
27
+ minY: number,
28
+ isChartNode: boolean
29
+ ): string {
30
+ const clone = sourceNode.cloneNode(true) as HTMLElement;
31
+ clone.className = clone.className
32
+ .split(/\s+/)
33
+ .filter((cls) => cls && !cls.startsWith("ring-"))
34
+ .join(" ");
35
+ clone.removeAttribute("data-moveable-target");
36
+ clone.style.left = `${sourceElement.x - minX}px`;
37
+ clone.style.top = `${sourceElement.y - minY}px`;
38
+ const sourceCanvases = Array.from(sourceNode.querySelectorAll("canvas"));
39
+ const cloneCanvases = Array.from(clone.querySelectorAll("canvas"));
40
+ if (isChartNode) return clone.outerHTML;
41
+ sourceCanvases.forEach((srcCanvas, idx) => {
42
+ const clonedCanvas = cloneCanvases[idx];
43
+ if (!clonedCanvas) return;
44
+ try {
45
+ const dataUrl = srcCanvas.toDataURL("image/png");
46
+ const img = document.createElement("img");
47
+ img.src = dataUrl;
48
+ img.style.width = "100%";
49
+ img.style.height = "100%";
50
+ img.style.objectFit = "fill";
51
+ img.style.display = "block";
52
+ clonedCanvas.replaceWith(img);
53
+ } catch {
54
+ // ignore tainted canvas
55
+ }
56
+ });
57
+ return clone.outerHTML;
58
+ }
59
+
60
+ function waitFrames(count = 2): Promise<void> {
61
+ return new Promise((resolve) => {
62
+ let remaining = count;
63
+ const step = () => {
64
+ remaining -= 1;
65
+ if (remaining <= 0) resolve();
66
+ else requestAnimationFrame(step);
67
+ };
68
+ requestAnimationFrame(step);
69
+ });
70
+ }
71
+
72
+ export type BuildEditorPreviewSnapshotSceneOptions = {
73
+ canvasRoot: HTMLElement | null;
74
+ allElements: PanelElement[];
75
+ layers: PanelLayer[];
76
+ activeLayerId: string;
77
+ outputScale: boolean;
78
+ retryCount?: number;
79
+ };
80
+
81
+ export async function buildEditorPreviewSnapshotScene(
82
+ options: BuildEditorPreviewSnapshotSceneOptions
83
+ ): Promise<{ scene: HTMLElement; width: number; height: number } | null> {
84
+ const retryCount = options.retryCount ?? 0;
85
+ const layerElements = resolvePreviewLayerElements(
86
+ options.allElements,
87
+ options.layers,
88
+ options.activeLayerId
89
+ );
90
+ if (!layerElements.length) return null;
91
+
92
+ const sceneBounds = computePanelSceneBounds(layerElements);
93
+ const mountedNodes = layerElements.map((el) => ({
94
+ el,
95
+ node:
96
+ options.canvasRoot?.querySelector<HTMLElement>(`[data-element-id="${el.id}"]`) ??
97
+ null,
98
+ }));
99
+ const mountedCount = mountedNodes.filter((item) => !!item.node).length;
100
+ if (mountedCount < layerElements.length && retryCount < 20) {
101
+ await new Promise((resolve) => window.setTimeout(resolve, 80));
102
+ return buildEditorPreviewSnapshotScene({ ...options, retryCount: retryCount + 1 });
103
+ }
104
+
105
+ const previewNodesHtml = mountedNodes
106
+ .map((item) => {
107
+ if (!item.node) return "";
108
+ return serializeNodeDom(
109
+ item.node,
110
+ item.el,
111
+ sceneBounds.minX,
112
+ sceneBounds.minY,
113
+ CHART_TYPES.has(item.el.materialType ?? "")
114
+ );
115
+ })
116
+ .filter(Boolean)
117
+ .join("");
118
+ if (!previewNodesHtml && retryCount < 20) {
119
+ await new Promise((resolve) => window.setTimeout(resolve, 80));
120
+ return buildEditorPreviewSnapshotScene({ ...options, retryCount: retryCount + 1 });
121
+ }
122
+ if (!previewNodesHtml) return null;
123
+
124
+ const host = document.createElement("div");
125
+ host.setAttribute("data-preview-snapshot-host", "true");
126
+ host.style.position = "fixed";
127
+ host.style.left = "-100000px";
128
+ host.style.top = "0";
129
+ host.style.width = `${sceneBounds.width}px`;
130
+ host.style.height = `${sceneBounds.height}px`;
131
+ host.style.overflow = "hidden";
132
+ host.style.pointerEvents = "none";
133
+ host.style.background = "#ffffff";
134
+
135
+ const scene = document.createElement("div");
136
+ scene.id = "preview-scene";
137
+ scene.className = "relative shrink-0 origin-top-left";
138
+ scene.style.width = `${sceneBounds.width}px`;
139
+ scene.style.height = `${sceneBounds.height}px`;
140
+ scene.style.transformOrigin = "left top";
141
+ scene.innerHTML = previewNodesHtml;
142
+ host.appendChild(scene);
143
+ document.body.appendChild(host);
144
+
145
+ const gridNodeIds = layerElements
146
+ .filter((el) => el.materialType === "grid")
147
+ .map((el) => el.id);
148
+ for (const id of gridNodeIds) {
149
+ const gridNode = scene.querySelector<HTMLElement>(`[data-element-id="${id}"]`);
150
+ const content = gridNode?.firstElementChild as HTMLElement | null;
151
+ if (content) {
152
+ content.innerHTML = "";
153
+ content.removeAttribute("style");
154
+ }
155
+ }
156
+
157
+ const chartNodes = layerElements.filter((el) =>
158
+ CHART_TYPES.has(el.materialType ?? "")
159
+ );
160
+ for (const el of chartNodes) {
161
+ const hostEl = scene.querySelector<HTMLElement>(`[data-element-id="${el.id}"] > div`);
162
+ if (!hostEl) continue;
163
+ hostEl.innerHTML = "";
164
+ const chart = (
165
+ await import("echarts")
166
+ ).init(hostEl, null, { renderer: el.chart?.renderMode ?? "canvas" });
167
+ chart.setOption(buildChartOption(el) ?? {}, true);
168
+ chart.resize();
169
+ }
170
+
171
+ await waitFrames(2);
172
+ return { scene, width: sceneBounds.width, height: sceneBounds.height };
173
+ }
174
+
175
+ export function disposePreviewSnapshotScene(scene: HTMLElement) {
176
+ const host = scene.parentElement;
177
+ host?.remove();
178
+ }
179
+
180
+ async function finalizePreviewSnapshot(
181
+ dataUrl: string,
182
+ options?: GetPreviewSnapshotOptions
183
+ ): Promise<string> {
184
+ if (!options) return dataUrl;
185
+ const mimeType = options.mimeType ?? "image/png";
186
+ const quality = options.quality ?? 0.92;
187
+ if (options.maxWidth || options.maxHeight) {
188
+ return scaleDataUrl(dataUrl, options.maxWidth, options.maxHeight, mimeType, quality);
189
+ }
190
+ if (mimeType === "image/jpeg" && !dataUrl.startsWith("data:image/jpeg")) {
191
+ return scaleDataUrl(dataUrl, undefined, undefined, mimeType, quality);
192
+ }
193
+ return dataUrl;
194
+ }
195
+
196
+ /**
197
+ * 获取当前预览画面的 base64 图片(data URL),可用于工作区缩略图等场景。
198
+ * 优先使用编辑器或在线预览页注册的截图提供者,其次尝试页面上的 `#preview-scene`。
199
+ */
200
+ export async function getPreviewSnapshot(
201
+ options?: GetPreviewSnapshotOptions
202
+ ): Promise<string> {
203
+ let dataUrl: string | null = null;
204
+ if (previewSnapshotProvider) {
205
+ dataUrl = await previewSnapshotProvider();
206
+ }
207
+
208
+ if (!dataUrl) {
209
+ const scene = document.getElementById("preview-scene");
210
+ if (scene instanceof HTMLElement) {
211
+ const width = scene.offsetWidth || Number.parseFloat(scene.style.width) || 1;
212
+ const height = scene.offsetHeight || Number.parseFloat(scene.style.height) || 1;
213
+ return capturePreviewSceneElement(scene, width, height, options);
214
+ }
215
+ throw new Error(
216
+ "Preview snapshot is unavailable. Mount the editor or open online preview first."
217
+ );
218
+ }
219
+
220
+ return finalizePreviewSnapshot(dataUrl, options);
221
+ }
222
+
223
+ export async function captureEditorPreviewSnapshot(
224
+ options: BuildEditorPreviewSnapshotSceneOptions & GetPreviewSnapshotOptions
225
+ ): Promise<string | null> {
226
+ const built = await buildEditorPreviewSnapshotScene(options);
227
+ if (!built) return null;
228
+ try {
229
+ return await capturePreviewSceneElement(
230
+ built.scene,
231
+ built.width,
232
+ built.height,
233
+ options
234
+ );
235
+ } finally {
236
+ disposePreviewSnapshotScene(built.scene);
237
+ }
238
+ }