├── src ├── vite-env.d.ts ├── assets │ └── logo.png ├── components │ ├── panels │ │ ├── index.ts │ │ ├── NodePanel.tsx │ │ └── KeyboardShortcutsPanel.tsx │ ├── nodes │ │ ├── index.ts │ │ ├── PPTAssemblerNode │ │ │ ├── types.ts │ │ │ ├── ThumbnailStrip.tsx │ │ │ └── PagePreview.tsx │ │ ├── PPTContentNode │ │ │ ├── ImageRefTag.tsx │ │ │ ├── ImageSelectorModal.tsx │ │ │ ├── PageList.tsx │ │ │ └── BuiltinTemplateModal.tsx │ │ ├── PromptNode.tsx │ │ ├── ImageInputNode.tsx │ │ └── FileUploadNode.tsx │ └── ui │ │ ├── Toast.tsx │ │ ├── Input.tsx │ │ ├── LoadingIndicator.tsx │ │ ├── ContextMenu.tsx │ │ ├── PromptEditorModal.tsx │ │ ├── Select.tsx │ │ └── ImagePreviewModal.tsx ├── main.tsx ├── hooks │ └── useLoadingDots.ts ├── stores │ ├── toastStore.ts │ ├── settingsStore.ts │ └── storageManagementStore.ts ├── utils │ ├── imageCompression.ts │ ├── tauriStorage.ts │ └── connectionValidator.ts ├── services │ ├── fileStorageService.ts │ ├── updateService.ts │ ├── ocrInpaintService.ts │ ├── imageService.ts │ └── taskManager.ts ├── App.tsx ├── config │ └── nodeConfig.ts ├── types │ └── index.ts └── index.css ├── src-tauri ├── build.rs ├── icons │ ├── 32x32.png │ ├── 64x64.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── StoreLogo.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square310x310Logo.png │ ├── ios │ │ ├── AppIcon-512@2x.png │ │ ├── AppIcon-20x20@1x.png │ │ ├── AppIcon-20x20@2x.png │ │ ├── AppIcon-20x20@3x.png │ │ ├── AppIcon-29x29@1x.png │ │ ├── AppIcon-29x29@2x.png │ │ ├── AppIcon-29x29@3x.png │ │ ├── AppIcon-40x40@1x.png │ │ ├── AppIcon-40x40@2x.png │ │ ├── AppIcon-40x40@3x.png │ │ ├── AppIcon-60x60@2x.png │ │ ├── AppIcon-60x60@3x.png │ │ ├── AppIcon-76x76@1x.png │ │ ├── AppIcon-76x76@2x.png │ │ ├── AppIcon-20x20@2x-1.png │ │ ├── AppIcon-29x29@2x-1.png │ │ ├── AppIcon-40x40@2x-1.png │ │ └── AppIcon-83.5x83.5@2x.png │ └── android │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png │ │ ├── values │ │ └── ic_launcher_background.xml │ │ └── mipmap-anydpi-v26 │ │ └── ic_launcher.xml ├── .gitignore ├── src │ ├── main.rs │ └── lib.rs ├── capabilities │ └── default.json ├── Cargo.toml └── tauri.conf.json ├── public ├── favicon.png ├── templates │ └── template_1.png ├── vite.svg └── tauri.svg ├── docs └── images │ ├── logo.png │ ├── ppt-pages.png │ ├── ppt-preview.png │ ├── ppt-workflow.png │ ├── main-interface.png │ └── ppt-preview-original.png ├── .vscode └── extensions.json ├── tsconfig.node.json ├── .gitignore ├── index.html ├── docker ├── Dockerfile.paddleocr ├── Dockerfile.iopaint ├── Dockerfile.easyocr ├── docker-compose.yml ├── README.md └── ocr_server.py ├── tsconfig.json ├── package.json ├── vite.config.ts ├── .github └── workflows │ └── release.yml └── README.md /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/public/favicon.png -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/docs/images/logo.png -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /docs/images/ppt-pages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/docs/images/ppt-pages.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/64x64.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /docs/images/ppt-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/docs/images/ppt-preview.png -------------------------------------------------------------------------------- /docs/images/ppt-workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/docs/images/ppt-workflow.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] 3 | } 4 | -------------------------------------------------------------------------------- /docs/images/main-interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/docs/images/main-interface.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /public/templates/template_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/public/templates/template_1.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /docs/images/ppt-preview-original.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/docs/images/ppt-preview-original.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-512@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-20x20@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-20x20@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-20x20@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-29x29@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-29x29@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-29x29@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-40x40@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-40x40@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-40x40@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-60x60@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-60x60@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-76x76@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-76x76@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-20x20@2x-1.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-29x29@2x-1.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-40x40@2x-1.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #fff 4 | -------------------------------------------------------------------------------- /src/components/panels/index.ts: -------------------------------------------------------------------------------- 1 | export { NodePanel } from "./NodePanel"; 2 | export { SettingsPanel } from "./SettingsPanel"; 3 | export { KeyboardShortcutsPanel } from "./KeyboardShortcutsPanel"; 4 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | nextcreator_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | NextCreator 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /docker/Dockerfile.paddleocr: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | WORKDIR /app 4 | 5 | # 安装系统依赖 (libgl1-mesa-glx 在新版 Debian 中改名为 libgl1) 6 | RUN apt-get update && apt-get install -y \ 7 | libgl1 \ 8 | libglib2.0-0 \ 9 | libsm6 \ 10 | libxext6 \ 11 | libxrender-dev \ 12 | && rm -rf /var/lib/apt/lists/* 13 | 14 | # 安装 Python 依赖 15 | RUN pip install --no-cache-dir \ 16 | paddlepaddle \ 17 | paddleocr \ 18 | flask 19 | 20 | # 复制服务代码 21 | COPY ocr_server.py /app/ 22 | 23 | EXPOSE 8866 24 | 25 | CMD ["python", "ocr_server.py"] 26 | -------------------------------------------------------------------------------- /docker/Dockerfile.iopaint: -------------------------------------------------------------------------------- 1 | # IOPaint CPU Dockerfile 2 | # 基于官方 https://github.com/Sanster/IOPaint/blob/main/docker/CPUDockerfile 3 | 4 | FROM python:3.10-slim 5 | 6 | ARG version=1.5.3 7 | 8 | # 安装 OpenCV 所需的系统依赖 (libGL) 9 | RUN apt-get update && apt-get install -y \ 10 | libgl1 \ 11 | libglib2.0-0 \ 12 | && rm -rf /var/lib/apt/lists/* 13 | 14 | # 升级 pip 并使用重试机制安装 15 | RUN pip install --upgrade pip && \ 16 | pip install --no-cache-dir --retries 5 --timeout 60 iopaint==${version} 17 | 18 | EXPOSE 8080 19 | 20 | CMD ["iopaint", "start", "--host", "0.0.0.0", "--port", "8080", "--model", "lama", "--device", "cpu"] 21 | -------------------------------------------------------------------------------- /docker/Dockerfile.easyocr: -------------------------------------------------------------------------------- 1 | # EasyOCR Dockerfile 2 | # 轻量级 OCR 服务,对 ARM 架构兼容性好 3 | 4 | FROM python:3.10-slim 5 | 6 | WORKDIR /app 7 | 8 | # 安装系统依赖(OpenCV 需要) 9 | RUN apt-get update && apt-get install -y \ 10 | libgl1 \ 11 | libglib2.0-0 \ 12 | && rm -rf /var/lib/apt/lists/* 13 | 14 | # 安装 Python 依赖 15 | # 使用 --extra-index-url 添加 PyTorch CPU 源,同时保留 PyPI 源 16 | RUN pip install --no-cache-dir \ 17 | torch \ 18 | --extra-index-url https://download.pytorch.org/whl/cpu 19 | 20 | RUN pip install --no-cache-dir \ 21 | easyocr \ 22 | flask 23 | 24 | # 复制服务代码 25 | COPY ocr_server.py /app/ 26 | 27 | EXPOSE 8866 28 | 29 | CMD ["python", "ocr_server.py"] 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Path Aliases */ 18 | "baseUrl": ".", 19 | "paths": { 20 | "@/*": ["./src/*"] 21 | }, 22 | 23 | /* Linting */ 24 | "strict": true, 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | "noFallthroughCasesInSwitch": true 28 | }, 29 | "include": ["src"], 30 | "references": [{ "path": "./tsconfig.node.json" }] 31 | } 32 | -------------------------------------------------------------------------------- /src/hooks/useLoadingDots.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | /** 4 | * 省略号加载动画 Hook 5 | * 使用 React state 驱动,不触发 GPU 合成,避免字体模糊 6 | * @param isLoading - 是否正在加载 7 | * @param interval - 切换间隔,默认 500ms 8 | * @returns 当前省略号字符串 ("." | ".." | "...") 9 | */ 10 | export function useLoadingDots(isLoading: boolean, interval = 500): string { 11 | const [dots, setDots] = useState("."); 12 | 13 | useEffect(() => { 14 | if (!isLoading) { 15 | setDots("."); 16 | return; 17 | } 18 | 19 | const timer = setInterval(() => { 20 | setDots(prev => { 21 | if (prev === ".") return ".."; 22 | if (prev === "..") return "..."; 23 | return "."; 24 | }); 25 | }, interval); 26 | 27 | return () => clearInterval(timer); 28 | }, [isLoading, interval]); 29 | 30 | return dots; 31 | } 32 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": ["main"], 6 | "permissions": [ 7 | "core:default", 8 | "opener:default", 9 | "dialog:default", 10 | "fs:default", 11 | "fs:allow-download-write", 12 | "fs:allow-download-write-recursive", 13 | "fs:allow-desktop-write", 14 | "fs:allow-desktop-write-recursive", 15 | "fs:allow-document-write", 16 | "fs:allow-document-write-recursive", 17 | "fs:allow-home-write", 18 | "fs:allow-home-write-recursive", 19 | "fs:allow-download-read", 20 | "fs:allow-desktop-read", 21 | "fs:allow-document-read", 22 | "fs:allow-home-read", 23 | "fs:write-all", 24 | "fs:read-all", 25 | "store:default" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod storage; 2 | mod gemini; 3 | mod ocr_inpaint; 4 | 5 | use storage::*; 6 | use gemini::*; 7 | use ocr_inpaint::*; 8 | 9 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 10 | pub fn run() { 11 | tauri::Builder::default() 12 | .plugin(tauri_plugin_opener::init()) 13 | .plugin(tauri_plugin_dialog::init()) 14 | .plugin(tauri_plugin_fs::init()) 15 | .plugin(tauri_plugin_store::Builder::default().build()) 16 | .invoke_handler(tauri::generate_handler![ 17 | save_image, 18 | read_image, 19 | delete_image, 20 | delete_canvas_images, 21 | get_storage_stats, 22 | clear_cache, 23 | clear_all_images, 24 | get_storage_path, 25 | list_canvas_images, 26 | gemini_generate_content, 27 | gemini_generate_text, 28 | process_ppt_page, 29 | test_ocr_connection, 30 | test_inpaint_connection 31 | ]) 32 | .run(tauri::generate_context!()) 33 | .expect("error while running tauri application"); 34 | } 35 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nextcreator" 3 | version = "0.1.1" 4 | description = "A Tauri App" 5 | authors = ["you"] 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [lib] 11 | # The `_lib` suffix may seem redundant but it is necessary 12 | # to make the lib name unique and wouldn't conflict with the bin name. 13 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 14 | name = "nextcreator_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2", features = [] } 19 | 20 | [dependencies] 21 | tauri = { version = "2", features = ["protocol-asset"] } 22 | tauri-plugin-opener = "2" 23 | tauri-plugin-dialog = "2" 24 | tauri-plugin-fs = "2" 25 | serde = { version = "1", features = ["derive"] } 26 | serde_json = "1" 27 | base64 = "0.22" 28 | uuid = { version = "1", features = ["v4"] } 29 | chrono = "0.4" 30 | reqwest = { version = "0.12", features = ["json"] } 31 | tokio = { version = "1", features = ["full"] } 32 | image = "0.25" 33 | tauri-plugin-store = "2.4.1" 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextcreator", 3 | "private": true, 4 | "version": "0.1.1", 5 | "license": "AGPL-3.0", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "preview": "vite preview", 11 | "tauri": "tauri" 12 | }, 13 | "dependencies": { 14 | "@google/genai": "^1.32.0", 15 | "@tailwindcss/vite": "^4.1.17", 16 | "@tauri-apps/api": "^2", 17 | "@tauri-apps/plugin-dialog": "^2.4.2", 18 | "@tauri-apps/plugin-fs": "^2.4.4", 19 | "@tauri-apps/plugin-opener": "^2", 20 | "@tauri-apps/plugin-store": "^2.4.1", 21 | "@xyflow/react": "^12.10.0", 22 | "daisyui": "^5.5.8", 23 | "lucide-react": "^0.556.0", 24 | "pptxgenjs": "^4.0.1", 25 | "react": "^19.1.0", 26 | "react-dom": "^19.1.0", 27 | "react-markdown": "^10.1.0", 28 | "tailwindcss": "^4.1.17", 29 | "uuid": "^13.0.0", 30 | "zustand": "^5.0.9" 31 | }, 32 | "devDependencies": { 33 | "@tauri-apps/cli": "^2", 34 | "@types/react": "^19.1.8", 35 | "@types/react-dom": "^19.1.6", 36 | "@types/uuid": "^11.0.0", 37 | "@vitejs/plugin-react": "^4.6.0", 38 | "typescript": "~5.8.3", 39 | "vite": "^7.0.4" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "NextCreator", 4 | "version": "0.1.1", 5 | "identifier": "com.sy.nextcreator", 6 | "build": { 7 | "beforeDevCommand": "bun run dev", 8 | "devUrl": "http://localhost:1420", 9 | "beforeBuildCommand": "bun run build", 10 | "frontendDist": "../dist" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "title": "NextCreator", 16 | "width": 1400, 17 | "height": 900, 18 | "minWidth": 1000, 19 | "minHeight": 700, 20 | "center": true, 21 | "resizable": true, 22 | "fullscreen": false, 23 | "dragDropEnabled": false, 24 | "useHttpsScheme": true 25 | } 26 | ], 27 | "security": { 28 | "csp": null, 29 | "assetProtocol": { 30 | "enable": true, 31 | "scope": ["$APPDATA/**", "$RESOURCE/**"] 32 | } 33 | } 34 | }, 35 | "bundle": { 36 | "active": true, 37 | "targets": "all", 38 | "icon": [ 39 | "icons/32x32.png", 40 | "icons/128x128.png", 41 | "icons/128x128@2x.png", 42 | "icons/icon.icns", 43 | "icons/icon.ico" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # NextCreator - OCR + Inpaint 服务 2 | # 用于 PPT 可编辑导出功能 3 | # 4 | # 使用方法: 5 | # cd docker 6 | # docker-compose up -d 7 | # 8 | # 服务地址: 9 | # - EasyOCR: http://127.0.0.1:8866 10 | # - IOPaint: http://127.0.0.1:8080 11 | # 12 | # 首次启动需要下载模型,请耐心等待(约 3-5 分钟) 13 | 14 | services: 15 | # EasyOCR 服务 - 文字检测和识别(轻量级,ARM 兼容) 16 | easyocr: 17 | build: 18 | context: . 19 | dockerfile: Dockerfile.easyocr 20 | container_name: next-easyocr 21 | ports: 22 | - "8866:8866" 23 | volumes: 24 | - easyocr_models:/root/.EasyOCR 25 | restart: unless-stopped 26 | deploy: 27 | resources: 28 | limits: 29 | memory: 4G 30 | reservations: 31 | memory: 1G 32 | 33 | # IOPaint 服务 - AI 背景修复 34 | iopaint: 35 | build: 36 | context: . 37 | dockerfile: Dockerfile.iopaint 38 | container_name: next-iopaint 39 | ports: 40 | - "8080:8080" 41 | volumes: 42 | - iopaint_models:/root/.cache 43 | restart: unless-stopped 44 | deploy: 45 | resources: 46 | limits: 47 | memory: 4G 48 | reservations: 49 | memory: 1G 50 | 51 | volumes: 52 | easyocr_models: 53 | iopaint_models: 54 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # NextCreator - OCR + Inpaint 服务部署 2 | 3 | 用于 PPT 可编辑导出功能的后端服务。 4 | 5 | ## 快速启动 6 | 7 | ```bash 8 | cd docker 9 | docker-compose up -d 10 | ``` 11 | 12 | ## 服务说明 13 | 14 | | 服务 | 端口 | 用途 | 15 | |------|------|------| 16 | | EasyOCR | 8866 | 文字检测和识别 | 17 | | IOPaint | 8080 | AI 背景修复(去除文字) | 18 | 19 | ## 首次启动 20 | 21 | 首次启动需要下载模型文件,可能需要 3-5 分钟。 22 | 23 | 查看启动日志: 24 | ```bash 25 | docker-compose logs -f 26 | ``` 27 | 28 | ## 测试服务 29 | 30 | ### 测试 EasyOCR 31 | ```bash 32 | curl http://127.0.0.1:8866/ 33 | # 返回: {"languages":["ch_sim","en"],"service":"EasyOCR","status":"ok"} 34 | ``` 35 | 36 | ### 测试 IOPaint 37 | ```bash 38 | curl http://127.0.0.1:8080/ 39 | # 返回 IOPaint 配置信息 40 | ``` 41 | 42 | ## 在 NextCreator 中配置 43 | 44 | 1. 打开 PPT 组装节点 45 | 2. 点击右上角设置按钮 46 | 3. 填入服务地址: 47 | - OCR 服务: `http://127.0.0.1:8866` 48 | - IOPaint 服务: `http://127.0.0.1:8080` 49 | 4. 点击「测试」验证连接 50 | 5. 切换到「可编辑」导出模式 51 | 52 | ## 停止服务 53 | 54 | ```bash 55 | docker-compose down 56 | ``` 57 | 58 | ## 常见问题 59 | 60 | ### Q: 内存占用过高 61 | A: 可以在 `docker-compose.yml` 中调整 `memory` 限制 62 | 63 | ### Q: 处理速度慢 64 | A: 默认使用 CPU,如有 GPU 可修改相关配置 65 | 66 | ### Q: 模型下载失败 67 | A: 检查网络连接,或配置代理 68 | 69 | ## 技术说明 70 | 71 | - **EasyOCR**: 基于 PyTorch,支持 80+ 语言,对 ARM 架构兼容性好 72 | - **IOPaint**: 使用 LaMa 模型进行图像修复,去除文字区域 73 | -------------------------------------------------------------------------------- /src/components/nodes/index.ts: -------------------------------------------------------------------------------- 1 | export { PromptNode } from "./PromptNode"; 2 | export { ImageGeneratorProNode, ImageGeneratorFastNode } from "./ImageGeneratorNode"; 3 | export { ImageInputNode } from "./ImageInputNode"; 4 | export { VideoGeneratorNode } from "./VideoGeneratorNode"; 5 | export { PPTContentNode } from "./PPTContentNode"; 6 | export { PPTAssemblerNode } from "./PPTAssemblerNode"; 7 | export { LLMContentNode } from "./LLMContentNode"; 8 | export { FileUploadNode } from "./FileUploadNode"; 9 | 10 | import { PromptNode } from "./PromptNode"; 11 | import { ImageGeneratorProNode, ImageGeneratorFastNode } from "./ImageGeneratorNode"; 12 | import { ImageInputNode } from "./ImageInputNode"; 13 | import { VideoGeneratorNode } from "./VideoGeneratorNode"; 14 | import { PPTContentNode } from "./PPTContentNode"; 15 | import { PPTAssemblerNode } from "./PPTAssemblerNode"; 16 | import { LLMContentNode } from "./LLMContentNode"; 17 | import { FileUploadNode } from "./FileUploadNode"; 18 | 19 | // 节点类型映射 20 | export const nodeTypes = { 21 | promptNode: PromptNode, 22 | imageGeneratorProNode: ImageGeneratorProNode, 23 | imageGeneratorFastNode: ImageGeneratorFastNode, 24 | imageInputNode: ImageInputNode, 25 | videoGeneratorNode: VideoGeneratorNode, 26 | pptContentNode: PPTContentNode, 27 | pptAssemblerNode: PPTAssemblerNode, 28 | llmContentNode: LLMContentNode, 29 | fileUploadNode: FileUploadNode, 30 | }; 31 | -------------------------------------------------------------------------------- /src/components/nodes/PPTAssemblerNode/types.ts: -------------------------------------------------------------------------------- 1 | // PPT 组装节点类型定义 2 | 3 | // PPT 页面数据(从上游接收) 4 | export interface PPTPageData { 5 | pageNumber: number; 6 | heading: string; 7 | points: string[]; 8 | script: string; 9 | image: string; // base64 图片 - 完整的 PPT 页面图片(用于导出) 10 | thumbnail?: string; // 缩略图 base64(JPEG 格式,用于画布预览) 11 | 12 | // 仅背景模式处理后的数据 13 | processedBackground?: string; // 处理后的背景图 base64(去除文字后) 14 | processedThumbnail?: string; // 处理后的背景图缩略图 15 | processStatus?: 'pending' | 'processing' | 'completed' | 'error'; // 处理状态 16 | processError?: string; // 处理错误信息 17 | } 18 | 19 | // PPT 组装节点数据 20 | export interface PPTAssemblerNodeData { 21 | [key: string]: unknown; 22 | label: string; 23 | 24 | // 幻灯片比例 25 | aspectRatio: "16:9" | "4:3"; 26 | 27 | // 页面数据(从上游同步) 28 | pages: PPTPageData[]; 29 | 30 | // 状态 31 | status: "idle" | "generating" | "processing" | "ready" | "error"; 32 | error?: string; 33 | 34 | // === 可编辑导出功能 === 35 | // 导出模式: 36 | // - image: 纯图片(原始图片直接嵌入) 37 | // - background: 仅背景(去除文字后的背景图,用户自行添加文字) 38 | exportMode: "image" | "background"; 39 | 40 | // OCR 服务地址 41 | ocrApiUrl: string; 42 | 43 | // IOPaint 服务地址 44 | inpaintApiUrl: string; 45 | 46 | // 处理进度(当前处理页面索引和详细步骤) 47 | processingProgress?: { 48 | current: number; 49 | total: number; 50 | currentStep?: 'ocr' | 'inpaint'; // 当前步骤:OCR识别 或 背景修复 51 | } | null; 52 | } 53 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import tailwindcss from "@tailwindcss/vite"; 4 | import path from "path"; 5 | import { readFileSync } from "fs"; 6 | 7 | // @ts-expect-error process is a nodejs global 8 | const host = process.env.TAURI_DEV_HOST; 9 | 10 | // 读取 package.json 获取版本号 11 | const packageJson = JSON.parse( 12 | readFileSync(path.resolve(__dirname, "package.json"), "utf-8") 13 | ); 14 | 15 | // https://vite.dev/config/ 16 | export default defineConfig(async () => ({ 17 | plugins: [react(), tailwindcss()], 18 | // 注入版本号到应用 19 | define: { 20 | __APP_VERSION__: JSON.stringify(packageJson.version), 21 | }, 22 | resolve: { 23 | alias: { 24 | "@": path.resolve(__dirname, "./src"), 25 | }, 26 | }, 27 | 28 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 29 | // 30 | // 1. prevent Vite from obscuring rust errors 31 | clearScreen: false, 32 | // 2. tauri expects a fixed port, fail if that port is not available 33 | server: { 34 | port: 1420, 35 | strictPort: true, 36 | host: host || false, 37 | hmr: host 38 | ? { 39 | protocol: "ws", 40 | host, 41 | port: 1421, 42 | } 43 | : undefined, 44 | watch: { 45 | // 3. tell Vite to ignore watching `src-tauri` 46 | ignored: ["**/src-tauri/**"], 47 | }, 48 | }, 49 | })); 50 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/nodes/PPTAssemblerNode/ThumbnailStrip.tsx: -------------------------------------------------------------------------------- 1 | import type { PPTPageData } from "./types"; 2 | 3 | interface ThumbnailStripProps { 4 | pages: PPTPageData[]; 5 | currentPage: number; 6 | onPageSelect: (index: number) => void; 7 | } 8 | 9 | export function ThumbnailStrip({ 10 | pages, 11 | currentPage, 12 | onPageSelect, 13 | }: ThumbnailStripProps) { 14 | if (pages.length === 0) { 15 | return null; 16 | } 17 | 18 | return ( 19 |
20 | {pages.map((page, index) => { 21 | // 优先使用缩略图,减少内存占用 22 | const imageUrl = page.thumbnail 23 | ? `data:image/jpeg;base64,${page.thumbnail}` 24 | : page.image 25 | ? `data:image/png;base64,${page.image}` 26 | : undefined; 27 | 28 | return ( 29 | 53 | ); 54 | })} 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/components/ui/Toast.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Toast 通知组件 3 | * 使用 daisyUI alert 样式 4 | */ 5 | 6 | import { createPortal } from "react-dom"; 7 | import { CheckCircle, XCircle, AlertTriangle, Info, X } from "lucide-react"; 8 | import { useToastStore, type ToastType } from "@/stores/toastStore"; 9 | 10 | // Toast 类型配置 11 | const toastConfig: Record = { 15 | success: { 16 | icon: , 17 | alertClass: "alert-success", 18 | }, 19 | error: { 20 | icon: , 21 | alertClass: "alert-error", 22 | }, 23 | warning: { 24 | icon: , 25 | alertClass: "alert-warning", 26 | }, 27 | info: { 28 | icon: , 29 | alertClass: "alert-info", 30 | }, 31 | }; 32 | 33 | export function ToastContainer() { 34 | const { toasts, removeToast } = useToastStore(); 35 | 36 | if (toasts.length === 0) return null; 37 | 38 | return createPortal( 39 |
40 | {toasts.map((toast) => { 41 | const config = toastConfig[toast.type]; 42 | return ( 43 |
51 | {config.icon} 52 | {toast.message} 53 | 59 |
60 | ); 61 | })} 62 |
, 63 | document.body 64 | ); 65 | } 66 | 67 | export default ToastContainer; 68 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 'Release' 2 | 3 | # 当推送版本 tag 时触发(如 v0.1.0, v1.0.0 等) 4 | on: 5 | push: 6 | tags: 7 | - 'v*' 8 | 9 | jobs: 10 | release: 11 | permissions: 12 | contents: write 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | # macOS Apple Silicon (M1/M2/M3) 18 | - platform: 'macos-latest' 19 | args: '--target aarch64-apple-darwin' 20 | # macOS Intel 21 | - platform: 'macos-latest' 22 | args: '--target x86_64-apple-darwin' 23 | # Windows 24 | - platform: 'windows-latest' 25 | args: '' 26 | 27 | runs-on: ${{ matrix.platform }} 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | 32 | - name: Setup Node.js 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: 'lts/*' 36 | 37 | - name: Setup Bun 38 | uses: oven-sh/setup-bun@v2 39 | with: 40 | bun-version: latest 41 | 42 | - name: Install Rust stable 43 | uses: dtolnay/rust-toolchain@stable 44 | with: 45 | # macOS 需要同时支持 Intel 和 Apple Silicon 架构 46 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 47 | 48 | - name: Rust cache 49 | uses: swatinem/rust-cache@v2 50 | with: 51 | workspaces: './src-tauri -> target' 52 | 53 | - name: Install frontend dependencies 54 | run: bun install 55 | 56 | - name: Build and release 57 | uses: tauri-apps/tauri-action@v0 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | with: 61 | tagName: ${{ github.ref_name }} 62 | releaseName: 'NextCreator ${{ github.ref_name }}' 63 | releaseBody: | 64 | ## NextCreator ${{ github.ref_name }} 65 | 66 | 请根据您的系统下载对应的安装包: 67 | 68 | - **macOS (Apple Silicon M1/M2/M3)**: `NextCreator_*_aarch64.dmg` 69 | - **macOS (Intel)**: `NextCreator_*_x64.dmg` 70 | - **Windows**: `NextCreator_*_x64-setup.exe` 或 `.msi` 71 | releaseDraft: false 72 | prerelease: false 73 | args: ${{ matrix.args }} 74 | -------------------------------------------------------------------------------- /src/components/nodes/PPTContentNode/ImageRefTag.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState } from "react"; 2 | import { X, Maximize2 } from "lucide-react"; 3 | import { ImagePreviewModal } from "@/components/ui/ImagePreviewModal"; 4 | 5 | interface ImageRefTagProps { 6 | id: string; 7 | fileName: string; 8 | imageData: string; 9 | onRemove: (id: string) => void; 10 | } 11 | 12 | // 图片引用标签组件 - 显示缩略图和文件名,支持预览和删除 13 | export const ImageRefTag = memo(({ id, fileName, imageData, onRemove }: ImageRefTagProps) => { 14 | const [showPreview, setShowPreview] = useState(false); 15 | 16 | return ( 17 | <> 18 |
19 | {/* 缩略图 */} 20 |
setShowPreview(true)} 23 | > 24 | {fileName} 29 |
30 | 31 |
32 |
33 | 34 | {/* 文件名 */} 35 | setShowPreview(true)} 38 | title={fileName} 39 | > 40 | {fileName} 41 | 42 | 43 | {/* 删除按钮 */} 44 | 54 |
55 | 56 | {/* 预览弹窗 */} 57 | {showPreview && ( 58 | setShowPreview(false)} 62 | /> 63 | )} 64 | 65 | ); 66 | }); 67 | 68 | ImageRefTag.displayName = "ImageRefTag"; 69 | -------------------------------------------------------------------------------- /src/stores/toastStore.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Toast 通知状态管理 3 | * 使用 Zustand 管理全局 toast 消息 4 | */ 5 | 6 | import { create } from "zustand"; 7 | 8 | // Toast 类型 9 | export type ToastType = "success" | "error" | "warning" | "info"; 10 | 11 | // Toast 消息 12 | export interface ToastMessage { 13 | id: string; 14 | type: ToastType; 15 | message: string; 16 | duration?: number; // 持续时间(毫秒),默认 3000 17 | } 18 | 19 | interface ToastState { 20 | toasts: ToastMessage[]; 21 | // 添加 toast 22 | addToast: (type: ToastType, message: string, duration?: number) => void; 23 | // 移除 toast 24 | removeToast: (id: string) => void; 25 | // 快捷方法 26 | success: (message: string, duration?: number) => void; 27 | error: (message: string, duration?: number) => void; 28 | warning: (message: string, duration?: number) => void; 29 | info: (message: string, duration?: number) => void; 30 | } 31 | 32 | export const useToastStore = create((set, get) => ({ 33 | toasts: [], 34 | 35 | addToast: (type, message, duration = 3000) => { 36 | const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; 37 | const toast: ToastMessage = { id, type, message, duration }; 38 | 39 | set((state) => ({ 40 | toasts: [...state.toasts, toast], 41 | })); 42 | 43 | // 自动移除 44 | if (duration > 0) { 45 | setTimeout(() => { 46 | get().removeToast(id); 47 | }, duration); 48 | } 49 | }, 50 | 51 | removeToast: (id) => { 52 | set((state) => ({ 53 | toasts: state.toasts.filter((t) => t.id !== id), 54 | })); 55 | }, 56 | 57 | // 快捷方法 58 | success: (message, duration) => get().addToast("success", message, duration), 59 | error: (message, duration) => get().addToast("error", message, duration ?? 5000), 60 | warning: (message, duration) => get().addToast("warning", message, duration), 61 | info: (message, duration) => get().addToast("info", message, duration), 62 | })); 63 | 64 | // 导出便捷函数,可以在非 React 组件中使用 65 | export const toast = { 66 | success: (message: string, duration?: number) => 67 | useToastStore.getState().success(message, duration), 68 | error: (message: string, duration?: number) => 69 | useToastStore.getState().error(message, duration), 70 | warning: (message: string, duration?: number) => 71 | useToastStore.getState().warning(message, duration), 72 | info: (message: string, duration?: number) => 73 | useToastStore.getState().info(message, duration), 74 | }; 75 | -------------------------------------------------------------------------------- /src/components/ui/Input.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useState } from "react"; 2 | import { Eye, EyeOff, Search } from "lucide-react"; 3 | 4 | interface InputProps extends Omit, "size"> { 5 | /** 输入框左侧图标 */ 6 | leftIcon?: React.ReactNode; 7 | /** 是否为密码输入框(支持显示/隐藏切换) */ 8 | isPassword?: boolean; 9 | /** 是否为搜索框 */ 10 | isSearch?: boolean; 11 | } 12 | 13 | export const Input = forwardRef( 14 | ({ className = "", leftIcon, isPassword, isSearch, type, ...props }, ref) => { 15 | const [showPassword, setShowPassword] = useState(false); 16 | 17 | // 确定实际的 type 18 | const inputType = isPassword ? (showPassword ? "text" : "password") : type; 19 | 20 | // 确定左侧图标 21 | const LeftIconComponent = isSearch ? : leftIcon; 22 | 23 | return ( 24 |
25 | {/* 左侧图标 */} 26 | {LeftIconComponent && ( 27 |
28 | {LeftIconComponent} 29 |
30 | )} 31 | 32 | {/* 输入框 */} 33 | 49 | 50 | {/* 密码显示/隐藏按钮 */} 51 | {isPassword && ( 52 | 64 | )} 65 |
66 | ); 67 | } 68 | ); 69 | 70 | Input.displayName = "Input"; 71 | -------------------------------------------------------------------------------- /public/tauri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/utils/imageCompression.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 图片压缩工具 3 | * 用于生成缩略图,优化画布预览性能 4 | */ 5 | 6 | export interface ThumbnailOptions { 7 | maxWidth?: number; // 最大宽度,默认 800px 8 | quality?: number; // JPEG 质量,0-1,默认 0.85 9 | format?: "jpeg" | "webp"; // 输出格式,默认 jpeg 10 | } 11 | 12 | /** 13 | * 从 base64 图片生成缩略图 14 | * @param base64Data - 原图 base64(不含 data:image/xxx;base64, 前缀) 15 | * @param options - 压缩选项 16 | * @returns 缩略图的 base64(不含前缀) 17 | */ 18 | export function generateThumbnail( 19 | base64Data: string, 20 | options: ThumbnailOptions = {} 21 | ): Promise { 22 | const { maxWidth = 800, quality = 0.85, format = "jpeg" } = options; 23 | 24 | return new Promise((resolve, reject) => { 25 | const img = new Image(); 26 | 27 | img.onload = () => { 28 | try { 29 | // 计算缩放后的尺寸 30 | let width = img.width; 31 | let height = img.height; 32 | 33 | if (width > maxWidth) { 34 | height = Math.round((height * maxWidth) / width); 35 | width = maxWidth; 36 | } 37 | 38 | // 创建 canvas 并绘制 39 | const canvas = document.createElement("canvas"); 40 | canvas.width = width; 41 | canvas.height = height; 42 | 43 | const ctx = canvas.getContext("2d"); 44 | if (!ctx) { 45 | reject(new Error("无法创建 canvas context")); 46 | return; 47 | } 48 | 49 | // 使用高质量缩放 50 | ctx.imageSmoothingEnabled = true; 51 | ctx.imageSmoothingQuality = "high"; 52 | ctx.drawImage(img, 0, 0, width, height); 53 | 54 | // 转换为目标格式 55 | const mimeType = format === "webp" ? "image/webp" : "image/jpeg"; 56 | const dataUrl = canvas.toDataURL(mimeType, quality); 57 | 58 | // 移除 data:image/xxx;base64, 前缀 59 | const base64 = dataUrl.split(",")[1]; 60 | resolve(base64); 61 | } catch (error) { 62 | reject(error); 63 | } 64 | }; 65 | 66 | img.onerror = () => { 67 | reject(new Error("图片加载失败")); 68 | }; 69 | 70 | // 加载原图 71 | img.src = `data:image/png;base64,${base64Data}`; 72 | }); 73 | } 74 | 75 | /** 76 | * 批量生成缩略图 77 | * @param images - base64 图片数组 78 | * @param options - 压缩选项 79 | * @returns 缩略图 base64 数组 80 | */ 81 | export async function generateThumbnails( 82 | images: string[], 83 | options: ThumbnailOptions = {} 84 | ): Promise { 85 | return Promise.all(images.map((img) => generateThumbnail(img, options))); 86 | } 87 | 88 | /** 89 | * 获取缩略图的 MIME 类型 90 | * @param format - 格式 91 | * @returns MIME 类型字符串 92 | */ 93 | export function getThumbnailMimeType(format: "jpeg" | "webp" = "jpeg"): string { 94 | return format === "webp" ? "image/webp" : "image/jpeg"; 95 | } 96 | -------------------------------------------------------------------------------- /src/utils/tauriStorage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tauri Store 存储适配器 3 | * 用于 Zustand persist 中间件,在 Tauri 环境使用 tauri-plugin-store 4 | * 浏览器环境降级到 localStorage 5 | */ 6 | 7 | import { isTauriEnvironment } from "@/services/fileStorageService"; 8 | 9 | // Store 实例缓存 10 | let storeInstance: Awaited> | null = null; 11 | 12 | // 获取或创建 Store 实例 13 | async function getStore() { 14 | if (!isTauriEnvironment()) { 15 | return null; 16 | } 17 | 18 | if (!storeInstance) { 19 | const { load } = await import("@tauri-apps/plugin-store"); 20 | // 使用 load 函数加载或创建 store 文件 21 | storeInstance = await load("app-data.json", { autoSave: true, defaults: {} }); 22 | } 23 | 24 | return storeInstance; 25 | } 26 | 27 | // 获取数据 28 | async function getItem(key: string): Promise { 29 | try { 30 | const store = await getStore(); 31 | 32 | if (store) { 33 | // Tauri 环境:从 store 读取 34 | const value = await store.get(key); 35 | return value ?? null; 36 | } else { 37 | // 浏览器环境:降级到 localStorage 38 | return localStorage.getItem(key); 39 | } 40 | } catch (error) { 41 | console.error("Storage getItem error:", error); 42 | // 出错时尝试 localStorage 43 | try { 44 | return localStorage.getItem(key); 45 | } catch { 46 | return null; 47 | } 48 | } 49 | } 50 | 51 | // 设置数据 52 | async function setItem(key: string, value: string): Promise { 53 | try { 54 | const store = await getStore(); 55 | 56 | if (store) { 57 | // Tauri 环境:写入 store 58 | await store.set(key, value); 59 | // autoSave 已启用,无需手动 save 60 | } else { 61 | // 浏览器环境:降级到 localStorage 62 | localStorage.setItem(key, value); 63 | } 64 | } catch (error) { 65 | console.error("Storage setItem error:", error); 66 | // 出错时尝试 localStorage 67 | try { 68 | localStorage.setItem(key, value); 69 | } catch { 70 | // 忽略 71 | } 72 | } 73 | } 74 | 75 | // 删除数据 76 | async function removeItem(key: string): Promise { 77 | try { 78 | const store = await getStore(); 79 | 80 | if (store) { 81 | // Tauri 环境:从 store 删除 82 | await store.delete(key); 83 | } else { 84 | // 浏览器环境:降级到 localStorage 85 | localStorage.removeItem(key); 86 | } 87 | } catch (error) { 88 | console.error("Storage removeItem error:", error); 89 | // 出错时尝试 localStorage 90 | try { 91 | localStorage.removeItem(key); 92 | } catch { 93 | // 忽略 94 | } 95 | } 96 | } 97 | 98 | // 导出符合 Zustand StateStorage 接口的对象 99 | export const tauriStorage = { 100 | getItem, 101 | setItem, 102 | removeItem, 103 | }; 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | NextCreator Logo 3 |

NextCreator

4 |

基于可视化节点的 AI 内容生成工作流工具

5 | 6 | ![Version](https://img.shields.io/badge/version-0.1.1-blue.svg) 7 | ![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Windows-lightgrey.svg) 8 | ![Tauri](https://img.shields.io/badge/Tauri-2.0-24C8DB.svg?logo=tauri&logoColor=white) 9 | ![React](https://img.shields.io/badge/React-19-61DAFB.svg?logo=react&logoColor=white) 10 | ![License](https://img.shields.io/badge/license-AGPL%20v3-blue.svg) 11 |
12 | 13 | --- 14 | 15 | ## 功能特性 16 | 17 | - **节点编辑器** - 拖拽式工作流设计,支持撤销/重做、复制粘贴、自动布局 18 | - **多画布管理** - 创建多个独立画布,数据自动持久化 19 | - **AI 图片生成** - 支持文生图、图生图,可配置分辨率和比例 20 | - **AI 视频生成** - 基于 Sora 模型的视频生成 21 | - **LLM 文本生成** - 支持多模态输入(文本/图片/PDF) 22 | - **PPT 工作流** - 自动生成大纲、PPT页面,导出可编辑文字的 PPTX 23 | 24 | ## 截图预览 25 | 26 | ### 主界面 27 | ![主界面](docs/images/main-interface.png) 28 | 29 | ### PPT 工作流 30 | ![PPT 工作流](docs/images/ppt-workflow.png) 31 | 32 | ### PPT 页面生成 33 | ![PPT 页面生成](docs/images/ppt-pages.png) 34 | 35 | ### PPT 预览导出 36 | 37 | **纯图片模式** - 直接导出 PPT 图片 38 | ![PPT 预览 - 纯图片模式](docs/images/ppt-preview-original.png) 39 | 40 | **可编辑模式** - 去除文字仅保留背景,方便后期编辑 41 | ![PPT 预览 - 可编辑模式](docs/images/ppt-preview.png) 42 | 43 | ## 快速开始 44 | 45 | 前往 [Releases](https://github.com/MoonWeSif/NextCreator/releases) 下载最新版本: 46 | 47 | - **macOS (Apple Silicon)**: `NextCreator_*_aarch64.dmg` 48 | - **macOS (Intel)**: `NextCreator_*_x64.dmg` 49 | - **Windows**: `NextCreator_*_x64-setup.exe` 50 | 51 | ### macOS 安装提示 52 | 53 | 由于应用未经 Apple 签名,首次打开可能会提示"无法验证开发者"。请在终端执行以下命令解决: 54 | 55 | ```bash 56 | xattr -rc "/Applications/NextCreator.app" 57 | codesign --force --deep --sign - "/Applications/NextCreator.app" 58 | ``` 59 | 60 | ## 使用流程 61 | 62 | 1. **配置供应商** - 点击右上角「供应商管理」,添加 API 供应商(如 OpenAI、Google Gemini 等) 63 | 2. **分配供应商** - 在供应商管理中为不同节点类型(图片生成、视频生成、LLM 等)指定默认供应商 64 | 3. **创建工作流** - 从左侧节点面板拖拽节点到画布,连接节点构建工作流 65 | 4. **运行生成** - 填写输入内容,点击节点的生成按钮即可 66 | 67 | ## 本地开发 68 | 69 | ```bash 70 | # 安装依赖 71 | bun install 72 | 73 | # 开发模式 74 | bun run tauri dev 75 | 76 | # 构建应用 77 | bun run tauri build 78 | ``` 79 | 80 | ## 可选:OCR + Inpaint 服务 81 | 82 | 如需使用 **PPT 可编辑导出**功能(去除文字仅保留背景),需要 OCR 和 Inpaint 服务。 83 | 84 | ### 方式一:使用公益服务 85 | 86 | 项目提供公益服务,可直接在设置中配置使用: 87 | 88 | | 服务 | 地址 | 用途 | 89 | |------|------|------| 90 | | EasyOCR | http://152.67.202.21:8866 | 文字检测识别 | 91 | | IOPaint | http://152.67.202.21:8877 | AI 背景修复 | 92 | 93 | > ⚠️ **注意**:公益服务受限于服务器性能,处理速度较慢,且不保障可用性。建议本地部署以获得更好体验。 94 | 95 | ### 方式二:本地部署(推荐) 96 | 97 | ```bash 98 | cd docker 99 | docker-compose up -d 100 | ``` 101 | 102 | | 服务 | 地址 | 用途 | 103 | |------|------|------| 104 | | EasyOCR | http://127.0.0.1:8866 | 文字检测识别 | 105 | | IOPaint | http://127.0.0.1:8080 | AI 背景修复 | 106 | 107 | > 首次启动需下载模型,约 3-5 分钟 108 | 109 | ## 技术栈 110 | 111 | | 层级 | 技术 | 112 | |------|------| 113 | | 前端 | React 19 + TypeScript + Tailwind CSS + daisyUI | 114 | | 后端 | Tauri 2 (Rust) | 115 | | 状态 | Zustand + IndexedDB | 116 | | 节点 | @xyflow/react | 117 | 118 | ## 许可证 119 | 120 | 本项目基于 [GNU Affero General Public License v3](https://www.gnu.org/licenses/agpl-3.0.html) 发行,详细条款请参阅仓库根目录的 `LICENSE` 文件。 121 | IT 122 | -------------------------------------------------------------------------------- /docker/ocr_server.py: -------------------------------------------------------------------------------- 1 | """ 2 | EasyOCR HTTP 服务 3 | 提供 OCR 文字检测和识别 API 4 | 兼容原 PaddleOCR API 格式 5 | """ 6 | 7 | from flask import Flask, request, jsonify 8 | import easyocr 9 | import base64 10 | import tempfile 11 | import os 12 | 13 | app = Flask(__name__) 14 | 15 | # 初始化 EasyOCR(首次运行会下载模型) 16 | print("正在初始化 EasyOCR...") 17 | reader = easyocr.Reader( 18 | ['ch_sim', 'en'], # 支持简体中文和英文 19 | gpu=False, # CPU 模式 20 | ) 21 | print("EasyOCR 初始化完成") 22 | 23 | 24 | @app.route('/predict/ocr', methods=['POST']) 25 | def predict(): 26 | """ 27 | OCR 识别接口(兼容原 PaddleOCR API 格式) 28 | 29 | 请求格式: 30 | { 31 | "images": ["base64_encoded_image", ...] 32 | } 33 | 34 | 响应格式: 35 | { 36 | "status": "000", 37 | "results": [{ 38 | "dt_polys": [[[x1,y1], [x2,y2], [x3,y3], [x4,y4]], ...], 39 | "rec_texts": ["识别的文字", ...], 40 | "rec_scores": [0.99, ...] 41 | }] 42 | } 43 | """ 44 | try: 45 | data = request.json 46 | images = data.get('images', []) 47 | 48 | if not images: 49 | return jsonify({ 50 | 'status': 'error', 51 | 'msg': 'No images provided' 52 | }), 400 53 | 54 | results = [] 55 | 56 | for img_b64 in images: 57 | # 解码 base64 并保存为临时文件 58 | img_data = base64.b64decode(img_b64) 59 | 60 | with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: 61 | f.write(img_data) 62 | temp_path = f.name 63 | 64 | try: 65 | # EasyOCR 识别 66 | # 返回格式: [([[x1,y1], [x2,y2], [x3,y3], [x4,y4]], "文字", 置信度), ...] 67 | ocr_result = reader.readtext(temp_path) 68 | finally: 69 | # 清理临时文件 70 | os.remove(temp_path) 71 | 72 | # 转换为兼容 PaddleOCR 的格式 73 | dt_polys = [] 74 | rec_texts = [] 75 | rec_scores = [] 76 | 77 | for item in ocr_result: 78 | bbox, text, confidence = item 79 | # bbox 是四个点的坐标 [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] 80 | # 需要将 numpy 类型转换为 Python 原生类型 81 | bbox_list = [[int(p[0]), int(p[1])] for p in bbox] 82 | dt_polys.append(bbox_list) 83 | rec_texts.append(str(text)) 84 | rec_scores.append(float(confidence)) 85 | 86 | results.append({ 87 | 'dt_polys': dt_polys, 88 | 'rec_texts': rec_texts, 89 | 'rec_scores': rec_scores 90 | }) 91 | 92 | return jsonify({ 93 | 'status': '000', 94 | 'results': results 95 | }) 96 | 97 | except Exception as e: 98 | import traceback 99 | traceback.print_exc() 100 | return jsonify({ 101 | 'status': 'error', 102 | 'msg': str(e) 103 | }), 500 104 | 105 | 106 | @app.route('/', methods=['GET']) 107 | def health(): 108 | """健康检查接口""" 109 | return jsonify({ 110 | 'status': 'ok', 111 | 'service': 'EasyOCR', 112 | 'languages': ['ch_sim', 'en'] 113 | }) 114 | 115 | 116 | if __name__ == '__main__': 117 | print('=' * 50) 118 | print('EasyOCR 服务启动中...') 119 | print('端口: 8866') 120 | print('API: POST /predict/ocr') 121 | print('支持语言: 简体中文, 英文') 122 | print('=' * 50) 123 | app.run(host='0.0.0.0', port=8866, threaded=True) 124 | -------------------------------------------------------------------------------- /src/components/nodes/PPTAssemblerNode/PagePreview.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react"; 3 | import type { PPTPageData } from "./types"; 4 | import { ImagePreviewModal } from "@/components/ui/ImagePreviewModal"; 5 | 6 | interface PagePreviewProps { 7 | pages: PPTPageData[]; 8 | currentPage: number; 9 | onPageChange: (page: number) => void; 10 | } 11 | 12 | export function PagePreview({ 13 | pages, 14 | currentPage, 15 | onPageChange, 16 | }: PagePreviewProps) { 17 | const [showFullPreview, setShowFullPreview] = useState(false); 18 | 19 | const page = pages[currentPage]; 20 | 21 | if (!page) { 22 | return ( 23 |
24 | 无页面数据 25 |
26 | ); 27 | } 28 | 29 | // 优先使用缩略图预览,减少内存占用 30 | const previewImageUrl = page.thumbnail 31 | ? `data:image/jpeg;base64,${page.thumbnail}` 32 | : page.image 33 | ? `data:image/png;base64,${page.image}` 34 | : undefined; 35 | 36 | return ( 37 | <> 38 |
39 | {/* 预览区域 - 使用缩略图显示 */} 40 |
page.image && setShowFullPreview(true)} 43 | > 44 |
45 | {previewImageUrl ? ( 46 | {`Page 51 | ) : ( 52 |
53 | 暂无图片 54 |
55 | )} 56 |
57 | 58 | {/* 悬浮放大按钮 */} 59 | {page.image && ( 60 |
61 | 62 |
63 | )} 64 |
65 | 66 | {/* 页面信息 */} 67 |
68 | {page.heading} 69 |
70 | 71 | {/* 导航控制 */} 72 |
73 | 80 | 81 | 82 | 第 {currentPage + 1} 页 / 共 {pages.length} 页 83 | 84 | 85 | 92 |
93 |
94 | 95 | {/* 图片全屏预览 - 使用原图显示高质量图片 */} 96 | {showFullPreview && page.image && ( 97 | setShowFullPreview(false)} 100 | /> 101 | )} 102 | 103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/components/ui/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 加载指示器组件 3 | * 使用脉冲/点动画替代旋转动画,避免 GPU 合成层导致的字体模糊问题 4 | */ 5 | import { memo } from "react"; 6 | 7 | interface LoadingIndicatorProps { 8 | size?: "xs" | "sm" | "md" | "lg"; 9 | variant?: "dots" | "pulse" | "bars"; 10 | className?: string; 11 | } 12 | 13 | // 尺寸映射 14 | const sizeMap = { 15 | xs: { container: "w-3 h-3", dot: "w-1 h-1", bar: "w-0.5 h-2" }, 16 | sm: { container: "w-4 h-4", dot: "w-1 h-1", bar: "w-0.5 h-3" }, 17 | md: { container: "w-5 h-5", dot: "w-1.5 h-1.5", bar: "w-1 h-4" }, 18 | lg: { container: "w-6 h-6", dot: "w-2 h-2", bar: "w-1 h-5" }, 19 | }; 20 | 21 | /** 22 | * 点状加载动画 - 三个点依次闪烁 23 | */ 24 | const DotsLoader = memo(({ size, className }: { size: "xs" | "sm" | "md" | "lg"; className?: string }) => { 25 | const { container, dot } = sizeMap[size]; 26 | return ( 27 |
28 | 32 | 36 | 40 |
41 | ); 42 | }); 43 | DotsLoader.displayName = "DotsLoader"; 44 | 45 | /** 46 | * 脉冲圆环动画 - 圆环脉冲扩散 47 | */ 48 | const PulseLoader = memo(({ size, className }: { size: "xs" | "sm" | "md" | "lg"; className?: string }) => { 49 | const { container } = sizeMap[size]; 50 | return ( 51 |
52 | 53 | 54 |
55 | ); 56 | }); 57 | PulseLoader.displayName = "PulseLoader"; 58 | 59 | /** 60 | * 条形加载动画 - 三条竖线依次变高 61 | */ 62 | const BarsLoader = memo(({ size, className }: { size: "xs" | "sm" | "md" | "lg"; className?: string }) => { 63 | const { container, bar } = sizeMap[size]; 64 | return ( 65 |
66 | 70 | 74 | 78 |
79 | ); 80 | }); 81 | BarsLoader.displayName = "BarsLoader"; 82 | 83 | /** 84 | * 加载指示器 85 | */ 86 | export const LoadingIndicator = memo(({ 87 | size = "sm", 88 | variant = "dots", 89 | className 90 | }: LoadingIndicatorProps) => { 91 | switch (variant) { 92 | case "pulse": 93 | return ; 94 | case "bars": 95 | return ; 96 | case "dots": 97 | default: 98 | return ; 99 | } 100 | }); 101 | LoadingIndicator.displayName = "LoadingIndicator"; 102 | -------------------------------------------------------------------------------- /src/components/nodes/PromptNode.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState, useCallback } from "react"; 2 | import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; 3 | import { MessageSquare, Edit3 } from "lucide-react"; 4 | import { useFlowStore } from "@/stores/flowStore"; 5 | import { PromptEditorModal } from "@/components/ui/PromptEditorModal"; 6 | import type { PromptNodeData } from "@/types"; 7 | 8 | // 定义节点类型 9 | type PromptNode = Node; 10 | 11 | // 提示词输入节点 12 | // 使用 Modal 弹窗编辑,避免节点内滚动条导致画布模糊 13 | export const PromptNode = memo(({ id, data, selected }: NodeProps) => { 14 | const updateNodeData = useFlowStore((state) => state.updateNodeData); 15 | const [isModalOpen, setIsModalOpen] = useState(false); 16 | 17 | const prompt = data.prompt || ""; 18 | 19 | // 保存提示词 20 | const handleSave = useCallback( 21 | (value: string) => { 22 | updateNodeData(id, { prompt: value }); 23 | }, 24 | [id, updateNodeData] 25 | ); 26 | 27 | // 打开编辑弹窗 28 | const handleOpenModal = useCallback(() => { 29 | setIsModalOpen(true); 30 | }, []); 31 | 32 | // 关闭编辑弹窗 33 | const handleCloseModal = useCallback(() => { 34 | setIsModalOpen(false); 35 | }, []); 36 | 37 | return ( 38 | <> 39 |
45 | {/* 节点头部 */} 46 |
47 |
48 | 49 | {data.label} 50 |
51 | {/* 编辑按钮 */} 52 | 59 |
60 | 61 | {/* 节点内容 - 预览区域,点击打开编辑弹窗 */} 62 |
66 |
74 | {prompt ? ( 75 |

76 | {prompt} 77 |

78 | ) : ( 79 |

点击编辑提示词...

80 | )} 81 |
82 |

83 | 点击编辑 84 |

85 |
86 | 87 | {/* 输出端口 - prompt 类型 */} 88 | 94 |
95 | 96 | {/* 编辑弹窗 */} 97 | {isModalOpen && ( 98 | 104 | )} 105 | 106 | ); 107 | }); 108 | 109 | PromptNode.displayName = "PromptNode"; 110 | -------------------------------------------------------------------------------- /src/components/ui/ContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { createPortal } from "react-dom"; 3 | 4 | export interface ContextMenuItem { 5 | id: string; 6 | label: string; 7 | icon?: React.ReactNode; 8 | shortcut?: string; 9 | disabled?: boolean; 10 | danger?: boolean; 11 | divider?: boolean; 12 | onClick?: () => void; 13 | } 14 | 15 | interface ContextMenuProps { 16 | x: number; 17 | y: number; 18 | items: ContextMenuItem[]; 19 | onClose: () => void; 20 | } 21 | 22 | export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { 23 | const menuRef = useRef(null); 24 | 25 | useEffect(() => { 26 | // 点击外部关闭菜单 27 | const handleClickOutside = (e: MouseEvent) => { 28 | if (menuRef.current && !menuRef.current.contains(e.target as Node)) { 29 | onClose(); 30 | } 31 | }; 32 | 33 | // ESC 关闭菜单 34 | const handleKeyDown = (e: KeyboardEvent) => { 35 | if (e.key === "Escape") { 36 | onClose(); 37 | } 38 | }; 39 | 40 | // 滚动时关闭菜单 41 | const handleScroll = () => { 42 | onClose(); 43 | }; 44 | 45 | document.addEventListener("mousedown", handleClickOutside); 46 | document.addEventListener("keydown", handleKeyDown); 47 | document.addEventListener("scroll", handleScroll, true); 48 | 49 | return () => { 50 | document.removeEventListener("mousedown", handleClickOutside); 51 | document.removeEventListener("keydown", handleKeyDown); 52 | document.removeEventListener("scroll", handleScroll, true); 53 | }; 54 | }, [onClose]); 55 | 56 | // 调整菜单位置,确保不超出视口 57 | useEffect(() => { 58 | if (menuRef.current) { 59 | const rect = menuRef.current.getBoundingClientRect(); 60 | const viewportWidth = window.innerWidth; 61 | const viewportHeight = window.innerHeight; 62 | 63 | let adjustedX = x; 64 | let adjustedY = y; 65 | 66 | if (x + rect.width > viewportWidth) { 67 | adjustedX = viewportWidth - rect.width - 8; 68 | } 69 | if (y + rect.height > viewportHeight) { 70 | adjustedY = viewportHeight - rect.height - 8; 71 | } 72 | 73 | menuRef.current.style.left = `${adjustedX}px`; 74 | menuRef.current.style.top = `${adjustedY}px`; 75 | } 76 | }, [x, y]); 77 | 78 | return createPortal( 79 |
84 | {items.map((item, index) => { 85 | if (item.divider) { 86 | return
; 87 | } 88 | 89 | return ( 90 | 116 | ); 117 | })} 118 |
, 119 | document.body 120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /src/services/fileStorageService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 文件存储服务 3 | * 使用 Tauri 命令将图片存储为独立文件,而不是 base64 存储在 IndexedDB 中 4 | */ 5 | 6 | import { invoke } from "@tauri-apps/api/core"; 7 | import { convertFileSrc } from "@tauri-apps/api/core"; 8 | 9 | // 图片信息类型 10 | export interface ImageInfo { 11 | id: string; 12 | filename: string; 13 | path: string; 14 | size: number; 15 | created_at: number; 16 | canvas_id?: string; 17 | node_id?: string; 18 | } 19 | 20 | // 存储统计信息类型 21 | export interface StorageStats { 22 | total_size: number; 23 | image_count: number; 24 | cache_size: number; 25 | images_by_canvas: CanvasImageStats[]; 26 | } 27 | 28 | export interface CanvasImageStats { 29 | canvas_id: string; 30 | image_count: number; 31 | total_size: number; 32 | } 33 | 34 | /** 35 | * 保存图片到文件系统 36 | * @param base64Data - 图片的 base64 数据(不含 data:image/xxx;base64, 前缀) 37 | * @param canvasId - 可选的画布 ID,用于分组存储 38 | * @param nodeId - 可选的节点 ID 39 | * @returns 图片信息 40 | */ 41 | export async function saveImage( 42 | base64Data: string, 43 | canvasId?: string, 44 | nodeId?: string 45 | ): Promise { 46 | return await invoke("save_image", { 47 | base64Data, 48 | canvasId, 49 | nodeId, 50 | }); 51 | } 52 | 53 | /** 54 | * 读取图片文件(返回 base64) 55 | * @param path - 图片文件路径 56 | * @returns base64 编码的图片数据 57 | */ 58 | export async function readImage(path: string): Promise { 59 | return await invoke("read_image", { path }); 60 | } 61 | 62 | /** 63 | * 获取图片的可访问 URL 64 | * 使用 Tauri 的 convertFileSrc 将本地路径转换为 webview 可访问的 URL 65 | * @param path - 图片文件路径 66 | * @returns 可在 webview 中使用的 URL 67 | */ 68 | export function getImageUrl(path: string): string { 69 | return convertFileSrc(path); 70 | } 71 | 72 | /** 73 | * 删除图片文件 74 | * @param path - 图片文件路径 75 | */ 76 | export async function deleteImage(path: string): Promise { 77 | await invoke("delete_image", { path }); 78 | } 79 | 80 | /** 81 | * 删除画布的所有图片 82 | * @param canvasId - 画布 ID 83 | * @returns 删除的总大小(字节) 84 | */ 85 | export async function deleteCanvasImages(canvasId: string): Promise { 86 | return await invoke("delete_canvas_images", { canvasId }); 87 | } 88 | 89 | /** 90 | * 获取存储统计信息 91 | * @returns 存储统计数据 92 | */ 93 | export async function getStorageStats(): Promise { 94 | return await invoke("get_storage_stats"); 95 | } 96 | 97 | /** 98 | * 清理缓存 99 | * @returns 清理的大小(字节) 100 | */ 101 | export async function clearCache(): Promise { 102 | return await invoke("clear_cache"); 103 | } 104 | 105 | /** 106 | * 清理所有图片 107 | * @returns 清理的大小(字节) 108 | */ 109 | export async function clearAllImages(): Promise { 110 | return await invoke("clear_all_images"); 111 | } 112 | 113 | /** 114 | * 获取应用存储路径 115 | * @returns 存储目录路径 116 | */ 117 | export async function getStoragePath(): Promise { 118 | return await invoke("get_storage_path"); 119 | } 120 | 121 | /** 122 | * 列出画布的所有图片 123 | * @param canvasId - 画布 ID 124 | * @returns 图片信息列表 125 | */ 126 | export async function listCanvasImages(canvasId: string): Promise { 127 | return await invoke("list_canvas_images", { canvasId }); 128 | } 129 | 130 | /** 131 | * 格式化文件大小 132 | * @param bytes - 字节数 133 | * @returns 格式化后的字符串(如 "1.5 MB") 134 | */ 135 | export function formatFileSize(bytes: number): string { 136 | if (bytes === 0) return "0 B"; 137 | 138 | const units = ["B", "KB", "MB", "GB", "TB"]; 139 | const k = 1024; 140 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 141 | 142 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${units[i]}`; 143 | } 144 | 145 | /** 146 | * 检查是否在 Tauri 环境中运行 147 | * @returns 是否在 Tauri 环境中 148 | */ 149 | export function isTauriEnvironment(): boolean { 150 | // Tauri 2.x 使用 __TAURI_INTERNALS__ 而不是 __TAURI__ 151 | return ( 152 | typeof window !== "undefined" && 153 | ("__TAURI_INTERNALS__" in window || "__TAURI__" in window) 154 | ); 155 | } 156 | -------------------------------------------------------------------------------- /src/stores/settingsStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { persist, createJSONStorage } from "zustand/middleware"; 3 | import type { AppSettings, SettingsState, Provider, NodeProviderMapping } from "@/types"; 4 | import { tauriStorage } from "@/utils/tauriStorage"; 5 | 6 | // 默认设置 7 | const defaultSettings: AppSettings = { 8 | providers: [], 9 | nodeProviders: {}, 10 | theme: "light", 11 | }; 12 | 13 | interface SettingsStore extends SettingsState { 14 | // 基础设置 15 | updateSettings: (settings: Partial) => void; 16 | resetSettings: () => void; 17 | openSettings: () => void; 18 | closeSettings: () => void; 19 | 20 | // 供应商 CRUD 21 | addProvider: (provider: Omit) => string; 22 | updateProvider: (id: string, updates: Partial>) => void; 23 | removeProvider: (id: string) => void; 24 | getProviderById: (id: string) => Provider | undefined; 25 | 26 | // 节点供应商映射 27 | setNodeProvider: (nodeType: keyof NodeProviderMapping, providerId: string | undefined) => void; 28 | getNodeProvider: (nodeType: keyof NodeProviderMapping) => Provider | undefined; 29 | 30 | // 供应商面板状态 31 | isProviderPanelOpen: boolean; 32 | openProviderPanel: () => void; 33 | closeProviderPanel: () => void; 34 | } 35 | 36 | // 生成唯一 ID 37 | function generateId(): string { 38 | return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; 39 | } 40 | 41 | export const useSettingsStore = create()( 42 | persist( 43 | (set, get) => ({ 44 | settings: defaultSettings, 45 | isSettingsOpen: false, 46 | isProviderPanelOpen: false, 47 | 48 | updateSettings: (newSettings) => 49 | set((state) => ({ 50 | settings: { ...state.settings, ...newSettings }, 51 | })), 52 | 53 | resetSettings: () => 54 | set({ settings: defaultSettings }), 55 | 56 | openSettings: () => 57 | set({ isSettingsOpen: true }), 58 | 59 | closeSettings: () => 60 | set({ isSettingsOpen: false }), 61 | 62 | // 供应商 CRUD 63 | addProvider: (provider) => { 64 | const id = generateId(); 65 | set((state) => ({ 66 | settings: { 67 | ...state.settings, 68 | providers: [...state.settings.providers, { ...provider, id }], 69 | }, 70 | })); 71 | return id; 72 | }, 73 | 74 | updateProvider: (id, updates) => 75 | set((state) => ({ 76 | settings: { 77 | ...state.settings, 78 | providers: state.settings.providers.map((p) => 79 | p.id === id ? { ...p, ...updates } : p 80 | ), 81 | }, 82 | })), 83 | 84 | removeProvider: (id) => 85 | set((state) => { 86 | // 移除供应商时,同时清除相关的节点映射 87 | const newNodeProviders = { ...state.settings.nodeProviders }; 88 | for (const key of Object.keys(newNodeProviders) as (keyof NodeProviderMapping)[]) { 89 | if (newNodeProviders[key] === id) { 90 | delete newNodeProviders[key]; 91 | } 92 | } 93 | 94 | return { 95 | settings: { 96 | ...state.settings, 97 | providers: state.settings.providers.filter((p) => p.id !== id), 98 | nodeProviders: newNodeProviders, 99 | }, 100 | }; 101 | }), 102 | 103 | getProviderById: (id) => { 104 | return get().settings.providers.find((p) => p.id === id); 105 | }, 106 | 107 | // 节点供应商映射 108 | setNodeProvider: (nodeType, providerId) => 109 | set((state) => ({ 110 | settings: { 111 | ...state.settings, 112 | nodeProviders: { 113 | ...state.settings.nodeProviders, 114 | [nodeType]: providerId, 115 | }, 116 | }, 117 | })), 118 | 119 | getNodeProvider: (nodeType) => { 120 | const state = get(); 121 | const providerId = state.settings.nodeProviders[nodeType]; 122 | if (!providerId) return undefined; 123 | return state.settings.providers.find((p) => p.id === providerId); 124 | }, 125 | 126 | // 供应商面板状态 127 | openProviderPanel: () => 128 | set({ isProviderPanelOpen: true }), 129 | 130 | closeProviderPanel: () => 131 | set({ isProviderPanelOpen: false }), 132 | }), 133 | { 134 | name: "next-creator-settings", 135 | storage: createJSONStorage(() => tauriStorage), 136 | partialize: (state) => ({ settings: state.settings }), 137 | } 138 | ) 139 | ); 140 | -------------------------------------------------------------------------------- /src/services/updateService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GitHub 更新检测服务 3 | * 通过 GitHub API 获取最新 tag 来检测更新 4 | */ 5 | 6 | // 从 package.json 动态获取版本号需要在编译时注入,这里使用常量 7 | // 在 vite.config.ts 中会通过 define 注入 8 | declare const __APP_VERSION__: string; 9 | 10 | // GitHub 仓库信息 11 | export const GITHUB_REPO = { 12 | owner: "MoonWeSif", 13 | repo: "NextCreator", 14 | url: "https://github.com/MoonWeSif/NextCreator", 15 | }; 16 | 17 | // 项目信息 18 | export const PROJECT_INFO = { 19 | name: "NextCreator", 20 | description: "基于可视化节点的 AI 内容生成工作流工具", 21 | author: "MoonWeSif", 22 | license: "AGPL-3.0", 23 | }; 24 | 25 | export interface UpdateInfo { 26 | hasUpdate: boolean; 27 | currentVersion: string; 28 | latestVersion: string; 29 | releaseUrl: string; 30 | releaseNotes?: string; 31 | publishedAt?: string; 32 | } 33 | 34 | export interface GitHubRelease { 35 | tag_name: string; 36 | html_url: string; 37 | body?: string; 38 | published_at?: string; 39 | prerelease: boolean; 40 | draft: boolean; 41 | } 42 | 43 | /** 44 | * 获取当前应用版本号 45 | */ 46 | export function getCurrentVersion(): string { 47 | // 使用 Vite 注入的版本号,如果不存在则使用默认值 48 | return typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "0.1.1"; 49 | } 50 | 51 | /** 52 | * 比较版本号 53 | * @returns 正数表示 v1 > v2,负数表示 v1 < v2,0 表示相等 54 | */ 55 | function compareVersions(v1: string, v2: string): number { 56 | // 移除版本号前的 v 前缀 57 | const normalize = (v: string) => v.replace(/^v/, ""); 58 | const parts1 = normalize(v1).split(".").map(Number); 59 | const parts2 = normalize(v2).split(".").map(Number); 60 | 61 | const maxLen = Math.max(parts1.length, parts2.length); 62 | 63 | for (let i = 0; i < maxLen; i++) { 64 | const p1 = parts1[i] || 0; 65 | const p2 = parts2[i] || 0; 66 | if (p1 !== p2) { 67 | return p1 - p2; 68 | } 69 | } 70 | 71 | return 0; 72 | } 73 | 74 | /** 75 | * 从 GitHub API 获取最新的 release 76 | */ 77 | async function fetchLatestRelease(): Promise { 78 | const url = `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases/latest`; 79 | 80 | try { 81 | const response = await fetch(url, { 82 | headers: { 83 | Accept: "application/vnd.github.v3+json", 84 | }, 85 | }); 86 | 87 | if (response.status === 404) { 88 | // 没有 release,尝试获取 tags 89 | return null; 90 | } 91 | 92 | if (!response.ok) { 93 | throw new Error(`GitHub API 请求失败: ${response.status}`); 94 | } 95 | 96 | return await response.json(); 97 | } catch (error) { 98 | console.error("获取最新 release 失败:", error); 99 | return null; 100 | } 101 | } 102 | 103 | /** 104 | * 从 GitHub API 获取最新的 tag 105 | */ 106 | async function fetchLatestTag(): Promise { 107 | const url = `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/tags`; 108 | 109 | try { 110 | const response = await fetch(url, { 111 | headers: { 112 | Accept: "application/vnd.github.v3+json", 113 | }, 114 | }); 115 | 116 | if (!response.ok) { 117 | throw new Error(`GitHub API 请求失败: ${response.status}`); 118 | } 119 | 120 | const tags: { name: string }[] = await response.json(); 121 | 122 | if (tags.length === 0) { 123 | return null; 124 | } 125 | 126 | // 返回第一个 tag(最新的) 127 | return tags[0].name; 128 | } catch (error) { 129 | console.error("获取最新 tag 失败:", error); 130 | return null; 131 | } 132 | } 133 | 134 | /** 135 | * 检测更新 136 | */ 137 | export async function checkForUpdates(): Promise { 138 | const currentVersion = getCurrentVersion(); 139 | 140 | // 首先尝试获取 release 信息 141 | const release = await fetchLatestRelease(); 142 | 143 | if (release && !release.draft && !release.prerelease) { 144 | const latestVersion = release.tag_name; 145 | const hasUpdate = compareVersions(latestVersion, currentVersion) > 0; 146 | 147 | return { 148 | hasUpdate, 149 | currentVersion, 150 | latestVersion: latestVersion.replace(/^v/, ""), 151 | releaseUrl: release.html_url, 152 | releaseNotes: release.body, 153 | publishedAt: release.published_at, 154 | }; 155 | } 156 | 157 | // 如果没有 release,尝试获取 tag 158 | const latestTag = await fetchLatestTag(); 159 | 160 | if (latestTag) { 161 | const hasUpdate = compareVersions(latestTag, currentVersion) > 0; 162 | 163 | return { 164 | hasUpdate, 165 | currentVersion, 166 | latestVersion: latestTag.replace(/^v/, ""), 167 | releaseUrl: `${GITHUB_REPO.url}/releases/tag/${latestTag}`, 168 | }; 169 | } 170 | 171 | // 没有找到任何版本信息 172 | return { 173 | hasUpdate: false, 174 | currentVersion, 175 | latestVersion: currentVersion, 176 | releaseUrl: `${GITHUB_REPO.url}/releases`, 177 | }; 178 | } 179 | -------------------------------------------------------------------------------- /src/components/ui/PromptEditorModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect, useRef } from "react"; 2 | import { createPortal } from "react-dom"; 3 | import { X, MessageSquare, Check } from "lucide-react"; 4 | 5 | interface PromptEditorModalProps { 6 | initialValue: string; 7 | onSave: (value: string) => void; 8 | onClose: () => void; 9 | title?: string; 10 | } 11 | 12 | // 提示词编辑弹窗组件 13 | // 使用 Portal 渲染到 body,避免被节点的 transform 影响导致画布模糊 14 | export function PromptEditorModal({ 15 | initialValue, 16 | onSave, 17 | onClose, 18 | title = "编辑提示词", 19 | }: PromptEditorModalProps) { 20 | const [value, setValue] = useState(initialValue); 21 | const [isVisible, setIsVisible] = useState(false); 22 | const [isClosing, setIsClosing] = useState(false); 23 | const textareaRef = useRef(null); 24 | const isComposingRef = useRef(false); 25 | 26 | // 进入动画 27 | useEffect(() => { 28 | requestAnimationFrame(() => setIsVisible(true)); 29 | // 聚焦到文本框末尾 30 | if (textareaRef.current) { 31 | textareaRef.current.focus(); 32 | textareaRef.current.setSelectionRange(value.length, value.length); 33 | } 34 | }, []); 35 | 36 | // 关闭时先播放退出动画 37 | const handleClose = useCallback(() => { 38 | setIsClosing(true); 39 | setIsVisible(false); 40 | setTimeout(onClose, 200); 41 | }, [onClose]); 42 | 43 | // 保存并关闭 44 | const handleSave = useCallback(() => { 45 | onSave(value); 46 | handleClose(); 47 | }, [value, onSave, handleClose]); 48 | 49 | // ESC 键关闭,Ctrl/Cmd + Enter 保存 50 | useEffect(() => { 51 | const handleKeyDown = (e: KeyboardEvent) => { 52 | if (e.key === "Escape") { 53 | handleClose(); 54 | } else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { 55 | e.preventDefault(); 56 | handleSave(); 57 | } 58 | }; 59 | document.addEventListener("keydown", handleKeyDown); 60 | return () => document.removeEventListener("keydown", handleKeyDown); 61 | }, [handleClose, handleSave]); 62 | 63 | return createPortal( 64 |
72 | {/* Modal 内容 */} 73 |
e.stopPropagation()} 83 | > 84 | {/* 头部 */} 85 |
86 |
87 | 88 | {title} 89 |
90 | 96 |
97 | 98 | {/* 编辑区域 */} 99 |
100 |