@arronqzy/react-view 1.0.15 → 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 +3 -3
- package/readme.md +94 -2
- package/src/index.ts +15 -1
- package/src/panel/ReactViewOnlinePreview.tsx +82 -28
- package/src/panel/ReactViewPanel.tsx +21 -0
- package/src/panel/hooks/useWorkspaceProjects.ts +24 -7
- package/src/panel/library/capture-preview-snapshot.ts +300 -0
- package/src/panel/library/event-subscription.ts +60 -0
- package/src/panel/library/preview-snapshot.ts +238 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arronqzy/react-view",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.16",
|
|
4
4
|
"files": [
|
|
5
5
|
"src",
|
|
6
6
|
"dist",
|
|
@@ -34,10 +34,10 @@
|
|
|
34
34
|
"@arronqzy/blueprint-dsl": "1.0.8",
|
|
35
35
|
"@arronqzy/i18n": "0.1.7",
|
|
36
36
|
"@arronqzy/react-blueprint": "1.0.9",
|
|
37
|
-
"@arronqzy/view-scene3d": "0.1.2",
|
|
38
37
|
"@arronqzy/rx-store": "1.0.5",
|
|
38
|
+
"@arronqzy/webllm-assistant": "0.1.9",
|
|
39
39
|
"@arronqzy/view-table": "0.1.5",
|
|
40
|
-
"@arronqzy/
|
|
40
|
+
"@arronqzy/view-scene3d": "0.1.2"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/react": "^19.0.1",
|
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
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|
110
|
-
if (!
|
|
111
|
-
setLoadError(t("panel.messages.
|
|
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
|
-
|
|
117
|
-
|
|
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={
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
}
|