Răsfoiți Sursa

feat: flow download plugin support both fixed & free layout (#1004)

* feat: add workflow export image functionality with PNG/JPEG/SVG support

* feat: create new download plugin package

* feat(download-plugin): add workflow export functionality for multiple formats

* feat(demo): integrate download plugin for export functionality

* feat(download): add PNG/JPEG/SVG export support for fixed-layout
Louis Young 1 lună în urmă
părinte
comite
aaa786a666
33 a modificat fișierele cu 1036 adăugiri și 59 ștergeri
  1. 35 35
      .github/workflows/ci.yml
  2. 1 0
      apps/demo-fixed-layout/README.md
  3. 4 3
      apps/demo-fixed-layout/README.zh_CN.md
  4. 1 0
      apps/demo-fixed-layout/package.json
  5. 102 0
      apps/demo-fixed-layout/src/components/tools/download.tsx
  6. 2 0
      apps/demo-fixed-layout/src/components/tools/index.tsx
  7. 6 0
      apps/demo-fixed-layout/src/hooks/use-editor-props.ts
  8. 1 0
      apps/demo-free-layout/README.md
  9. 7 6
      apps/demo-free-layout/README.zh_CN.md
  10. 1 0
      apps/demo-free-layout/package.json
  11. 9 7
      apps/demo-free-layout/src/components/problem-panel/problem-panel.tsx
  12. 102 0
      apps/demo-free-layout/src/components/tools/download.tsx
  13. 2 0
      apps/demo-free-layout/src/components/tools/index.tsx
  14. 6 1
      apps/demo-free-layout/src/hooks/use-editor-props.tsx
  15. 96 7
      common/config/rush/pnpm-lock.yaml
  16. 11 0
      packages/plugins/download-plugin/.eslintrc.js
  17. 63 0
      packages/plugins/download-plugin/package.json
  18. 20 0
      packages/plugins/download-plugin/src/constant.ts
  19. 23 0
      packages/plugins/download-plugin/src/create-plugin.ts
  20. 7 0
      packages/plugins/download-plugin/src/download-service/index.ts
  21. 139 0
      packages/plugins/download-plugin/src/download-service/service.ts
  22. 15 0
      packages/plugins/download-plugin/src/download-service/type.ts
  23. 12 0
      packages/plugins/download-plugin/src/export-image-service/constant.ts
  24. 6 0
      packages/plugins/download-plugin/src/export-image-service/index.ts
  25. 236 0
      packages/plugins/download-plugin/src/export-image-service/service.ts
  26. 27 0
      packages/plugins/download-plugin/src/export-image-service/type.ts
  27. 32 0
      packages/plugins/download-plugin/src/export-image-service/utils.ts
  28. 9 0
      packages/plugins/download-plugin/src/index.ts
  29. 8 0
      packages/plugins/download-plugin/src/type.ts
  30. 7 0
      packages/plugins/download-plugin/tsconfig.json
  31. 31 0
      packages/plugins/download-plugin/vitest.config.ts
  32. 6 0
      packages/plugins/download-plugin/vitest.setup.ts
  33. 9 0
      rush.json

+ 35 - 35
.github/workflows/ci.yml

@@ -1,38 +1,38 @@
 name: CI
 on:
-  push:
-    branches: [ "main" ]
-  pull_request:
-    branches: [ "main" ]
-  merge_group:
-    branches: [ "main" ]
+    push:
+        branches: ['main']
+    pull_request:
+        branches: ['main']
+    merge_group:
+        branches: ['main']
 jobs:
-  build:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v3
-        with:
-          fetch-depth: 0
-      - name: Config Git User
-        run: |
-          git config --local user.name "dragooncjw"
-          git config --local user.email "289056872@qq.com"
-      - name: For Debug
-        run: |
-          echo "Listing files in the root directory:"
-          ls -alh
-      - uses: actions/setup-node@v3
-        with:
-          node-version: 22
-      # - name: Verify Change Logs
-      #   run: node common/scripts/install-run-rush.js change --verify
-      - name: Rush Install
-        run: node common/scripts/install-run-rush.js install
-      - name: Rush build
-        run: node common/scripts/install-run-rush.js build
-      - name: Check Lint
-        run: node common/scripts/install-run-rush.js lint --verbose
-      - name: Check TS
-        run: node common/scripts/install-run-rush.js ts-check
-      - name: Test (coverage)
-        run: node common/scripts/install-run-rush.js test:cov
+    build:
+        runs-on: ubuntu-latest
+        steps:
+            - uses: actions/checkout@v3
+              with:
+                  fetch-depth: 0
+            - name: Config Git User
+              run: |
+                  git config --local user.name "dragooncjw"
+                  git config --local user.email "289056872@qq.com"
+            - name: For Debug
+              run: |
+                  echo "Listing files in the root directory:"
+                  ls -alh
+            - uses: actions/setup-node@v3
+              with:
+                  node-version: 22
+            # - name: Verify Change Logs
+            #   run: node common/scripts/install-run-rush.js change --verify
+            - name: Rush Install
+              run: node common/scripts/install-run-rush.js install
+            - name: Rush build
+              run: node common/scripts/install-run-rush.js build
+            - name: Check Lint
+              run: node common/scripts/install-run-rush.js lint --verbose
+            - name: Check TS
+              run: node common/scripts/install-run-rush.js ts-check
+            - name: Test (coverage)
+              run: node common/scripts/install-run-rush.js test:cov

+ 1 - 0
apps/demo-fixed-layout/README.md

@@ -26,6 +26,7 @@ npx @flowgram.ai/create-app@latest fixed-layout
 - @flowgram.ai/form-materials: Form materials library
 - @flowgram.ai/group-plugin: Group plugin
 - @flowgram.ai/minimap-plugin: Minimap plugin
+- @flowgram.ai/download-plugin: Download plugin
 
 ## Code Overview
 

+ 4 - 3
apps/demo-fixed-layout/README.zh_CN.md

@@ -26,6 +26,7 @@ npx @flowgram.ai/create-app@latest fixed-layout
 - **@flowgram.ai/form-materials**: 表单物料库
 - **@flowgram.ai/group-plugin**: 分组插件
 - **@flowgram.ai/minimap-plugin**: 缩略图插件
+- **@flowgram.ai/download-plugin**: 下载插件
 
 ## 代码说明
 
@@ -191,11 +192,11 @@ src/
 │           └── variable-panel.tsx
 ├── services/                  # 服务层
-│   ├── index.ts               
+│   ├── index.ts
 │   └── custom-service.ts      # 自定义服务
 ├── shortcuts/                 # 快捷键系统
-│   ├── index.ts               
+│   ├── index.ts
 │   ├── constants.ts           # 快捷键常量
 │   └── utils.ts               # 快捷键工具函数
@@ -238,7 +239,7 @@ src/
 </FixedLayoutEditorProvider>
 ```
 
-**应用场景**: 
+**应用场景**:
 - `FixedLayoutEditorProvider`: 提供编辑器核心功能和状态
 - `SidebarProvider`: 管理侧边栏的显示状态和选中节点
 

+ 1 - 0
apps/demo-fixed-layout/package.json

@@ -39,6 +39,7 @@
     "@flowgram.ai/form-materials": "workspace:*",
     "@flowgram.ai/group-plugin": "workspace:*",
     "@flowgram.ai/minimap-plugin": "workspace:*",
+    "@flowgram.ai/download-plugin": "workspace:*",
     "@flowgram.ai/panel-manager-plugin": "workspace:*",
     "lodash-es": "^4.17.21",
     "nanoid": "^5.0.9",

+ 102 - 0
apps/demo-fixed-layout/src/components/tools/download.tsx

@@ -0,0 +1,102 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useEffect, useState, type FC } from 'react';
+
+import { usePlayground, useService } from '@flowgram.ai/fixed-layout-editor';
+import { FlowDownloadFormat, FlowDownloadService } from '@flowgram.ai/download-plugin';
+import { IconButton, Toast, Dropdown, Tooltip } from '@douyinfe/semi-ui';
+import { IconFilledArrowDown } from '@douyinfe/semi-icons';
+
+const formatOptions = [
+  {
+    label: 'PNG',
+    value: FlowDownloadFormat.PNG,
+  },
+  {
+    label: 'JPEG',
+    value: FlowDownloadFormat.JPEG,
+  },
+  {
+    label: 'SVG',
+    value: FlowDownloadFormat.SVG,
+  },
+  {
+    label: 'JSON',
+    value: FlowDownloadFormat.JSON,
+  },
+  {
+    label: 'YAML',
+    value: FlowDownloadFormat.YAML,
+  },
+];
+
+export const DownloadTool: FC = () => {
+  const [downloading, setDownloading] = useState<boolean>(false);
+  const [visible, setVisible] = useState(false);
+  const playground = usePlayground();
+  const { readonly } = playground.config;
+  const downloadService = useService(FlowDownloadService);
+
+  useEffect(() => {
+    const subscription = downloadService.onDownloadingChange((v) => {
+      setDownloading(v);
+    });
+
+    return () => {
+      subscription.dispose();
+    };
+  }, [downloadService]);
+
+  const handleDownload = async (format: FlowDownloadFormat) => {
+    setVisible(false);
+    await downloadService.download({
+      format,
+    });
+    const formatOption = formatOptions.find((option) => option.value === format);
+    Toast.success(`Download ${formatOption?.label} successfully`);
+  };
+
+  const button = (
+    <IconButton
+      type="tertiary"
+      theme="borderless"
+      className={visible ? '!coz-mg-secondary-pressed' : undefined}
+      icon={<IconFilledArrowDown />}
+      loading={downloading}
+      onClick={() => setVisible(true)}
+    />
+  );
+
+  return (
+    <Dropdown
+      trigger="custom"
+      visible={visible}
+      position="topLeft"
+      onClickOutSide={() => setVisible(false)}
+      render={
+        <Dropdown.Menu className="min-w-[120px]">
+          {formatOptions.map((item) => (
+            <Dropdown.Item
+              disabled={downloading || readonly}
+              key={item.value}
+              onClick={() => handleDownload(item.value)}
+            >
+              {item.label}
+            </Dropdown.Item>
+          ))}
+        </Dropdown.Menu>
+      }
+    >
+      {visible ? (
+        button
+      ) : (
+        <div>
+          <Tooltip content="Download">{button}</Tooltip>
+        </div>
+      )}
+    </Dropdown>
+  );
+};

+ 2 - 0
apps/demo-fixed-layout/src/components/tools/index.tsx

@@ -19,6 +19,7 @@ import { MinimapSwitch } from './minimap-switch';
 import { Minimap } from './minimap';
 import { Interactive } from './interactive';
 import { FitView } from './fit-view';
+import { DownloadTool } from './download';
 
 export const DemoTools = () => {
   const tools = usePlaygroundTools();
@@ -57,6 +58,7 @@ export const DemoTools = () => {
             onClick={() => tools.redo()}
           />
         </Tooltip>
+        <DownloadTool />
         <Save disabled={playground.config.readonly} />
         <Run />
       </ToolSection>

+ 6 - 0
apps/demo-fixed-layout/src/hooks/use-editor-props.ts

@@ -18,6 +18,7 @@ import {
   ShortcutsRegistry,
   ConstantKeys,
 } from '@flowgram.ai/fixed-layout-editor';
+import { createDownloadPlugin } from '@flowgram.ai/download-plugin';
 
 import { type FlowNodeRegistry } from '../typings';
 import { shortcutGetter } from '../shortcuts';
@@ -270,6 +271,11 @@ export function useEditorProps(
             overlayColor: 'rgba(255, 255, 255, 0)',
           },
         }),
+        /**
+         * Download plugin
+         * 下载插件
+         */
+        createDownloadPlugin({}),
         /**
          * Group plugin
          * 分组插件

+ 1 - 0
apps/demo-free-layout/README.md

@@ -25,6 +25,7 @@ npx @flowgram.ai/create-app@latest free-layout
 - **@flowgram.ai/free-lines-plugin**: Connection line rendering plugin
 - **@flowgram.ai/free-node-panel-plugin**: Node add-panel rendering plugin
 - **@flowgram.ai/minimap-plugin**: Minimap plugin
+- **@flowgram.ai/download-plugin**: Download plugin
 - **@flowgram.ai/free-container-plugin**: Sub-canvas plugin
 - **@flowgram.ai/free-group-plugin**: Grouping plugin
 - **@flowgram.ai/form-materials**: Form materials

+ 7 - 6
apps/demo-free-layout/README.zh_CN.md

@@ -2,7 +2,7 @@
 
 自由布局最佳实践 demo
 
-## 安装 
+## 安装
 
 ```shell
 npx @flowgram.ai/create-app@latest free-layout
@@ -25,6 +25,7 @@ npx @flowgram.ai/create-app@latest free-layout
 - **@flowgram.ai/free-lines-plugin**: 连线渲染插件
 - **@flowgram.ai/free-node-panel-plugin**: 节点添加面板渲染插件
 - **@flowgram.ai/minimap-plugin**: 缩略图插件
+- **@flowgram.ai/download-plugin**: 下载插件
 - **@flowgram.ai/free-container-plugin**: 子画布插件
 - **@flowgram.ai/free-group-plugin**: 分组插件
 - **@flowgram.ai/form-materials**: 表单物料
@@ -69,7 +70,7 @@ src/
 │   ├── form-inputs/         # 表单输入
 │   └── form-item/           # 表单项
 │   └── feedback.tsx         # 表单校验错误渲染
-├── hooks/                   
+├── hooks/
 │   ├── index.ts
 │   ├── use-editor-props.tsx # 编辑器属性钩子
 │   ├── use-is-sidebar.ts    # 侧边栏状态钩子
@@ -217,22 +218,22 @@ export function useEditorProps(
     readonly: false,                     // 是否只读
     initialData,                         // 初始数据
     nodeRegistries,                      // 节点注册表
-    
+
     // 核心功能配置
     playground: { preventGlobalGesture: true /* 阻止 mac 浏览器手势翻页 */ },
     nodeEngine: { enable: true },
     variableEngine: { enable: true },
     history: { enable: true, enableChangeNode: true },
-    
+
     // 业务逻辑配置
     canAddLine: (ctx, fromPort, toPort) => { /* 连线规则 */ },
     canDeleteLine: (ctx, line) => { /* 删除连线规则 */ },
     canDeleteNode: (ctx, node) => { /* 删除节点规则 */ },
     canDropToNode: (ctx, params) => { /* 拖拽规则 */ },
-    
+
     // 插件配置
     plugins: () => [/* 插件列表 */],
-    
+
     // 事件处理
     onContentChange: debounce((ctx, event) => { /* 自动保存 */ }, 1000),
     onInit: (ctx) => { /* 初始化 */ },

+ 1 - 0
apps/demo-free-layout/package.json

@@ -41,6 +41,7 @@
     "@flowgram.ai/free-lines-plugin": "workspace:*",
     "@flowgram.ai/free-node-panel-plugin": "workspace:*",
     "@flowgram.ai/minimap-plugin": "workspace:*",
+    "@flowgram.ai/download-plugin": "workspace:*",
     "@flowgram.ai/free-container-plugin": "workspace:*",
     "@flowgram.ai/free-group-plugin": "workspace:*",
     "@flowgram.ai/form-materials": "workspace:*",

+ 9 - 7
apps/demo-free-layout/src/components/problem-panel/problem-panel.tsx

@@ -4,7 +4,7 @@
  */
 
 import { useService, WorkflowSelectService } from '@flowgram.ai/free-layout-editor';
-import { IconButton, Spin, Typography, Avatar } from '@douyinfe/semi-ui';
+import { IconButton, Spin, Typography, Avatar, Tooltip } from '@douyinfe/semi-ui';
 import { IconUploadError, IconClose } from '@douyinfe/semi-icons';
 
 import { useProblemPanel, useNodeFormPanel } from '../../plugins/panel-manager-plugin/hooks';
@@ -88,11 +88,13 @@ export const ProblemPanel = () => {
 export const ProblemButton = () => {
   const { open } = useProblemPanel();
   return (
-    <IconButton
-      type="tertiary"
-      theme="borderless"
-      icon={<IconUploadError />}
-      onClick={() => open()}
-    />
+    <Tooltip content="Problem">
+      <IconButton
+        type="tertiary"
+        theme="borderless"
+        icon={<IconUploadError />}
+        onClick={() => open()}
+      />
+    </Tooltip>
   );
 };

+ 102 - 0
apps/demo-free-layout/src/components/tools/download.tsx

@@ -0,0 +1,102 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useEffect, useState, type FC } from 'react';
+
+import { usePlayground, useService } from '@flowgram.ai/free-layout-editor';
+import { FlowDownloadFormat, FlowDownloadService } from '@flowgram.ai/download-plugin';
+import { IconButton, Toast, Dropdown, Tooltip } from '@douyinfe/semi-ui';
+import { IconFilledArrowDown } from '@douyinfe/semi-icons';
+
+const formatOptions = [
+  {
+    label: 'PNG',
+    value: FlowDownloadFormat.PNG,
+  },
+  {
+    label: 'JPEG',
+    value: FlowDownloadFormat.JPEG,
+  },
+  {
+    label: 'SVG',
+    value: FlowDownloadFormat.SVG,
+  },
+  {
+    label: 'JSON',
+    value: FlowDownloadFormat.JSON,
+  },
+  {
+    label: 'YAML',
+    value: FlowDownloadFormat.YAML,
+  },
+];
+
+export const DownloadTool: FC = () => {
+  const [downloading, setDownloading] = useState<boolean>(false);
+  const [visible, setVisible] = useState(false);
+  const playground = usePlayground();
+  const { readonly } = playground.config;
+  const downloadService = useService(FlowDownloadService);
+
+  useEffect(() => {
+    const subscription = downloadService.onDownloadingChange((v) => {
+      setDownloading(v);
+    });
+
+    return () => {
+      subscription.dispose();
+    };
+  }, [downloadService]);
+
+  const handleDownload = async (format: FlowDownloadFormat) => {
+    setVisible(false);
+    await downloadService.download({
+      format,
+    });
+    const formatOption = formatOptions.find((option) => option.value === format);
+    Toast.success(`Download ${formatOption?.label} successfully`);
+  };
+
+  const button = (
+    <IconButton
+      type="tertiary"
+      theme="borderless"
+      className={visible ? '!coz-mg-secondary-pressed' : undefined}
+      icon={<IconFilledArrowDown />}
+      loading={downloading}
+      onClick={() => setVisible(true)}
+    />
+  );
+
+  return (
+    <Dropdown
+      trigger="custom"
+      visible={visible}
+      position="topLeft"
+      onClickOutSide={() => setVisible(false)}
+      render={
+        <Dropdown.Menu className="min-w-[120px]">
+          {formatOptions.map((item) => (
+            <Dropdown.Item
+              disabled={downloading || readonly}
+              key={item.value}
+              onClick={() => handleDownload(item.value)}
+            >
+              {item.label}
+            </Dropdown.Item>
+          ))}
+        </Dropdown.Menu>
+      }
+    >
+      {visible ? (
+        button
+      ) : (
+        <div>
+          <Tooltip content="Download">{button}</Tooltip>
+        </div>
+      )}
+    </Dropdown>
+  );
+};

+ 2 - 0
apps/demo-free-layout/src/components/tools/index.tsx

@@ -23,6 +23,7 @@ import { FitView } from './fit-view';
 import { Comment } from './comment';
 import { AutoLayout } from './auto-layout';
 import { ProblemButton } from '../problem-panel';
+import { DownloadTool } from './download';
 
 export const DemoTools = () => {
   const { history, playground } = useClientContext();
@@ -74,6 +75,7 @@ export const DemoTools = () => {
           />
         </Tooltip>
         <ProblemButton />
+        <DownloadTool />
         <Divider layout="vertical" style={{ height: '16px' }} margin={3} />
         <AddNode disabled={playground.config.readonly} />
         <Divider layout="vertical" style={{ height: '16px' }} margin={3} />

+ 6 - 1
apps/demo-free-layout/src/hooks/use-editor-props.tsx

@@ -20,6 +20,7 @@ import {
 } from '@flowgram.ai/free-layout-editor';
 import { createFreeGroupPlugin } from '@flowgram.ai/free-group-plugin';
 import { createContainerNodePlugin } from '@flowgram.ai/free-container-plugin';
+import { createDownloadPlugin } from '@flowgram.ai/download-plugin';
 
 import { canContainNode, onDragLineEnd } from '../utils';
 import { FlowNodeRegistry, FlowDocumentJSON } from '../typings';
@@ -326,7 +327,11 @@ export function useEditorProps(
             overlayColor: 'rgba(255, 255, 255, 0.55)',
           },
         }),
-
+        /**
+         * Download plugin
+         * 下载插件
+         */
+        createDownloadPlugin({}),
         /**
          * Snap plugin
          * 自动对齐及辅助线插件

+ 96 - 7
common/config/rush/pnpm-lock.yaml

@@ -107,6 +107,9 @@ importers:
       '@douyinfe/semi-ui':
         specifier: ^2.80.0
         version: 2.86.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@flowgram.ai/download-plugin':
+        specifier: workspace:*
+        version: link:../../packages/plugins/download-plugin
       '@flowgram.ai/fixed-layout-editor':
         specifier: workspace:*
         version: link:../../packages/client/fixed-layout-editor
@@ -256,6 +259,9 @@ importers:
       '@douyinfe/semi-ui':
         specifier: ^2.80.0
         version: 2.86.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@flowgram.ai/download-plugin':
+        specifier: workspace:*
+        version: link:../../packages/plugins/download-plugin
       '@flowgram.ai/form-materials':
         specifier: workspace:*
         version: link:../../packages/materials/form-materials
@@ -2718,6 +2724,79 @@ importers:
         specifier: ^3.2.4
         version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.20)(jiti@2.5.1)(jsdom@26.1.0)(less@4.4.1)(lightningcss@1.30.1)(sass-embedded@1.93.2)(sass@1.93.2)(terser@5.44.0)(tsx@4.19.4)(yaml@2.8.1)
 
+  ../../packages/plugins/download-plugin:
+    dependencies:
+      '@flowgram.ai/core':
+        specifier: workspace:*
+        version: link:../../canvas-engine/core
+      '@flowgram.ai/document':
+        specifier: workspace:*
+        version: link:../../canvas-engine/document
+      '@flowgram.ai/utils':
+        specifier: workspace:*
+        version: link:../../common/utils
+      inversify:
+        specifier: ^6.0.1
+        version: 6.2.2(reflect-metadata@0.2.2)
+      js-yaml:
+        specifier: ^4.1.1
+        version: 4.1.1
+      lodash-es:
+        specifier: ^4.17.21
+        version: 4.17.21
+      modern-screenshot:
+        specifier: 4.6.7
+        version: 4.6.7
+      nanoid:
+        specifier: ^5.0.9
+        version: 5.1.5
+      reflect-metadata:
+        specifier: ~0.2.2
+        version: 0.2.2
+    devDependencies:
+      '@flowgram.ai/eslint-config':
+        specifier: workspace:*
+        version: link:../../../config/eslint-config
+      '@flowgram.ai/ts-config':
+        specifier: workspace:*
+        version: link:../../../config/ts-config
+      '@types/bezier-js':
+        specifier: 4.1.3
+        version: 4.1.3
+      '@types/js-yaml':
+        specifier: ^4.0.9
+        version: 4.0.9
+      '@types/lodash-es':
+        specifier: ^4.17.12
+        version: 4.17.12
+      '@types/react':
+        specifier: ^18
+        version: 18.3.24
+      '@types/react-dom':
+        specifier: ^18
+        version: 18.3.7(@types/react@18.3.24)
+      '@vitest/coverage-v8':
+        specifier: ^3.2.4
+        version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.20)(jiti@2.5.1)(jsdom@26.1.0)(less@4.4.1)(lightningcss@1.30.1)(sass-embedded@1.93.2)(sass@1.93.2)(terser@5.44.0)(tsx@4.19.4)(yaml@2.8.1))
+      eslint:
+        specifier: ^8.54.0
+        version: 8.57.1
+      react:
+        specifier: ^18
+        version: 18.3.1
+      react-dom:
+        specifier: ^18
+        version: 18.3.1(react@18.3.1)
+      tsup:
+        specifier: ^8.0.1
+        version: 8.5.0(jiti@2.5.1)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.9.2)(yaml@2.8.1)
+      typescript:
+        specifier: ^5.8.3
+        version: 5.9.2
+      vitest:
+        specifier: ^3.2.4
+        version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.20)(jiti@2.5.1)(jsdom@26.1.0)(less@4.4.1)(lightningcss@1.30.1)(sass-embedded@1.93.2)(sass@1.93.2)(terser@5.44.0)(tsx@4.19.4)(yaml@2.8.1)
+
   ../../packages/plugins/fixed-drag-plugin:
     dependencies:
       '@flowgram.ai/core':
@@ -7421,6 +7500,9 @@ packages:
   '@types/inquirer@9.0.9':
     resolution: {integrity: sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw==}
 
+  '@types/js-yaml@4.0.9':
+    resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
+
   '@types/json-schema@7.0.15':
     resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
 
@@ -10070,8 +10152,8 @@ packages:
     resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
     hasBin: true
 
-  js-yaml@4.1.0:
-    resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
+  js-yaml@4.1.1:
+    resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
     hasBin: true
 
   jsdom@26.1.0:
@@ -10809,6 +10891,9 @@ packages:
   mnemonist@0.39.6:
     resolution: {integrity: sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==}
 
+  modern-screenshot@4.6.7:
+    resolution: {integrity: sha512-0GhgI6i6le4AhKzCvLYjwEmsP47kTsX45iT5yuAzsLTi/7i3Rjxe8fbH2VjGJLuyOThwsa0CdQAPd4auoEtsZg==}
+
   monaco-editor@0.53.0:
     resolution: {integrity: sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ==}
 
@@ -14958,7 +15043,7 @@ snapshots:
       globals: 13.24.0
       ignore: 5.3.2
       import-fresh: 3.3.1
-      js-yaml: 4.1.0
+      js-yaml: 4.1.1
       minimatch: 3.1.2
       strip-json-comments: 3.1.1
     transitivePeerDependencies:
@@ -14972,7 +15057,7 @@ snapshots:
       globals: 14.0.0
       ignore: 5.3.2
       import-fresh: 3.3.1
-      js-yaml: 4.1.0
+      js-yaml: 4.1.1
       minimatch: 3.1.2
       strip-json-comments: 3.1.1
     transitivePeerDependencies:
@@ -16883,6 +16968,8 @@ snapshots:
       '@types/through': 0.0.33
       rxjs: 7.8.2
 
+  '@types/js-yaml@4.0.9': {}
+
   '@types/json-schema@7.0.15': {}
 
   '@types/json5@0.0.29': {}
@@ -19063,7 +19150,7 @@ snapshots:
       imurmurhash: 0.1.4
       is-glob: 4.0.3
       is-path-inside: 3.0.3
-      js-yaml: 4.1.0
+      js-yaml: 4.1.1
       json-stable-stringify-without-jsonify: 1.0.1
       levn: 0.4.1
       lodash.merge: 4.6.2
@@ -20137,7 +20224,7 @@ snapshots:
       argparse: 1.0.10
       esprima: 4.0.1
 
-  js-yaml@4.1.0:
+  js-yaml@4.1.1:
     dependencies:
       argparse: 2.0.1
 
@@ -21399,6 +21486,8 @@ snapshots:
     dependencies:
       obliterator: 2.0.5
 
+  modern-screenshot@4.6.7: {}
+
   monaco-editor@0.53.0:
     dependencies:
       '@types/trusted-types': 1.0.6
@@ -22564,7 +22653,7 @@ snapshots:
       '@types/mdast': 4.0.4
       '@types/unist': 3.0.3
       flat: 5.0.2
-      js-yaml: 4.1.0
+      js-yaml: 4.1.1
       mdast-util-from-markdown: 2.0.2
       mdast-util-to-markdown: 2.1.2
       micromark: 4.0.2

+ 11 - 0
packages/plugins/download-plugin/.eslintrc.js

@@ -0,0 +1,11 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+const { defineConfig } = require('@flowgram.ai/eslint-config');
+
+module.exports = defineConfig({
+  preset: 'web',
+  packageRoot: __dirname,
+});

+ 63 - 0
packages/plugins/download-plugin/package.json

@@ -0,0 +1,63 @@
+{
+  "name": "@flowgram.ai/download-plugin",
+  "version": "0.1.8",
+  "homepage": "https://flowgram.ai/",
+  "repository": "https://github.com/bytedance/flowgram.ai",
+  "license": "MIT",
+  "exports": {
+    "types": "./dist/index.d.ts",
+    "import": "./dist/esm/index.js",
+    "require": "./dist/index.js"
+  },
+  "main": "./dist/index.js",
+  "module": "./dist/esm/index.js",
+  "types": "./dist/index.d.ts",
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "npm run build:fast -- --dts-resolve",
+    "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
+    "build:watch": "npm run build:fast -- --dts-resolve",
+    "clean": "rimraf dist",
+    "test": "exit 0",
+    "test:cov": "exit 0",
+    "ts-check": "tsc --noEmit",
+    "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
+  },
+  "dependencies": {
+    "@flowgram.ai/core": "workspace:*",
+    "@flowgram.ai/document": "workspace:*",
+    "@flowgram.ai/utils": "workspace:*",
+    "inversify": "^6.0.1",
+    "reflect-metadata": "~0.2.2",
+    "nanoid": "^5.0.9",
+    "modern-screenshot": "4.6.7",
+    "lodash-es": "^4.17.21",
+    "js-yaml": "^4.1.1"
+  },
+  "devDependencies": {
+    "@flowgram.ai/eslint-config": "workspace:*",
+    "@flowgram.ai/ts-config": "workspace:*",
+    "@types/bezier-js": "4.1.3",
+    "@types/lodash-es": "^4.17.12",
+    "@types/react": "^18",
+    "@types/react-dom": "^18",
+    "@vitest/coverage-v8": "^3.2.4",
+    "eslint": "^8.54.0",
+    "react": "^18",
+    "react-dom": "^18",
+    "tsup": "^8.0.1",
+    "typescript": "^5.8.3",
+    "vitest": "^3.2.4",
+    "@types/js-yaml": "^4.0.9"
+  },
+  "peerDependencies": {
+    "react": ">=16.8",
+    "react-dom": ">=16.8"
+  },
+  "publishConfig": {
+    "access": "public",
+    "registry": "https://registry.npmjs.org/"
+  }
+}

+ 20 - 0
packages/plugins/download-plugin/src/constant.ts

@@ -0,0 +1,20 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export enum FlowDownloadFormat {
+  JSON = 'json',
+  YAML = 'yaml',
+  PNG = 'png',
+  JPEG = 'jpeg',
+  SVG = 'svg',
+}
+
+export const FlowImageFormats = [
+  FlowDownloadFormat.PNG,
+  FlowDownloadFormat.JPEG,
+  FlowDownloadFormat.SVG,
+];
+
+export const FlowDataFormats = [FlowDownloadFormat.JSON, FlowDownloadFormat.YAML];

+ 23 - 0
packages/plugins/download-plugin/src/create-plugin.ts

@@ -0,0 +1,23 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { definePluginCreator, PluginContext } from '@flowgram.ai/core';
+
+import { CreateDownloadPluginOptions } from './type';
+import { WorkflowExportImageService } from './export-image-service';
+import { FlowDownloadService } from './download-service';
+
+export const createDownloadPlugin = definePluginCreator<CreateDownloadPluginOptions>({
+  onBind: ({ bind }) => {
+    bind(WorkflowExportImageService).toSelf().inSingletonScope();
+    bind(FlowDownloadService).toSelf().inSingletonScope();
+  },
+  onInit: (ctx: PluginContext, opts: CreateDownloadPluginOptions) => {
+    ctx.get(FlowDownloadService).init(opts);
+  },
+  onDispose: (ctx: PluginContext) => {
+    ctx.get(FlowDownloadService).dispose();
+  },
+});

+ 7 - 0
packages/plugins/download-plugin/src/download-service/index.ts

@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { FlowDownloadService } from './service';
+export { DownloadServiceOptions } from './type';

+ 139 - 0
packages/plugins/download-plugin/src/download-service/service.ts

@@ -0,0 +1,139 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+import { nanoid } from 'nanoid';
+import { inject, injectable } from 'inversify';
+import { DisposableCollection, Emitter } from '@flowgram.ai/utils';
+import { FlowDocument } from '@flowgram.ai/document';
+
+import type { DownloadServiceOptions, WorkflowDownloadParams } from './type';
+import { WorkflowExportImageService } from '../export-image-service';
+import { FlowDataFormats, FlowDownloadFormat, FlowImageFormats } from '../constant';
+
+@injectable()
+export class FlowDownloadService {
+  @inject(FlowDocument) private readonly document: FlowDocument;
+
+  @inject(WorkflowExportImageService)
+  private readonly exportImageService: WorkflowExportImageService;
+
+  private toDispose: DisposableCollection = new DisposableCollection();
+
+  public downloading = false;
+
+  private onDownloadingChangeEmitter = new Emitter<boolean>();
+
+  private options: DownloadServiceOptions = {};
+
+  public onDownloadingChange = this.onDownloadingChangeEmitter.event;
+
+  public init(options?: Partial<DownloadServiceOptions>) {
+    this.options = options ?? {};
+    this.toDispose.push(this.onDownloadingChangeEmitter);
+  }
+
+  public dispose(): void {
+    this.toDispose.dispose();
+  }
+
+  public async download(params: WorkflowDownloadParams): Promise<void> {
+    if (this.downloading) {
+      return;
+    }
+
+    const { format } = params;
+
+    if (FlowImageFormats.includes(format)) {
+      await this.handleImageDownload(format);
+    } else if (FlowDataFormats.includes(format)) {
+      await this.handleDataDownload(format);
+    }
+  }
+
+  public setDownloading(value: boolean) {
+    this.downloading = value;
+    this.onDownloadingChangeEmitter.fire(value);
+  }
+
+  private async handleImageDownload(format: FlowDownloadFormat): Promise<void> {
+    this.setDownloading(true);
+    try {
+      await this.downloadImage(format);
+    } finally {
+      this.setDownloading(false);
+    }
+  }
+
+  private async handleDataDownload(format: FlowDownloadFormat): Promise<void> {
+    this.setDownloading(true);
+    try {
+      await this.downloadData(format);
+    } finally {
+      this.setDownloading(false);
+    }
+  }
+
+  private async downloadData(format: FlowDownloadFormat): Promise<void> {
+    const json = this.document.toJSON();
+    const { content, mimeType } = await this.formatDataContent(json, format);
+
+    const blob = new Blob([content], { type: mimeType });
+    const url = URL.createObjectURL(blob);
+    const filename = this.getFileName(format);
+
+    this.downloadFile(url, filename);
+    URL.revokeObjectURL(url);
+  }
+
+  private async formatDataContent(
+    json: unknown,
+    format: FlowDownloadFormat
+  ): Promise<{ content: string; mimeType: string }> {
+    if (format === FlowDownloadFormat.YAML) {
+      const yaml = await import('js-yaml');
+      return {
+        content: yaml.dump(json, {
+          indent: 2,
+          lineWidth: -1,
+          noRefs: true,
+        }),
+        mimeType: 'application/x-yaml',
+      };
+    }
+
+    return {
+      content: JSON.stringify(json, null, 2),
+      mimeType: 'application/json',
+    };
+  }
+
+  private async downloadImage(format: FlowDownloadFormat): Promise<void> {
+    const imageUrl = await this.exportImageService.export({
+      format,
+      watermarkSVG: this.options.watermarkSVG,
+    });
+    if (!imageUrl) {
+      return;
+    }
+
+    const filename = this.getFileName(format);
+    this.downloadFile(imageUrl, filename);
+  }
+
+  private getFileName(format: FlowDownloadFormat): string {
+    if (this.options.getFilename) {
+      return this.options.getFilename(format);
+    }
+    return `flowgram-${nanoid(5)}.${format}`;
+  }
+
+  private downloadFile(href: string, filename: string): void {
+    const link = document.createElement('a');
+    link.href = href;
+    link.download = filename;
+    document.body.appendChild(link);
+    link.click();
+    document.body.removeChild(link);
+  }
+}

+ 15 - 0
packages/plugins/download-plugin/src/download-service/type.ts

@@ -0,0 +1,15 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FlowDownloadFormat } from '../constant';
+
+export interface WorkflowDownloadParams {
+  format: FlowDownloadFormat;
+}
+
+export interface DownloadServiceOptions {
+  getFilename?: (format: FlowDownloadFormat) => string;
+  watermarkSVG?: string;
+}

Fișier diff suprimat deoarece este prea mare
+ 12 - 0
packages/plugins/download-plugin/src/export-image-service/constant.ts


+ 6 - 0
packages/plugins/download-plugin/src/export-image-service/index.ts

@@ -0,0 +1,6 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { FlowExportImageService as WorkflowExportImageService } from './service';

+ 236 - 0
packages/plugins/download-plugin/src/export-image-service/service.ts

@@ -0,0 +1,236 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { inject, injectable } from 'inversify';
+import { FlowDocument } from '@flowgram.ai/document';
+
+import { getWorkflowRect } from './utils';
+import { type IFlowExportImageService, type ExportImageOptions } from './type';
+import {
+  IN_SAFARI,
+  IN_FIREFOX,
+  EXPORT_IMAGE_WATERMARK_SVG,
+  EXPORT_IMAGE_STYLE_PROPERTIES,
+} from './constant';
+import { FlowDownloadFormat } from '../constant';
+
+const PADDING_X = 58;
+const PADDING_Y = 138;
+
+@injectable()
+export class FlowExportImageService implements IFlowExportImageService {
+  private modernScreenshot: any;
+
+  @inject(FlowDocument)
+  private document: FlowDocument;
+
+  public async export(options: ExportImageOptions): Promise<string | undefined> {
+    try {
+      const imgUrl = await this.doExport(options);
+      return imgUrl;
+    } catch (e) {
+      console.error('Export image failed:', e);
+      return;
+    }
+  }
+
+  private async loadModernScreenshot() {
+    if (this.modernScreenshot) {
+      return this.modernScreenshot;
+    }
+
+    const modernScreenshot = await import('modern-screenshot');
+    this.modernScreenshot = modernScreenshot;
+  }
+
+  private async doExport(exportOptions: ExportImageOptions): Promise<string | undefined> {
+    if (this.document.layout.name.includes('fixed-layout')) {
+      return await this.doFixedExport(exportOptions);
+    }
+    return await this.doFreeExport(exportOptions);
+  }
+
+  private async doFreeExport(exportOptions: ExportImageOptions): Promise<string | undefined> {
+    const { format } = exportOptions;
+    // const el = this.stackingContextManager.node as HTMLElement;
+    const renderLayer = window.document.querySelector('.gedit-flow-render-layer') as HTMLElement;
+
+    if (!renderLayer) {
+      return;
+    }
+
+    const { width, height, x, y } = getWorkflowRect(this.document);
+
+    await this.loadModernScreenshot();
+    const { domToPng, domToForeignObjectSvg, domToJpeg } = this.modernScreenshot;
+    let imgUrl: string;
+    const options = {
+      scale: 2,
+      includeStyleProperties: IN_SAFARI || IN_FIREFOX ? EXPORT_IMAGE_STYLE_PROPERTIES : undefined,
+      width: width + PADDING_X * 2,
+      height: height + PADDING_Y * 2,
+      onCloneEachNode: (cloned: HTMLElement) => {
+        this.handleFreeClone(cloned, { width, height, x, y, options: exportOptions });
+      },
+    };
+    switch (format) {
+      case FlowDownloadFormat.PNG:
+        imgUrl = await domToPng(renderLayer, options);
+        break;
+      case FlowDownloadFormat.SVG: {
+        const svg = await domToForeignObjectSvg(renderLayer, options);
+        imgUrl = await this.svgToDataURL(svg);
+        break;
+      }
+      case FlowDownloadFormat.JPEG:
+        imgUrl = await domToJpeg(renderLayer, options);
+        break;
+      default:
+        imgUrl = await domToPng(renderLayer, options);
+    }
+    return imgUrl;
+  }
+
+  private async doFixedExport(exportOptions: ExportImageOptions): Promise<string | undefined> {
+    const { format } = exportOptions;
+
+    const el = window.document.querySelector('.gedit-flow-nodes-layer') as HTMLElement;
+
+    if (!el) {
+      return;
+    }
+
+    const { width, height, x, y } = getWorkflowRect(this.document);
+    await this.loadModernScreenshot();
+    const { domToPng, domToForeignObjectSvg, domToJpeg } = this.modernScreenshot;
+    let imgUrl: string;
+    const options = {
+      scale: 2,
+      includeStyleProperties: IN_SAFARI || IN_FIREFOX ? EXPORT_IMAGE_STYLE_PROPERTIES : undefined,
+      width: width + PADDING_X * 2,
+      height: height + PADDING_Y * 2,
+      onCloneEachNode: (cloned: HTMLElement) => {
+        this.handleFixedClone(cloned, { width, height, x, y, options: exportOptions });
+      },
+    };
+    switch (format) {
+      case FlowDownloadFormat.PNG:
+        imgUrl = await domToPng(el, options);
+        break;
+      case FlowDownloadFormat.SVG: {
+        const svg = await domToForeignObjectSvg(el, options);
+        imgUrl = await this.svgToDataURL(svg);
+        break;
+      }
+      case FlowDownloadFormat.JPEG:
+        imgUrl = await domToJpeg(el, options);
+        break;
+      default:
+        imgUrl = await domToPng(el, options);
+    }
+    return imgUrl;
+  }
+
+  private async svgToDataURL(svg: SVGElement): Promise<string> {
+    return Promise.resolve()
+      .then(() => new XMLSerializer().serializeToString(svg))
+      .then(encodeURIComponent)
+      .then((html) => `data:image/svg+xml;charset=utf-8,${html}`);
+  }
+
+  // 处理克隆节点
+  private handleFreeClone(
+    cloned: HTMLElement,
+    {
+      width,
+      height,
+      x,
+      y,
+      options,
+    }: { width: number; height: number; x: number; y: number; options: ExportImageOptions }
+  ) {
+    if (
+      cloned?.classList?.contains('gedit-flow-activity-node') ||
+      cloned?.classList?.contains('gedit-flow-activity-line')
+    ) {
+      this.handlePosition(cloned, x, y);
+    }
+
+    if (cloned?.classList?.contains('gedit-flow-render-layer')) {
+      this.handleCanvas(cloned, width, height, options);
+    }
+  }
+
+  // 处理克隆节点
+  private handleFixedClone(
+    cloned: HTMLElement,
+    {
+      width,
+      height,
+      x,
+      y,
+      options,
+    }: { width: number; height: number; x: number; y: number; options: ExportImageOptions }
+  ) {
+    if (
+      cloned?.classList?.contains('gedit-flow-activity-node') ||
+      cloned?.classList?.contains('gedit-flow-activity-line')
+    ) {
+      this.handlePosition(cloned, x, y);
+    }
+
+    if (cloned?.classList?.contains('gedit-flow-nodes-layer')) {
+      const linesLayer = window.document
+        .querySelector('.gedit-flow-lines-layer')
+        ?.cloneNode(true) as HTMLElement;
+      this.handleLines(linesLayer, width, height);
+      cloned.appendChild(linesLayer);
+      this.handleCanvas(cloned, width, height, options);
+    }
+  }
+
+  // 处理节点位置
+  private handlePosition(cloned: HTMLElement, x: number, y: number) {
+    cloned.style.transform = `translate(${-x + PADDING_X}px, ${-y + PADDING_Y}px)`;
+  }
+
+  // 处理画布
+  private handleLines(cloned: HTMLElement, width: number, height: number) {
+    cloned.style.position = 'absolute';
+    cloned.style.width = `${width}px`;
+    cloned.style.height = `${height}px`;
+    cloned.style.left = `${width / 2 - PADDING_X}px`;
+    cloned.style.top = `${PADDING_Y}px`;
+    cloned.style.transform = 'none';
+    cloned.style.backgroundColor = 'transparent';
+    cloned.querySelector('.flow-lines-container')!.setAttribute('viewBox', `0 0 1000 1000`);
+  }
+
+  // 处理画布
+  private handleCanvas(
+    cloned: HTMLElement,
+    width: number,
+    height: number,
+    options: ExportImageOptions
+  ) {
+    cloned.style.width = `${width + PADDING_X * 2}px`;
+    cloned.style.height = `${height + PADDING_Y * 2}px`;
+    cloned.style.transform = 'none';
+    cloned.style.backgroundColor = '#ECECEE';
+    this.handleWaterMark(cloned, options);
+  }
+
+  // 添加水印节点
+  private handleWaterMark(element: HTMLElement, options: ExportImageOptions) {
+    const watermarkNode = document.createElement('div');
+    // 水印svg
+    watermarkNode.innerHTML = options?.watermarkSVG ?? EXPORT_IMAGE_WATERMARK_SVG;
+    watermarkNode.style.position = 'absolute';
+    watermarkNode.style.bottom = '32px';
+    watermarkNode.style.right = '32px';
+    watermarkNode.style.zIndex = '999999';
+    element.appendChild(watermarkNode);
+  }
+}

+ 27 - 0
packages/plugins/download-plugin/src/export-image-service/type.ts

@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FlowDownloadFormat } from '../constant';
+
+/**
+ * 导出图片服务
+ */
+export interface IFlowExportImageService {
+  /**
+   * 导出
+   */
+  export: (options: ExportImageOptions) => Promise<string | undefined>;
+}
+
+/**
+ * 导出图片选项
+ */
+export interface ExportImageOptions {
+  /**
+   * 导出的格式
+   */
+  format: FlowDownloadFormat;
+  watermarkSVG?: string;
+}

+ 32 - 0
packages/plugins/download-plugin/src/export-image-service/utils.ts

@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FlowDocument, FlowNodeEntity } from '@flowgram.ai/document';
+import { TransformData } from '@flowgram.ai/core';
+
+const getNodesRect = (nodes: FlowNodeEntity[]) => {
+  const rects = nodes
+    .map((node) => node.getData<TransformData>(TransformData)?.bounds)
+    .filter(Boolean);
+  const x1 = Math.min(...rects.map((rect) => rect.x));
+  const x2 = Math.max(...rects.map((rect) => rect.x + rect.width));
+  const y1 = Math.min(...rects.map((rect) => rect.y));
+  const y2 = Math.max(...rects.map((rect) => rect.y + rect.height));
+
+  const width = x2 - x1;
+  const height = y2 - y1;
+
+  return {
+    width,
+    height,
+    x: x1,
+    y: y1,
+  };
+};
+
+/**
+ * 获取流程所有节点矩形坐标
+ */
+export const getWorkflowRect = (document: FlowDocument) => getNodesRect(document.getAllNodes());

+ 9 - 0
packages/plugins/download-plugin/src/index.ts

@@ -0,0 +1,9 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export { createDownloadPlugin } from './create-plugin';
+export { FlowDownloadService, type DownloadServiceOptions } from './download-service';
+export { type CreateDownloadPluginOptions } from './type';
+export { FlowDownloadFormat } from './constant';

+ 8 - 0
packages/plugins/download-plugin/src/type.ts

@@ -0,0 +1,8 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { DownloadServiceOptions } from './download-service';
+
+export interface CreateDownloadPluginOptions extends Partial<DownloadServiceOptions> {}

+ 7 - 0
packages/plugins/download-plugin/tsconfig.json

@@ -0,0 +1,7 @@
+{
+  "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
+  "compilerOptions": {
+  },
+  "include": ["./src"],
+  "exclude": ["node_modules"]
+}

+ 31 - 0
packages/plugins/download-plugin/vitest.config.ts

@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+const path = require('path');
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  build: {
+    commonjsOptions: {
+      transformMixedEsModules: true,
+    },
+  },
+  test: {
+    globals: true,
+    mockReset: false,
+    environment: 'jsdom',
+    setupFiles: [path.resolve(__dirname, './vitest.setup.ts')],
+    include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],
+    exclude: [
+      '**/__mocks__**',
+      '**/node_modules/**',
+      '**/dist/**',
+      '**/lib/**', // lib 编译结果忽略掉
+      '**/cypress/**',
+      '**/.{idea,git,cache,output,temp}/**',
+      '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
+    ],
+  },
+});

+ 6 - 0
packages/plugins/download-plugin/vitest.setup.ts

@@ -0,0 +1,6 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import 'reflect-metadata';

+ 9 - 0
rush.json

@@ -711,6 +711,15 @@
                 "team-flow"
             ]
         },
+        {
+            "packageName": "@flowgram.ai/download-plugin",
+            "projectFolder": "packages/plugins/download-plugin",
+            "versionPolicyName": "publishPolicy",
+            "tags": [
+                "level-1",
+                "team-flow"
+            ]
+        },
         {
             "packageName": "@flowgram.ai/node",
             "projectFolder": "packages/node-engine/node",

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff