diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 96c2e73aad..ce07892bc4 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -51,7 +51,7 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: main @@ -94,17 +94,18 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt-get install -y rpm - yarn build:npm linux yarn build:linux env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} NODE_OPTIONS: --max-old-space-size=8192 + MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} - name: Build Mac if: matrix.os == 'macos-latest' run: | - yarn build:npm mac yarn build:mac env: CSC_LINK: ${{ secrets.CSC_LINK }} @@ -112,19 +113,24 @@ jobs: APPLE_ID: ${{ vars.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 + MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} - name: Build Windows if: matrix.os == 'windows-latest' run: | - yarn build:npm windows yarn build:win env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} NODE_OPTIONS: --max-old-space-size=8192 + MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} - name: Rename artifacts with nightly format shell: bash @@ -220,7 +226,7 @@ jobs: shell: bash - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: path: all-artifacts merge-multiple: false diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 82058ec2a9..e3c30c2dd0 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa3aa91a19..7428aa031e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -80,12 +80,12 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt-get install -y rpm - yarn build:npm linux yarn build:linux env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 + MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }} MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} @@ -94,7 +94,6 @@ jobs: if: matrix.os == 'macos-latest' run: | sudo -H pip install setuptools - yarn build:npm mac yarn build:mac env: CSC_LINK: ${{ secrets.CSC_LINK }} @@ -104,6 +103,7 @@ jobs: APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 + MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }} MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} @@ -111,11 +111,11 @@ jobs: - name: Build Windows if: matrix.os == 'windows-latest' run: | - yarn build:npm windows yarn build:win env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=8192 + MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }} MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }} RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }} RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }} diff --git a/.gitignore b/.gitignore index b74d3d5821..39b5630926 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ coverage .vitest-cache vitest.config.*.timestamp-* +# TypeScript incremental build +.tsbuildinfo + # playwright playwright-report test-results diff --git a/.prettierignore b/.prettierignore index 3cd61d0cb9..e6e3d34935 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,4 @@ CHANGELOG*.md agents.json src/renderer/src/integration/nutstore/sso/lib AGENT.md +src/main/integration/cherryin/index.js diff --git a/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch b/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch new file mode 100644 index 0000000000..5c64db053b --- /dev/null +++ b/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch @@ -0,0 +1,30 @@ +diff --git a/index.js b/index.js +index dc071739e79876dff88e1be06a9168e294222d13..b9df7525c62bdf777e89e732e1b0c81f84d872f2 100644 +--- a/index.js ++++ b/index.js +@@ -380,7 +380,7 @@ if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { + } + } + +-if (!nativeBinding) { ++if (!nativeBinding && process.platform !== 'linux') { + if (loadErrors.length > 0) { + throw new Error( + `Cannot find native binding. ` + +@@ -392,6 +392,13 @@ if (!nativeBinding) { + throw new Error(`Failed to load native binding`) + } + +-module.exports = nativeBinding +-module.exports.OcrAccuracy = nativeBinding.OcrAccuracy +-module.exports.recognize = nativeBinding.recognize ++if (process.platform === 'linux') { ++ module.exports = {OcrAccuracy: { ++ Fast: 0, ++ Accurate: 1 ++ }, recognize: () => Promise.resolve({text: '', confidence: 1.0})} ++}else{ ++ module.exports = nativeBinding ++ module.exports.OcrAccuracy = nativeBinding.OcrAccuracy ++ module.exports.recognize = nativeBinding.recognize ++} diff --git a/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch b/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch new file mode 100644 index 0000000000..575577acec --- /dev/null +++ b/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch @@ -0,0 +1,48 @@ +diff --git a/dist/index.cjs b/dist/index.cjs +index 8e560a4406c5cc616c11bb9fd5455ac0dcf47fa3..c7cd0d65ddc971bff71e89f610de82cfdaa5a8c7 100644 +--- a/dist/index.cjs ++++ b/dist/index.cjs +@@ -413,6 +413,19 @@ var DragHandlePlugin = ({ + } + return false; + }, ++ scroll(view) { ++ if (!element || locked) { ++ return false; ++ } ++ if (view.hasFocus()) { ++ hideHandle(); ++ currentNode = null; ++ currentNodePos = -1; ++ onNodeChange == null ? void 0 : onNodeChange({ editor, node: null, pos: -1 }); ++ return false; ++ } ++ return false; ++ }, + mouseleave(_view, e) { + if (locked) { + return false; +diff --git a/dist/index.js b/dist/index.js +index 39e4c3ef9986cd25544d9d3994cf6a9ada74b145..378d9130abbfdd0e1e4f743b5b537743c9ab07d0 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -387,6 +387,19 @@ var DragHandlePlugin = ({ + } + return false; + }, ++ scroll(view) { ++ if (!element || locked) { ++ return false; ++ } ++ if (view.hasFocus()) { ++ hideHandle(); ++ currentNode = null; ++ currentNodePos = -1; ++ onNodeChange == null ? void 0 : onNodeChange({ editor, node: null, pos: -1 }); ++ return false; ++ } ++ return false; ++ }, + mouseleave(_view, e) { + if (locked) { + return false; diff --git a/README.md b/README.md index 763ff5e542..47f29b0daf 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@
Featured|HelloGitHub - kangfenmao%2Fcherry-studio | Trendshift + CherryHQ%2Fcherry-studio | Trendshift Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt
diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist index 38c887b211..6bc22e913b 100644 --- a/build/entitlements.mac.plist +++ b/build/entitlements.mac.plist @@ -8,5 +8,7 @@ com.apple.security.cs.allow-dyld-environment-variables + com.apple.security.cs.disable-library-validation + diff --git a/electron-builder.yml b/electron-builder.yml index e0b34efc38..6683c22ff2 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -62,6 +62,7 @@ files: asarUnpack: - resources/** - '**/*.{metal,exp,lib}' + - 'node_modules/@img/sharp-libvips-*/**' win: executableName: Cherry Studio artifactName: ${productName}-${version}-${arch}-setup.${ext} @@ -114,31 +115,30 @@ publish: url: https://releases.cherry-ai.com electronDownload: mirror: https://npmmirror.com/mirrors/electron/ +beforePack: scripts/before-pack.js afterPack: scripts/after-pack.js afterSign: scripts/notarize.js artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - 🎉 新增功能: - - 新增 API 密钥轮换功能,支持提供商的智能密钥管理 - - 新增 AWS Bedrock 提供商支持,扩展云端AI服务生态 - - 增强文件处理能力,支持图像文件和更多文档类型的智能识别 - - 新增文件大小限制检查和回退机制,提升文件处理稳定性 + ✨ 重要更新: + - 新增笔记模块,支持富文本编辑和管理 + - 内置 GLM-4.5-Flash 免费模型(由智谱开放平台提供) + - 内置 Qwen3-8B 免费模型(由硅基流动提供) + - 新增 Nano Banana(Gemini 2.5 Flash Image)模型支持 + - 新增系统 OCR 功能 (macOS & Windows) + - 新增图片 OCR 识别和翻译功能 + - 模型切换支持通过标签筛选 + - 翻译功能增强:历史搜索和收藏 - 🔧 优化改进: - - 重构 AI Core 类型系统,整合流文本参数定义,增强类型安全性 - - 优化思考块渲染逻辑,提升界面响应速度和用户体验 - - 重构 VertexAI 配置架构,将配置检查逻辑移至合适位置 - - 改进代码注释和文档说明,提升代码可维护性 - - 增强提供商配置验证机制,添加完整性检查 + 🔧 性能优化: + - 优化历史页面搜索性能 + - 优化拖拽列表组件交互 + - 升级 Electron 到 37.4.0 - 🐛 问题修复: - - 修复文件数据和媒体类型提取逻辑,确保正确处理 base64 数据和 MIME 类型 - - 修复 Google Vertex AI 导入路径和函数命名一致性问题 - - 修复提供商配置空值检查,增强错误处理机制 - - 修复模型标识符在图像生成测试中的问题 - - ⚡ 性能提升: - - 优化文件处理流程,添加模型支持检查,避免不必要的处理 - - 提升类型定义复用性,减少重复代码和内存占用 - - 改进日志记录机制,提供更详细的文件处理状态信息 + 🐛 修复问题: + - 修复知识库加密 PDF 文档处理 + - 修复导航栏在左侧时笔记侧边栏按钮缺失 + - 修复多个模型兼容性问题 + - 修复 MCP 相关问题 + - 其他稳定性改进 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 0f56b364e3..705d9c9b40 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -26,7 +26,20 @@ export default defineConfig({ }, build: { rollupOptions: { - external: ['@libsql/client', 'bufferutil', 'utf-8-validate'], + external: [ + '@libsql/client', + 'bufferutil', + 'utf-8-validate', + 'jsdom', + 'electron', + 'graceful-fs', + 'selection-hook', + '@napi-rs/system-ocr', + '@strongtz/win32-arm64-msvc', + 'os-proxy-config', + 'sharp', + 'turndown' + ], output: { manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包 inlineDynamicImports: true // 内联所有动态导入,这是关键配置 @@ -84,7 +97,8 @@ export default defineConfig({ '@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web'), '@cherrystudio/ai-core/provider': resolve('packages/aiCore/src/core/providers'), '@cherrystudio/ai-core/built-in/plugins': resolve('packages/aiCore/src/core/plugins/built-in'), - '@cherrystudio/ai-core': resolve('packages/aiCore/src') + '@cherrystudio/ai-core': resolve('packages/aiCore/src'), + '@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src') } }, optimizeDeps: { diff --git a/eslint.config.mjs b/eslint.config.mjs index abaadac841..133025b1fd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -122,7 +122,8 @@ export default defineConfig([ '.yarn/**', '.gitignore', 'scripts/cloudflare-worker.js', - 'src/main/integration/nutstore/sso/lib/**' + 'src/main/integration/nutstore/sso/lib/**', + 'src/main/integration/cherryin/index.js' ] } ]) diff --git a/package.json b/package.json index 9997141c8c..eb55a06ffd 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "packages/database", "packages/mcp-trace/trace-core", "packages/mcp-trace/trace-node", - "packages/mcp-trace/trace-web" + "packages/mcp-trace/trace-web", + "packages/extension-table-plus" ] } }, @@ -39,7 +40,6 @@ "build:linux": "dotenv npm run build && electron-builder --linux --x64 --arm64", "build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64", "build:linux:x64": "dotenv npm run build && electron-builder --linux --x64", - "build:npm": "node scripts/build-npm.js", "release": "node scripts/version.js", "publish": "yarn build:check && yarn release patch push", "pulish:artifacts": "cd packages/artifacts && npm publish && cd -", @@ -47,7 +47,7 @@ "generate:icons": "electron-icon-builder --input=./build/logo.png --output=build", "analyze:renderer": "VISUALIZER_RENDERER=true yarn build", "analyze:main": "VISUALIZER_MAIN=true yarn build", - "typecheck": "npm run typecheck:node && npm run typecheck:web", + "typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "check:i18n": "tsx scripts/check-i18n.ts", @@ -72,9 +72,10 @@ "dependencies": { "@libsql/client": "0.14.0", "@libsql/win32-x64-msvc": "^0.4.7", - "@napi-rs/system-ocr": "^1.0.2", + "@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch", "@strongtz/win32-arm64-msvc": "^0.4.7", "graceful-fs": "^4.2.11", + "htmlparser2": "^10.0.0", "jsdom": "26.1.0", "node-stream-zip": "^1.15.0", "officeparser": "^4.2.0", @@ -110,6 +111,7 @@ "@cherrystudio/embedjs-loader-xml": "^0.1.31", "@cherrystudio/embedjs-ollama": "^0.1.31", "@cherrystudio/embedjs-openai": "^0.1.31", + "@cherrystudio/extension-table-plus": "workspace:^", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -149,9 +151,27 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@tiptap/extension-collaboration": "^3.2.0", + "@tiptap/extension-drag-handle": "patch:@tiptap/extension-drag-handle@npm%3A3.2.0#~/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch", + "@tiptap/extension-drag-handle-react": "^3.2.0", + "@tiptap/extension-image": "^3.2.0", + "@tiptap/extension-list": "^3.2.0", + "@tiptap/extension-mathematics": "^3.2.0", + "@tiptap/extension-mention": "^3.2.0", + "@tiptap/extension-node-range": "^3.2.0", + "@tiptap/extension-table-of-contents": "^3.2.0", + "@tiptap/extension-typography": "^3.2.0", + "@tiptap/extension-underline": "^3.2.0", + "@tiptap/pm": "^3.2.0", + "@tiptap/react": "^3.2.0", + "@tiptap/starter-kit": "^3.2.0", + "@tiptap/suggestion": "^3.2.0", + "@tiptap/y-tiptap": "^3.0.0", + "@truto/turndown-plugin-gfm": "^1.0.2", "@tryfabric/martian": "^1.2.4", "@types/cli-progress": "^3", "@types/fs-extra": "^11", + "@types/he": "^1", "@types/lodash": "^4.17.5", "@types/markdown-it": "^14", "@types/md5": "^2.3.5", @@ -162,6 +182,7 @@ "@types/react-infinite-scroll-component": "^5.0.0", "@types/react-transition-group": "^4.4.12", "@types/tinycolor2": "^1", + "@types/turndown": "^5.0.5", "@types/word-extractor": "^1", "@uiw/codemirror-extensions-langs": "^4.25.1", "@uiw/codemirror-themes-all": "^4.25.1", @@ -181,17 +202,20 @@ "axios": "^1.7.3", "browser-image-compression": "^2.0.2", "chardet": "^2.1.0", + "chokidar": "^4.0.3", "cli-progress": "^3.12.0", "code-inspector-plugin": "^0.20.14", "color": "^5.0.0", + "concurrently": "^9.2.1", "country-flag-emoji-polyfill": "0.1.8", "dayjs": "^1.11.11", "dexie": "^4.0.8", "dexie-react-hooks": "^1.1.7", "diff": "^8.0.2", "docx": "^9.0.2", + "dompurify": "^3.2.6", "dotenv-cli": "^7.4.2", - "electron": "37.3.1", + "electron": "37.4.0", "electron-builder": "26.0.15", "electron-devtools-installer": "^3.2.0", "electron-store": "^8.2.0", @@ -211,7 +235,10 @@ "franc-min": "^6.2.0", "fs-extra": "^11.2.0", "google-auth-library": "^9.15.1", + "he": "^1.2.0", + "html-tags": "^5.1.0", "html-to-image": "^1.11.13", + "htmlparser2": "^10.0.0", "husky": "^9.1.7", "i18next": "^23.11.5", "iconv-lite": "^0.6.3", @@ -268,11 +295,13 @@ "shiki": "^3.12.0", "strict-url-sanitise": "^0.0.1", "string-width": "^7.2.0", + "striptags": "^3.2.0", "styled-components": "^6.1.11", "tar": "^7.4.3", "tiny-pinyin": "^1.3.2", "tokenx": "^1.1.0", "tsx": "^4.20.3", + "turndown-plugin-gfm": "^1.0.2", "typescript": "^5.6.2", "undici": "6.21.2", "unified": "^11.0.5", @@ -283,6 +312,8 @@ "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0", "word-extractor": "^1.0.4", + "y-protocols": "^1.0.6", + "yjs": "^13.6.27", "zipread": "^1.3.3", "zod": "^3.25.74" }, diff --git a/packages/extension-table-plus/CHANGELOG.md b/packages/extension-table-plus/CHANGELOG.md new file mode 100755 index 0000000000..6f24f5b060 --- /dev/null +++ b/packages/extension-table-plus/CHANGELOG.md @@ -0,0 +1,1457 @@ +# Change Log + +## 3.0.9 + +### Patch Changes + +- @tiptap/core@3.0.9 +- @tiptap/pm@3.0.9 + +## 3.0.8 + +### Patch Changes + +- @tiptap/core@3.0.8 +- @tiptap/pm@3.0.8 + +## 3.0.7 + +### Patch Changes + +- @tiptap/core@3.0.7 +- @tiptap/pm@3.0.7 + +## 3.0.6 + +### Patch Changes + +- Updated dependencies [2e71d05] + - @tiptap/core@3.0.6 + - @tiptap/pm@3.0.6 + +## 3.0.5 + +### Patch Changes + +- @tiptap/core@3.0.5 +- @tiptap/pm@3.0.5 + +## 3.0.4 + +### Patch Changes + +- Updated dependencies [7ed03fa] + - @tiptap/core@3.0.4 + - @tiptap/pm@3.0.4 + +## 3.0.3 + +### Patch Changes + +- Updated dependencies [75cabde] + - @tiptap/core@3.0.3 + - @tiptap/pm@3.0.3 + +## 3.0.2 + +### Patch Changes + +- @tiptap/core@3.0.2 +- @tiptap/pm@3.0.2 + +## 3.0.1 + +### Major Changes + +- a92f4a6: We are now building packages with tsup which does not support UMD builds, please repackage if you require UMD builds + +### Minor Changes + +- 131c7d0: This adds all of the table packages to the `@tiptap/extension-table` package. + + ## TableKit + + The `TableKit` export allows configuring the entire table with one extension, and is the recommended way of using the table extensions. + + ```ts + import { TableKit } from '@tiptap/extension-table' + + new Editor({ + extensions: [ + TableKit.configure({ + table: { + HTMLAttributes: { + class: 'table', + }, + }, + tableCell: { + HTMLAttributes: { + class: 'table-cell', + }, + }, + tableHeader: { + HTMLAttributes: { + class: 'table-header', + }, + }, + tableRow: { + HTMLAttributes: { + class: 'table-row', + }, + }, + }), + ], + }) + ``` + + ## Table repackaging + + Since we've moved the code out of the table extensions to the `@tiptap/extension-table` package, you can remove the following packages from your project: + + ```bash + npm uninstall @tiptap/extension-table-header @tiptap/extension-table-cell @tiptap/extension-table-row + ``` + + And replace them with the new `@tiptap/extension-table` package: + + ```bash + npm install @tiptap/extension-table + ``` + + ## Want to use the extensions separately? + + For more control, you can also use the extensions separately. + + ### Table + + This extension adds a table to the editor. + + Migrate from default export to named export: + + ```diff + - import Table from '@tiptap/extension-table' + + import { Table } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { Table } from '@tiptap/extension-table' + ``` + + ### TableCell + + This extension adds a table cell to the editor. + + Migrate from `@tiptap/extension-table-cell` to `@tiptap/extension-table`: + + ```diff + - import TableCell from '@tiptap/extension-table-cell' + + import { TableCell } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableCell } from '@tiptap/extension-table' + ``` + + ### TableHeader + + This extension adds a table header to the editor. + + Migrate from `@tiptap/extension-table-header` to `@tiptap/extension-table`: + + ```diff + - import TableHeader from '@tiptap/extension-table-header' + + import { TableHeader } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableHeader } from '@tiptap/extension-table' + ``` + + ### TableRow + + This extension adds a table row to the editor. + + Migrate from `@tiptap/extension-table-row` to `@tiptap/extension-table`: + + ```diff + - import TableRow from '@tiptap/extension-table-row' + + import { TableRow } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableRow } from '@tiptap/extension-table' + ``` + +### Patch Changes + +- 1b4c82b: We are now using pnpm package aliases for versions to enable better version pinning for the monorepository +- 89bd9c7: Enforce type imports so that the bundler ignores TypeScript type imports when generating the index.js file of the dist directory +- 991f43c: Added new export for TableView class +- 8c69002: Synced beta with stable features +- Updated dependencies [1b4c82b] +- Updated dependencies [1e91f9b] +- Updated dependencies [a92f4a6] +- Updated dependencies [8de8e13] +- Updated dependencies [20f68f6] +- Updated dependencies [5e957e5] +- Updated dependencies [89bd9c7] +- Updated dependencies [d0fda30] +- Updated dependencies [0e3207f] +- Updated dependencies [37913d5] +- Updated dependencies [28c5418] +- Updated dependencies [32958d6] +- Updated dependencies [12bb31a] +- Updated dependencies [9f207a6] +- Updated dependencies [412e1bd] +- Updated dependencies [062afaf] +- Updated dependencies [ff8eed6] +- Updated dependencies [704f462] +- Updated dependencies [95b8c71] +- Updated dependencies [8c69002] +- Updated dependencies [664834f] +- Updated dependencies [ac897e7] +- Updated dependencies [087d114] +- Updated dependencies [32958d6] +- Updated dependencies [fc17b21] +- Updated dependencies [62b0877] +- Updated dependencies [e20006b] +- Updated dependencies [5ba480b] +- Updated dependencies [d6c7558] +- Updated dependencies [062afaf] +- Updated dependencies [9ceeab4] +- Updated dependencies [32958d6] +- Updated dependencies [bf835b0] +- Updated dependencies [4e2f6d8] +- Updated dependencies [32958d6] + - @tiptap/core@3.0.1 + - @tiptap/pm@3.0.1 + +## 3.0.0-beta.30 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.30 +- @tiptap/pm@3.0.0-beta.30 + +## 3.0.0-beta.29 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.29 +- @tiptap/pm@3.0.0-beta.29 + +## 3.0.0-beta.28 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.28 +- @tiptap/pm@3.0.0-beta.28 + +## 3.0.0-beta.27 + +### Patch Changes + +- Updated dependencies [412e1bd] + - @tiptap/core@3.0.0-beta.27 + - @tiptap/pm@3.0.0-beta.27 + +## 3.0.0-beta.26 + +### Patch Changes + +- Updated dependencies [5ba480b] + - @tiptap/core@3.0.0-beta.26 + - @tiptap/pm@3.0.0-beta.26 + +## 3.0.0-beta.25 + +### Patch Changes + +- Updated dependencies [4e2f6d8] + - @tiptap/core@3.0.0-beta.25 + - @tiptap/pm@3.0.0-beta.25 + +## 3.0.0-beta.24 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.24 +- @tiptap/pm@3.0.0-beta.24 + +## 3.0.0-beta.23 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.23 +- @tiptap/pm@3.0.0-beta.23 + +## 3.0.0-beta.22 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.22 +- @tiptap/pm@3.0.0-beta.22 + +## 3.0.0-beta.21 + +### Patch Changes + +- Updated dependencies [813674c] +- Updated dependencies [fc17b21] + - @tiptap/core@3.0.0-beta.21 + - @tiptap/pm@3.0.0-beta.21 + +## 3.0.0-beta.20 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.20 +- @tiptap/pm@3.0.0-beta.20 + +## 3.0.0-beta.19 + +### Patch Changes + +- Updated dependencies [9ceeab4] + - @tiptap/core@3.0.0-beta.19 + - @tiptap/pm@3.0.0-beta.19 + +## 3.0.0-beta.18 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.18 +- @tiptap/pm@3.0.0-beta.18 + +## 3.0.0-beta.17 + +### Patch Changes + +- Updated dependencies [e20006b] + - @tiptap/core@3.0.0-beta.17 + - @tiptap/pm@3.0.0-beta.17 + +## 3.0.0-beta.16 + +### Patch Changes + +- Updated dependencies [ac897e7] +- Updated dependencies [bf835b0] + - @tiptap/core@3.0.0-beta.16 + - @tiptap/pm@3.0.0-beta.16 + +## 3.0.0-beta.15 + +### Patch Changes + +- Updated dependencies [087d114] + - @tiptap/core@3.0.0-beta.15 + - @tiptap/pm@3.0.0-beta.15 + +## 3.0.0-beta.14 + +### Patch Changes + +- Updated dependencies [95b8c71] + - @tiptap/core@3.0.0-beta.14 + - @tiptap/pm@3.0.0-beta.14 + +## 3.0.0-beta.13 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.13 +- @tiptap/pm@3.0.0-beta.13 + +## 3.0.0-beta.12 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.12 +- @tiptap/pm@3.0.0-beta.12 + +## 3.0.0-beta.11 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.11 +- @tiptap/pm@3.0.0-beta.11 + +## 3.0.0-beta.10 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.10 +- @tiptap/pm@3.0.0-beta.10 + +## 3.0.0-beta.9 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.9 +- @tiptap/pm@3.0.0-beta.9 + +## 3.0.0-beta.8 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.8 +- @tiptap/pm@3.0.0-beta.8 + +## 3.0.0-beta.7 + +### Patch Changes + +- Updated dependencies [d0fda30] + - @tiptap/core@3.0.0-beta.7 + - @tiptap/pm@3.0.0-beta.7 + +## 3.0.0-beta.6 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.6 +- @tiptap/pm@3.0.0-beta.6 + +## 3.0.0-beta.5 + +### Patch Changes + +- 8c69002: Synced beta with stable features +- Updated dependencies [8c69002] +- Updated dependencies [62b0877] + - @tiptap/core@3.0.0-beta.5 + - @tiptap/pm@3.0.0-beta.5 + +## 3.0.0-beta.4 + +### Patch Changes + +- Updated dependencies [5e957e5] +- Updated dependencies [9f207a6] + - @tiptap/core@3.0.0-beta.4 + - @tiptap/pm@3.0.0-beta.4 + +## 3.0.0-beta.3 + +### Patch Changes + +- 1b4c82b: We are now using pnpm package aliases for versions to enable better version pinning for the monorepository +- Updated dependencies [1b4c82b] + - @tiptap/core@3.0.0-beta.3 + - @tiptap/pm@3.0.0-beta.3 + +## 3.0.0-beta.2 + +## 3.0.0-beta.1 + +### Patch Changes + +- 991f43c: Added new export for TableView class + +## 3.0.0-beta.0 + +## 3.0.0-next.8 + +## 3.0.0-next.7 + +### Patch Changes + +- 89bd9c7: Enforce type imports so that the bundler ignores TypeScript type imports when generating the index.js file of the dist directory + +## 3.0.0-next.6 + +### Major Changes + +- a92f4a6: We are now building packages with tsup which does not support UMD builds, please repackage if you require UMD builds + +### Minor Changes + +- 131c7d0: This adds all of the table packages to the `@tiptap/extension-table` package. + + ## TableKit + + The `TableKit` export allows configuring the entire table with one extension, and is the recommended way of using the table extensions. + + ```ts + import { TableKit } from '@tiptap/extension-table' + + new Editor({ + extensions: [ + TableKit.configure({ + table: { + HTMLAttributes: { + class: 'table', + }, + }, + tableCell: { + HTMLAttributes: { + class: 'table-cell', + }, + }, + tableHeader: { + HTMLAttributes: { + class: 'table-header', + }, + }, + tableRow: { + HTMLAttributes: { + class: 'table-row', + }, + }, + }), + ], + }) + ``` + + ## Table repackaging + + Since we've moved the code out of the table extensions to the `@tiptap/extension-table` package, you can remove the following packages from your project: + + ```bash + npm uninstall @tiptap/extension-table-header @tiptap/extension-table-cell @tiptap/extension-table-row + ``` + + And replace them with the new `@tiptap/extension-table` package: + + ```bash + npm install @tiptap/extension-table + ``` + + ## Want to use the extensions separately? + + For more control, you can also use the extensions separately. + + ### Table + + This extension adds a table to the editor. + + Migrate from default export to named export: + + ```diff + - import Table from '@tiptap/extension-table' + + import { Table } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { Table } from '@tiptap/extension-table' + ``` + + ### TableCell + + This extension adds a table cell to the editor. + + Migrate from `@tiptap/extension-table-cell` to `@tiptap/extension-table`: + + ```diff + - import TableCell from '@tiptap/extension-table-cell' + + import { TableCell } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableCell } from '@tiptap/extension-table' + ``` + + ### TableHeader + + This extension adds a table header to the editor. + + Migrate from `@tiptap/extension-table-header` to `@tiptap/extension-table`: + + ```diff + - import TableHeader from '@tiptap/extension-table-header' + + import { TableHeader } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableHeader } from '@tiptap/extension-table' + ``` + + ### TableRow + + This extension adds a table row to the editor. + + Migrate from `@tiptap/extension-table-row` to `@tiptap/extension-table`: + + ```diff + - import TableRow from '@tiptap/extension-table-row' + + import { TableRow } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableRow } from '@tiptap/extension-table' + ``` + +## 3.0.0-next.5 + +## 3.0.0-next.4 + +## 3.0.0-next.3 + +## 3.0.0-next.2 + +## 3.0.0-next.1 + +### Major Changes + +- a92f4a6: We are now building packages with tsup which does not support UMD builds, please repackage if you require UMD builds + +### Patch Changes + +- Updated dependencies [a92f4a6] +- Updated dependencies [da76972] + - @tiptap/core@3.0.0-next.1 + - @tiptap/pm@3.0.0-next.1 + +## 3.0.0-next.0 + +### Patch Changes + +- Updated dependencies [0ec0af6] + - @tiptap/core@3.0.0-next.0 + - @tiptap/pm@3.0.0-next.0 + +## 2.12.0 + +## 2.11.9 + +## 2.11.8 + +## 2.11.7 + +### Patch Changes + +- a44a311: Added new export for TableView class + +## 2.11.6 + +## 2.11.5 + +## 2.11.4 + +## 2.11.3 + +## 2.11.2 + +## 2.11.1 + +## 2.11.0 + +## 2.10.4 + +## 2.10.3 + +## 2.10.2 + +## 2.10.1 + +## 2.10.0 + +### Patch Changes + +- 7619215: enforce cellMinWidth even on column not resized by the user, fixes #5435 + +## 2.9.1 + +## 2.9.0 + +## 2.8.0 + +### Minor Changes + +- 131c7d0: This change repackages all of the table extensions to be within the `@tiptap/extension-table` package (other packages are just a re-export of the `@tiptap/extension-table` package). It also adds the `TableKit` export which will allow configuring the entire table with one extension. + +## 2.5.8 + +### Patch Changes + +- Updated dependencies [a08bf85] + - @tiptap/core@2.5.8 + - @tiptap/pm@2.5.8 + +## 2.5.7 + +### Patch Changes + +- Updated dependencies [b012471] +- Updated dependencies [cc3497e] + - @tiptap/core@2.5.7 + - @tiptap/pm@2.5.7 + +## 2.5.6 + +### Patch Changes + +- c7f5550: Set correct `min-width` for a table fixes #5217 +- Updated dependencies [b5c1b32] +- Updated dependencies [618bca9] +- Updated dependencies [35682d1] +- Updated dependencies [2104f0f] + - @tiptap/pm@2.5.6 + - @tiptap/core@2.5.6 + +## 2.5.5 + +### Patch Changes + +- Updated dependencies [4cca382] +- Updated dependencies [3b67e8a] + - @tiptap/core@2.5.5 + - @tiptap/pm@2.5.5 + +## 2.5.4 + +### Patch Changes + +- dd7f9ac: There was an issue with the cjs bundling of packages and default exports, now we resolve default exports in legacy compatible way +- Updated dependencies [dd7f9ac] + - @tiptap/core@2.5.4 + - @tiptap/pm@2.5.4 + +## 2.5.3 + +### Patch Changes + +- @tiptap/core@2.5.3 +- @tiptap/pm@2.5.3 + +## 2.5.2 + +### Patch Changes + +- Updated dependencies [07f4c03] + - @tiptap/core@2.5.2 + - @tiptap/pm@2.5.2 + +## 2.5.1 + +### Patch Changes + +- @tiptap/core@2.5.1 +- @tiptap/pm@2.5.1 + +## 2.5.0 + +### Patch Changes + +- Updated dependencies [fb45149] +- Updated dependencies [fb45149] +- Updated dependencies [fb45149] +- Updated dependencies [fb45149] + - @tiptap/core@2.5.0 + - @tiptap/pm@2.5.0 + +## 2.5.0-pre.16 + +### Patch Changes + +- @tiptap/core@2.5.0-pre.16 +- @tiptap/pm@2.5.0-pre.16 + +## 2.5.0-pre.15 + +### Patch Changes + +- @tiptap/core@2.5.0-pre.15 +- @tiptap/pm@2.5.0-pre.15 + +## 2.5.0-pre.14 + +### Patch Changes + +- @tiptap/core@2.5.0-pre.14 +- @tiptap/pm@2.5.0-pre.14 + +## 2.5.0-pre.13 + +### Patch Changes + +- Updated dependencies [74a37ff] + - @tiptap/core@2.5.0-pre.13 + - @tiptap/pm@2.5.0-pre.13 + +## 2.5.0-pre.12 + +### Patch Changes + +- Updated dependencies [74a37ff] + - @tiptap/core@2.5.0-pre.12 + - @tiptap/pm@2.5.0-pre.12 + +## 2.5.0-pre.11 + +### Patch Changes + +- Updated dependencies [74a37ff] + - @tiptap/core@2.5.0-pre.11 + - @tiptap/pm@2.5.0-pre.11 + +## 2.5.0-pre.10 + +### Patch Changes + +- Updated dependencies [74a37ff] + - @tiptap/core@2.5.0-pre.10 + - @tiptap/pm@2.5.0-pre.10 + +## 2.5.0-pre.9 + +### Patch Changes + +- Updated dependencies [14a00f4] + - @tiptap/core@2.5.0-pre.9 + - @tiptap/pm@2.5.0-pre.9 + +## 2.5.0-pre.8 + +### Patch Changes + +- Updated dependencies [509676e] + - @tiptap/core@2.5.0-pre.8 + - @tiptap/pm@2.5.0-pre.8 + +## 2.5.0-pre.7 + +### Patch Changes + +- @tiptap/core@2.5.0-pre.7 +- @tiptap/pm@2.5.0-pre.7 + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [2.4.0](https://github.com/ueberdosis/tiptap/compare/v2.3.2...v2.4.0) (2024-05-14) + +### Features + +- added jsdocs ([#4356](https://github.com/ueberdosis/tiptap/issues/4356)) ([b941eea](https://github.com/ueberdosis/tiptap/commit/b941eea6daba09d48a5d18ccc1b9a1d84b2249dd)) + +## [2.3.2](https://github.com/ueberdosis/tiptap/compare/v2.3.1...v2.3.2) (2024-05-08) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.3.1](https://github.com/ueberdosis/tiptap/compare/v2.3.0...v2.3.1) (2024-04-30) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.3.0](https://github.com/ueberdosis/tiptap/compare/v2.2.6...v2.3.0) (2024-04-09) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.6](https://github.com/ueberdosis/tiptap/compare/v2.2.5...v2.2.6) (2024-04-06) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.5](https://github.com/ueberdosis/tiptap/compare/v2.2.4...v2.2.5) (2024-04-05) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.4](https://github.com/ueberdosis/tiptap/compare/v2.2.3...v2.2.4) (2024-02-23) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.3](https://github.com/ueberdosis/tiptap/compare/v2.2.2...v2.2.3) (2024-02-15) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.2](https://github.com/ueberdosis/tiptap/compare/v2.2.1...v2.2.2) (2024-02-07) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.1](https://github.com/ueberdosis/tiptap/compare/v2.2.0...v2.2.1) (2024-01-31) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.2.0](https://github.com/ueberdosis/tiptap/compare/v2.1.16...v2.2.0) (2024-01-29) + +### Bug Fixes + +- fix imports, fix demos, unpin y-prosemirror ([681aa57](https://github.com/ueberdosis/tiptap/commit/681aa577bff500015c3f925e300c55a71c73efaf)) + +# [2.2.0-rc.8](https://github.com/ueberdosis/tiptap/compare/v2.1.14...v2.2.0-rc.8) (2024-01-08) + +# [2.2.0-rc.7](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.6...v2.2.0-rc.7) (2023-11-27) + +# [2.2.0-rc.6](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.5...v2.2.0-rc.6) (2023-11-23) + +# [2.2.0-rc.4](https://github.com/ueberdosis/tiptap/compare/v2.1.11...v2.2.0-rc.4) (2023-10-10) + +# [2.2.0-rc.3](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.2...v2.2.0-rc.3) (2023-08-18) + +# [2.2.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.0...v2.2.0-rc.1) (2023-08-18) + +# [2.2.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.1.5...v2.2.0-rc.0) (2023-08-18) + +## [2.1.16](https://github.com/ueberdosis/tiptap/compare/v2.1.15...v2.1.16) (2024-01-10) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.15](https://github.com/ueberdosis/tiptap/compare/v2.1.14...v2.1.15) (2024-01-08) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.14](https://github.com/ueberdosis/tiptap/compare/v2.1.13...v2.1.14) (2024-01-08) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.13](https://github.com/ueberdosis/tiptap/compare/v2.1.12...v2.1.13) (2023-11-30) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.12](https://github.com/ueberdosis/tiptap/compare/v2.1.11...v2.1.12) (2023-10-11) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.11](https://github.com/ueberdosis/tiptap/compare/v2.1.10...v2.1.11) (2023-09-20) + +### Reverts + +- Revert "v2.2.11" ([6aa755a](https://github.com/ueberdosis/tiptap/commit/6aa755a04b9955fc175c7ab33dee527d0d5deef0)) + +## [2.1.10](https://github.com/ueberdosis/tiptap/compare/v2.1.9...v2.1.10) (2023-09-15) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.9](https://github.com/ueberdosis/tiptap/compare/v2.1.8...v2.1.9) (2023-09-14) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.8](https://github.com/ueberdosis/tiptap/compare/v2.1.7...v2.1.8) (2023-09-04) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.7](https://github.com/ueberdosis/tiptap/compare/v2.1.6...v2.1.7) (2023-09-04) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.6](https://github.com/ueberdosis/tiptap/compare/v2.1.5...v2.1.6) (2023-08-18) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.5](https://github.com/ueberdosis/tiptap/compare/v2.1.4...v2.1.5) (2023-08-18) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.4](https://github.com/ueberdosis/tiptap/compare/v2.1.3...v2.1.4) (2023-08-18) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.3](https://github.com/ueberdosis/tiptap/compare/v2.1.2...v2.1.3) (2023-08-18) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.2](https://github.com/ueberdosis/tiptap/compare/v2.1.1...v2.1.2) (2023-08-17) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.1](https://github.com/ueberdosis/tiptap/compare/v2.1.0...v2.1.1) (2023-08-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.14...v2.1.0) (2023-08-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.14](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.13...v2.1.0-rc.14) (2023-08-11) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.13](https://github.com/ueberdosis/tiptap/compare/v2.0.4...v2.1.0-rc.13) (2023-08-11) + +# [2.1.0-rc.12](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.11...v2.1.0-rc.12) (2023-07-14) + +# [2.1.0-rc.11](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.10...v2.1.0-rc.11) (2023-07-07) + +# [2.1.0-rc.10](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.9...v2.1.0-rc.10) (2023-07-07) + +# [2.1.0-rc.9](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.8...v2.1.0-rc.9) (2023-06-15) + +# [2.1.0-rc.8](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.7...v2.1.0-rc.8) (2023-05-25) + +# [2.1.0-rc.5](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.4...v2.1.0-rc.5) (2023-05-25) + +# [2.1.0-rc.4](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.3...v2.1.0-rc.4) (2023-04-27) + +# [2.1.0-rc.3](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.2...v2.1.0-rc.3) (2023-04-26) + +# [2.1.0-rc.2](https://github.com/ueberdosis/tiptap/compare/v2.0.3...v2.1.0-rc.2) (2023-04-26) + +# [2.1.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.0...v2.1.0-rc.1) (2023-04-12) + +# [2.1.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.1.0-rc.0) (2023-04-05) + +### Bug Fixes + +- Update peerDependencies to fix lerna version tasks ([#3914](https://github.com/ueberdosis/tiptap/issues/3914)) ([0c1bba3](https://github.com/ueberdosis/tiptap/commit/0c1bba3137b535776bcef95ff3c55e13f5a2db46)) + +# [2.1.0-rc.12](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.11...v2.1.0-rc.12) (2023-07-14) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.11](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.10...v2.1.0-rc.11) (2023-07-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.10](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.9...v2.1.0-rc.10) (2023-07-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.9](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.8...v2.1.0-rc.9) (2023-06-15) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.8](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.7...v2.1.0-rc.8) (2023-05-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.7](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.6...v2.1.0-rc.7) (2023-05-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.6](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.5...v2.1.0-rc.6) (2023-05-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.5](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.4...v2.1.0-rc.5) (2023-05-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.4](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.3...v2.1.0-rc.4) (2023-04-27) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.3](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.2...v2.1.0-rc.3) (2023-04-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.2](https://github.com/ueberdosis/tiptap/compare/v2.0.3...v2.1.0-rc.2) (2023-04-26) + +# [2.1.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.0...v2.1.0-rc.1) (2023-04-12) + +# [2.1.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.1.0-rc.0) (2023-04-05) + +### Bug Fixes + +- Update peerDependencies to fix lerna version tasks ([#3914](https://github.com/ueberdosis/tiptap/issues/3914)) ([0c1bba3](https://github.com/ueberdosis/tiptap/commit/0c1bba3137b535776bcef95ff3c55e13f5a2db46)) + +# [2.1.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.0...v2.1.0-rc.1) (2023-04-12) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.1.0-rc.0) (2023-04-05) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.0.3](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.0.3) (2023-04-13) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.0.2](https://github.com/ueberdosis/tiptap/compare/v2.0.1...v2.0.2) (2023-04-03) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.0.1](https://github.com/ueberdosis/tiptap/compare/v2.0.0...v2.0.1) (2023-03-30) + +### Bug Fixes + +- Update peerDependencies to fix lerna version tasks ([#3914](https://github.com/ueberdosis/tiptap/issues/3914)) ([0534f76](https://github.com/ueberdosis/tiptap/commit/0534f76401bf5399c01ca7f39d87f7221d91b4f7)) + +# [2.0.0-beta.220](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.219...v2.0.0-beta.220) (2023-02-28) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.219](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.218...v2.0.0-beta.219) (2023-02-27) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.218](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.217...v2.0.0-beta.218) (2023-02-18) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.217](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.216...v2.0.0-beta.217) (2023-02-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.216](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.215...v2.0.0-beta.216) (2023-02-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.215](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.214...v2.0.0-beta.215) (2023-02-08) + +### Bug Fixes + +- fix builds including prosemirror ([a380ec4](https://github.com/ueberdosis/tiptap/commit/a380ec41d198ebacc80cea9e79b0a8aa3092618a)) + +# [2.0.0-beta.214](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.213...v2.0.0-beta.214) (2023-02-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.213](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.212...v2.0.0-beta.213) (2023-02-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.212](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.211...v2.0.0-beta.212) (2023-02-03) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.211](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.210...v2.0.0-beta.211) (2023-02-02) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.210](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.209...v2.0.0-beta.210) (2023-02-02) + +### Features + +- **pm:** new prosemirror package for dependency resolving ([f387ad3](https://github.com/ueberdosis/tiptap/commit/f387ad3dd4c2b30eaea33fb0ba0b42e0cd39263b)) + +# [2.0.0-beta.209](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.208...v2.0.0-beta.209) (2022-12-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.208](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.207...v2.0.0-beta.208) (2022-12-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.207](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.206...v2.0.0-beta.207) (2022-12-08) + +### Bug Fixes + +- **extension-table:** add prosemirror-tables to peerDependencies ([c187e0e](https://github.com/ueberdosis/tiptap/commit/c187e0e2586f1d0069e93ab41a144ae14d5172e0)) + +# [2.0.0-beta.206](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.205...v2.0.0-beta.206) (2022-12-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.205](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.204...v2.0.0-beta.205) (2022-12-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.204](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.203...v2.0.0-beta.204) (2022-11-25) + +### Bug Fixes + +- **core:** rename esm modules to esm.js ([c1a0c3a](https://github.com/ueberdosis/tiptap/commit/c1a0c3ae43baac9dd5ed90903d3a0d4eaeea7702)) + +# [2.0.0-beta.203](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.202...v2.0.0-beta.203) (2022-11-24) + +### Bug Fixes + +- **extension/table:** move dependency from @\_ueberdosis to [@tiptap](https://github.com/tiptap) ([#3448](https://github.com/ueberdosis/tiptap/issues/3448)) ([31c3a9a](https://github.com/ueberdosis/tiptap/commit/31c3a9aad9eb37f445eadcd27135611291178ca6)) + +# [2.0.0-beta.202](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.201...v2.0.0-beta.202) (2022-11-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.201](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.200...v2.0.0-beta.201) (2022-11-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.200](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.199...v2.0.0-beta.200) (2022-11-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.199](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.198...v2.0.0-beta.199) (2022-09-30) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.198](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.197...v2.0.0-beta.198) (2022-09-29) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.197](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.196...v2.0.0-beta.197) (2022-09-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.196](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.195...v2.0.0-beta.196) (2022-09-20) + +### Bug Fixes + +- **types:** fix link and table type errors ([#3208](https://github.com/ueberdosis/tiptap/issues/3208)) ([ae13cf6](https://github.com/ueberdosis/tiptap/commit/ae13cf61ad0ead942515d8c597f96a4b4d026412)) + +# [2.0.0-beta.195](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.194...v2.0.0-beta.195) (2022-09-14) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.194](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.193...v2.0.0-beta.194) (2022-09-11) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.54](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.53...@tiptap/extension-table@2.0.0-beta.54) (2022-06-27) + +### Bug Fixes + +- **maintainment:** fix cjs issues with prosemirror-tables ([eb92597](https://github.com/ueberdosis/tiptap/commit/eb925976038fbf59f6ba333ccc57ea84113da00e)) + +# [2.0.0-beta.53](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.52...@tiptap/extension-table@2.0.0-beta.53) (2022-06-20) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.52](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.50...@tiptap/extension-table@2.0.0-beta.52) (2022-06-17) + +### Reverts + +- Revert "Publish" ([9c38d27](https://github.com/ueberdosis/tiptap/commit/9c38d2713e6feac5645ad9c1bfc57abdbf054576)) + +# [2.0.0-beta.50](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.50...@tiptap/extension-table@2.0.0-beta.50) (2022-06-17) + +### Reverts + +- Revert "Publish" ([9c38d27](https://github.com/ueberdosis/tiptap/commit/9c38d2713e6feac5645ad9c1bfc57abdbf054576)) + +# [2.0.0-beta.49](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.48...@tiptap/extension-table@2.0.0-beta.49) (2022-05-18) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.48](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.47...@tiptap/extension-table@2.0.0-beta.48) (2022-01-25) + +### Bug Fixes + +- use toggleHeader from prosemirror-tables ([#2412](https://github.com/ueberdosis/tiptap/issues/2412)), fix [#548](https://github.com/ueberdosis/tiptap/issues/548) ([c6bea9a](https://github.com/ueberdosis/tiptap/commit/c6bea9aa5c4d38523f2f1095a570cdfc6936392e)) + +# [2.0.0-beta.47](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.46...@tiptap/extension-table@2.0.0-beta.47) (2022-01-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.46](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.45...@tiptap/extension-table@2.0.0-beta.46) (2022-01-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.45](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.44...@tiptap/extension-table@2.0.0-beta.45) (2021-12-03) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.44](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.43...@tiptap/extension-table@2.0.0-beta.44) (2021-12-02) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.43](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.42...@tiptap/extension-table@2.0.0-beta.43) (2021-11-17) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.42](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.41...@tiptap/extension-table@2.0.0-beta.42) (2021-11-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.41](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.40...@tiptap/extension-table@2.0.0-beta.41) (2021-11-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.40](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.39...@tiptap/extension-table@2.0.0-beta.40) (2021-11-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.39](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.38...@tiptap/extension-table@2.0.0-beta.39) (2021-11-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.38](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.37...@tiptap/extension-table@2.0.0-beta.38) (2021-11-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.37](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.36...@tiptap/extension-table@2.0.0-beta.37) (2021-10-31) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.36](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.35...@tiptap/extension-table@2.0.0-beta.36) (2021-10-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.35](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.34...@tiptap/extension-table@2.0.0-beta.35) (2021-10-22) + +### Features + +- Add extension storage ([#2069](https://github.com/ueberdosis/tiptap/issues/2069)) ([7ffabf2](https://github.com/ueberdosis/tiptap/commit/7ffabf251c408a652eec1931cc78a8bd43cccb67)) + +# [2.0.0-beta.34](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.33...@tiptap/extension-table@2.0.0-beta.34) (2021-10-14) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.33](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.32...@tiptap/extension-table@2.0.0-beta.33) (2021-10-14) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.32](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.31...@tiptap/extension-table@2.0.0-beta.32) (2021-10-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.31](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.30...@tiptap/extension-table@2.0.0-beta.31) (2021-09-15) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.30](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.29...@tiptap/extension-table@2.0.0-beta.30) (2021-09-06) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.29](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.28...@tiptap/extension-table@2.0.0-beta.29) (2021-08-20) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.28](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.27...@tiptap/extension-table@2.0.0-beta.28) (2021-08-13) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.27](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.26...@tiptap/extension-table@2.0.0-beta.27) (2021-08-09) + +### Bug Fixes + +- don’t resize tables if editable is set to false, fix [#1549](https://github.com/ueberdosis/tiptap/issues/1549) ([239a2e3](https://github.com/ueberdosis/tiptap/commit/239a2e36a47e4d0ad3012a54cda2d8b5c4f7a3ca)) + +# [2.0.0-beta.26](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.25...@tiptap/extension-table@2.0.0-beta.26) (2021-07-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.25](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.24...@tiptap/extension-table@2.0.0-beta.25) (2021-07-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.24](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.23...@tiptap/extension-table@2.0.0-beta.24) (2021-06-23) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.23](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.22...@tiptap/extension-table@2.0.0-beta.23) (2021-06-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.22](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.21...@tiptap/extension-table@2.0.0-beta.22) (2021-05-27) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.21](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.20...@tiptap/extension-table@2.0.0-beta.21) (2021-05-18) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.20](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.19...@tiptap/extension-table@2.0.0-beta.20) (2021-05-17) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.19](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.18...@tiptap/extension-table@2.0.0-beta.19) (2021-05-13) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.18](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.17...@tiptap/extension-table@2.0.0-beta.18) (2021-05-07) + +### Bug Fixes + +- revert adding exports ([bc320d0](https://github.com/ueberdosis/tiptap/commit/bc320d0b4b80b0e37a7e47a56e0f6daec6e65d98)) + +# [2.0.0-beta.17](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.16...@tiptap/extension-table@2.0.0-beta.17) (2021-05-06) + +### Bug Fixes + +- revert adding type: module ([f8d6475](https://github.com/ueberdosis/tiptap/commit/f8d6475e2151faea6f96baecdd6bd75880d50d2c)) + +# [2.0.0-beta.16](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.15...@tiptap/extension-table@2.0.0-beta.16) (2021-05-06) + +### Bug Fixes + +- add exports to package.json ([1277fa4](https://github.com/ueberdosis/tiptap/commit/1277fa47151e9c039508cdb219bdd0ffe647f4ee)) + +# [2.0.0-beta.15](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.14...@tiptap/extension-table@2.0.0-beta.15) (2021-05-06) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.14](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.13...@tiptap/extension-table@2.0.0-beta.14) (2021-05-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.13](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.12...@tiptap/extension-table@2.0.0-beta.13) (2021-05-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.12](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.11...@tiptap/extension-table@2.0.0-beta.12) (2021-04-27) + +### Features + +- add setCellSelection command ([eb7e92f](https://github.com/ueberdosis/tiptap/commit/eb7e92f10aff60e68cae613750903eb0adce5933)) + +# [2.0.0-beta.11](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.10...@tiptap/extension-table@2.0.0-beta.11) (2021-04-23) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.10](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.9...@tiptap/extension-table@2.0.0-beta.10) (2021-04-22) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.9](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.8...@tiptap/extension-table@2.0.0-beta.9) (2021-04-21) + +### Bug Fixes + +- add name to context ([a43d4c7](https://github.com/ueberdosis/tiptap/commit/a43d4c7bcb5ba5e386f268a2a71a7449bc2f658e)) + +# [2.0.0-beta.8](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.7...@tiptap/extension-table@2.0.0-beta.8) (2021-04-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.7](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.6...@tiptap/extension-table@2.0.0-beta.7) (2021-04-15) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.6](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.5...@tiptap/extension-table@2.0.0-beta.6) (2021-04-12) + +### Features + +- add parentConfig to extension context for more extendable extensions, fix [#259](https://github.com/ueberdosis/tiptap/issues/259) ([5e1ec5d](https://github.com/ueberdosis/tiptap/commit/5e1ec5d2a66be164f505d631f97861ab9344ba96)) + +# [2.0.0-beta.5](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.4...@tiptap/extension-table@2.0.0-beta.5) (2021-03-31) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.4](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.3...@tiptap/extension-table@2.0.0-beta.4) (2021-03-28) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.3](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.2...@tiptap/extension-table@2.0.0-beta.3) (2021-03-24) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.2](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.1...@tiptap/extension-table@2.0.0-beta.2) (2021-03-18) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.1](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.12...@tiptap/extension-table@2.0.0-beta.1) (2021-03-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.12](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.11...@tiptap/extension-table@2.0.0-alpha.12) (2021-02-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.11](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.10...@tiptap/extension-table@2.0.0-alpha.11) (2021-02-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.10](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.9...@tiptap/extension-table@2.0.0-alpha.10) (2021-02-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.9](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.8...@tiptap/extension-table@2.0.0-alpha.9) (2021-02-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.8](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.7...@tiptap/extension-table@2.0.0-alpha.8) (2021-01-29) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.7](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.6...@tiptap/extension-table@2.0.0-alpha.7) (2021-01-29) + +**Note:** Version bump only for package @tiptap/extension-table + +# 2.0.0-alpha.6 (2021-01-28) + +**Note:** Version bump only for package @tiptap/extension-table diff --git a/packages/extension-table-plus/README.md b/packages/extension-table-plus/README.md new file mode 100755 index 0000000000..09164acab0 --- /dev/null +++ b/packages/extension-table-plus/README.md @@ -0,0 +1,18 @@ +# @tiptap/extension-table + +[![Version](https://img.shields.io/npm/v/@tiptap/extension-table.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-table) +[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-table.svg)](https://npmcharts.com/compare/tiptap?minimal=true) +[![License](https://img.shields.io/npm/l/@tiptap/extension-table.svg)](https://www.npmjs.com/package/@tiptap/extension-table) +[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis) + +## Introduction + +Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_. + +## Official Documentation + +Documentation can be found on the [Tiptap website](https://tiptap.dev). + +## License + +Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md). diff --git a/packages/extension-table-plus/package.json b/packages/extension-table-plus/package.json new file mode 100755 index 0000000000..d34c25ccd7 --- /dev/null +++ b/packages/extension-table-plus/package.json @@ -0,0 +1,93 @@ +{ + "name": "@cherrystudio/extension-table-plus", + "description": "table extension for tiptap forked from tiptap/extension-table", + "version": "3.0.11", + "homepage": "https://cherry-ai.com", + "keywords": [ + "tiptap", + "tiptap extension" + ], + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": { + "import": "./dist/index.d.ts", + "require": "./dist/index.d.cts" + }, + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./table": { + "types": { + "import": "./dist/table/index.d.ts", + "require": "./dist/table/index.d.cts" + }, + "import": "./dist/table/index.js", + "require": "./dist/table/index.cjs" + }, + "./cell": { + "types": { + "import": "./dist/cell/index.d.ts", + "require": "./dist/cell/index.d.cts" + }, + "import": "./dist/cell/index.js", + "require": "./dist/cell/index.cjs" + }, + "./header": { + "types": { + "import": "./dist/header/index.d.ts", + "require": "./dist/header/index.d.cts" + }, + "import": "./dist/header/index.js", + "require": "./dist/header/index.cjs" + }, + "./kit": { + "types": { + "import": "./dist/kit/index.d.ts", + "require": "./dist/kit/index.d.cts" + }, + "import": "./dist/kit/index.js", + "require": "./dist/kit/index.cjs" + }, + "./row": { + "types": { + "import": "./dist/row/index.d.ts", + "require": "./dist/row/index.d.cts" + }, + "import": "./dist/row/index.js", + "require": "./dist/row/index.cjs" + } + }, + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "src", + "dist" + ], + "devDependencies": { + "@tiptap/core": "^3.2.0", + "@tiptap/pm": "^3.2.0", + "eslint": "^9.22.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-unused-imports": "^4.1.4", + "prettier": "^3.5.3", + "tsdown": "^0.13.3" + }, + "peerDependencies": { + "@tiptap/core": "^3.0.9", + "@tiptap/pm": "^3.0.9" + }, + "repository": { + "type": "git", + "url": "https://github.com/CherryHQ/cherry-studio", + "directory": "packages/extension-table-plus" + }, + "scripts": { + "build": "tsdown", + "lint": "prettier ./src/ --write && eslint --fix ./src/" + }, + "packageManager": "yarn@4.9.1" +} diff --git a/packages/extension-table-plus/src/cell/index.ts b/packages/extension-table-plus/src/cell/index.ts new file mode 100755 index 0000000000..cabf450700 --- /dev/null +++ b/packages/extension-table-plus/src/cell/index.ts @@ -0,0 +1 @@ +export * from './table-cell.js' diff --git a/packages/extension-table-plus/src/cell/table-cell.ts b/packages/extension-table-plus/src/cell/table-cell.ts new file mode 100755 index 0000000000..fa549d7f9f --- /dev/null +++ b/packages/extension-table-plus/src/cell/table-cell.ts @@ -0,0 +1,150 @@ +import '../types.js' + +import { mergeAttributes, Node } from '@tiptap/core' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import type { Selection } from '@tiptap/pm/state' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { CellSelection, TableMap } from '@tiptap/pm/tables' +import { Decoration, DecorationSet } from '@tiptap/pm/view' + +export interface TableCellOptions { + /** + * The HTML attributes for a table cell node. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record + /** + * Whether nodes can be nested inside a cell. + * @default false + */ + allowNestedNodes: boolean +} + +const cellSelectionPluginKey = new PluginKey('cellSelectionStyling') + +function isTableNode(node: ProseMirrorNode): boolean { + const spec = node.type.spec as { tableRole?: string } | undefined + return node.type.name === 'table' || spec?.tableRole === 'table' +} + +function createCellSelectionDecorationSet(doc: ProseMirrorNode, selection: Selection): DecorationSet { + if (!(selection instanceof CellSelection)) { + return DecorationSet.empty + } + + const $anchor = selection.$anchorCell || selection.$anchor + let tableNode: ProseMirrorNode | null = null + let tablePos = -1 + + for (let depth = $anchor.depth; depth > 0; depth--) { + const nodeAtDepth = $anchor.node(depth) as ProseMirrorNode + if (isTableNode(nodeAtDepth)) { + tableNode = nodeAtDepth + tablePos = $anchor.before(depth) + break + } + } + + if (!tableNode) { + return DecorationSet.empty + } + + const map = TableMap.get(tableNode) + const tableStart = tablePos + 1 + + type Rect = { top: number; bottom: number; left: number; right: number } + type Item = { pos: number; node: ProseMirrorNode; rect: Rect } + + const items: Item[] = [] + let minRow = Number.POSITIVE_INFINITY + let maxRow = Number.NEGATIVE_INFINITY + let minCol = Number.POSITIVE_INFINITY + let maxCol = Number.NEGATIVE_INFINITY + + selection.forEachCell((cell, pos) => { + const rect = map.findCell(pos - tableStart) + items.push({ pos, node: cell, rect }) + + minRow = Math.min(minRow, rect.top) + maxRow = Math.max(maxRow, rect.bottom - 1) + minCol = Math.min(minCol, rect.left) + maxCol = Math.max(maxCol, rect.right - 1) + }) + + const decorations: Decoration[] = [] + for (const { pos, node, rect } of items) { + const classes: string[] = ['selectedCell'] + if (rect.top === minRow) classes.push('selection-top') + if (rect.bottom - 1 === maxRow) classes.push('selection-bottom') + if (rect.left === minCol) classes.push('selection-left') + if (rect.right - 1 === maxCol) classes.push('selection-right') + + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + class: classes.join(' ') + }) + ) + } + + return DecorationSet.create(doc, decorations) +} +/** + * This extension allows you to create table cells. + * @see https://www.tiptap.dev/api/nodes/table-cell + */ +export const TableCell = Node.create({ + name: 'tableCell', + + addOptions() { + return { + HTMLAttributes: {}, + allowNestedNodes: false + } + }, + + content: '(paragraph | image)+', + + addAttributes() { + return { + colspan: { + default: 1 + }, + rowspan: { + default: 1 + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute('colwidth') + const value = colwidth ? colwidth.split(',').map((width) => parseInt(width, 10)) : null + + return value + } + } + } + }, + + tableRole: 'cell', + + isolating: true, + + parseHTML() { + return [{ tag: 'td' }] + }, + + renderHTML({ HTMLAttributes }) { + return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: cellSelectionPluginKey, + props: { + decorations: ({ doc, selection }) => createCellSelectionDecorationSet(doc as ProseMirrorNode, selection) + } + }) + ] + } +}) diff --git a/packages/extension-table-plus/src/header/index.ts b/packages/extension-table-plus/src/header/index.ts new file mode 100755 index 0000000000..0bd179194c --- /dev/null +++ b/packages/extension-table-plus/src/header/index.ts @@ -0,0 +1 @@ +export * from './table-header.js' diff --git a/packages/extension-table-plus/src/header/table-header.ts b/packages/extension-table-plus/src/header/table-header.ts new file mode 100755 index 0000000000..50c30ac4a6 --- /dev/null +++ b/packages/extension-table-plus/src/header/table-header.ts @@ -0,0 +1,60 @@ +import '../types.js' + +import { mergeAttributes, Node } from '@tiptap/core' + +export interface TableHeaderOptions { + /** + * The HTML attributes for a table header node. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record +} + +/** + * This extension allows you to create table headers. + * @see https://www.tiptap.dev/api/nodes/table-header + */ +export const TableHeader = Node.create({ + name: 'tableHeader', + + addOptions() { + return { + HTMLAttributes: {} + } + }, + + content: 'paragraph+', + + addAttributes() { + return { + colspan: { + default: 1 + }, + rowspan: { + default: 1 + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute('colwidth') + const value = colwidth ? colwidth.split(',').map((width) => parseInt(width, 10)) : null + + return value + } + } + } + }, + + tableRole: 'header_cell', + + isolating: true, + + parseHTML() { + return [{ tag: 'th' }] + }, + + renderHTML({ HTMLAttributes }) { + return ['th', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + } +}) diff --git a/packages/extension-table-plus/src/index.ts b/packages/extension-table-plus/src/index.ts new file mode 100755 index 0000000000..c16b6c4e46 --- /dev/null +++ b/packages/extension-table-plus/src/index.ts @@ -0,0 +1,6 @@ +export * from './cell/index.js' +export * from './header/index.js' +export * from './kit/index.js' +export * from './row/index.js' +export * from './table/index.js' +export * from './table/TableView.js' diff --git a/packages/extension-table-plus/src/kit/index.ts b/packages/extension-table-plus/src/kit/index.ts new file mode 100755 index 0000000000..00221c5bfe --- /dev/null +++ b/packages/extension-table-plus/src/kit/index.ts @@ -0,0 +1,64 @@ +import { Extension, Node } from '@tiptap/core' + +import type { TableCellOptions } from '../cell/index.js' +import { TableCell } from '../cell/index.js' +import type { TableHeaderOptions } from '../header/index.js' +import { TableHeader } from '../header/index.js' +import type { TableRowOptions } from '../row/index.js' +import { TableRow } from '../row/index.js' +import type { TableOptions } from '../table/index.js' +import { Table } from '../table/index.js' + +export interface TableKitOptions { + /** + * If set to false, the table extension will not be registered + * @example table: false + */ + table: Partial | false + /** + * If set to false, the table extension will not be registered + * @example tableCell: false + */ + tableCell: Partial | false + /** + * If set to false, the table extension will not be registered + * @example tableHeader: false + */ + tableHeader: Partial | false + /** + * If set to false, the table extension will not be registered + * @example tableRow: false + */ + tableRow: Partial | false +} + +/** + * The table kit is a collection of table editor extensions. + * + * It’s a good starting point for building your own table in Tiptap. + */ +export const TableKit = Extension.create({ + name: 'tableKit', + + addExtensions() { + const extensions: Node[] = [] + + if (this.options.table !== false) { + extensions.push(Table.configure(this.options.table)) + } + + if (this.options.tableCell !== false) { + extensions.push(TableCell.configure(this.options.tableCell)) + } + + if (this.options.tableHeader !== false) { + extensions.push(TableHeader.configure(this.options.tableHeader)) + } + + if (this.options.tableRow !== false) { + extensions.push(TableRow.configure(this.options.tableRow)) + } + + return extensions + } +}) diff --git a/packages/extension-table-plus/src/row/index.ts b/packages/extension-table-plus/src/row/index.ts new file mode 100755 index 0000000000..8a3564c008 --- /dev/null +++ b/packages/extension-table-plus/src/row/index.ts @@ -0,0 +1 @@ +export * from './table-row.js' diff --git a/packages/extension-table-plus/src/row/table-row.ts b/packages/extension-table-plus/src/row/table-row.ts new file mode 100755 index 0000000000..382954397f --- /dev/null +++ b/packages/extension-table-plus/src/row/table-row.ts @@ -0,0 +1,38 @@ +import '../types.js' + +import { mergeAttributes, Node } from '@tiptap/core' + +export interface TableRowOptions { + /** + * The HTML attributes for a table row node. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record +} + +/** + * This extension allows you to create table rows. + * @see https://www.tiptap.dev/api/nodes/table-row + */ +export const TableRow = Node.create({ + name: 'tableRow', + + addOptions() { + return { + HTMLAttributes: {} + } + }, + + content: '(tableCell | tableHeader)*', + + tableRole: 'row', + + parseHTML() { + return [{ tag: 'tr' }] + }, + + renderHTML({ HTMLAttributes }) { + return ['tr', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + } +}) diff --git a/packages/extension-table-plus/src/table/TableView.ts b/packages/extension-table-plus/src/table/TableView.ts new file mode 100755 index 0000000000..1a06255364 --- /dev/null +++ b/packages/extension-table-plus/src/table/TableView.ts @@ -0,0 +1,558 @@ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { TextSelection } from '@tiptap/pm/state' +import { addColumnAfter, addRowAfter, CellSelection, TableMap } from '@tiptap/pm/tables' +import type { EditorView, NodeView, ViewMutationRecord } from '@tiptap/pm/view' + +import { getColStyleDeclaration } from './utilities/colStyle.js' +import { getElementBorderWidth } from './utilities/getBorderWidth.js' +import { isCellSelection } from './utilities/isCellSelection.js' +import { getCellSelectionBounds } from './utilities/selectionBounds.js' + +export function updateColumns( + node: ProseMirrorNode, + colgroup: HTMLTableColElement, // has the same prototype as + table: HTMLTableElement, + cellMinWidth: number, + overrideCol?: number, + overrideValue?: number +) { + let totalWidth = 0 + let fixedWidth = true + let nextDOM = colgroup.firstChild + const row = node.firstChild + + if (row !== null) { + for (let i = 0, col = 0; i < row.childCount; i += 1) { + const { colspan, colwidth } = row.child(i).attrs + + for (let j = 0; j < colspan; j += 1, col += 1) { + const hasWidth = overrideCol === col ? overrideValue : ((colwidth && colwidth[j]) as number | undefined) + const cssWidth = hasWidth ? `${hasWidth}px` : '' + + totalWidth += hasWidth || cellMinWidth + + if (!hasWidth) { + fixedWidth = false + } + + if (!nextDOM) { + const colElement = document.createElement('col') + + const [propertyKey, propertyValue] = getColStyleDeclaration(cellMinWidth, hasWidth) + + colElement.style.setProperty(propertyKey, propertyValue) + + colgroup.appendChild(colElement) + } else { + if ((nextDOM as HTMLTableColElement).style.width !== cssWidth) { + const [propertyKey, propertyValue] = getColStyleDeclaration(cellMinWidth, hasWidth) + + ;(nextDOM as HTMLTableColElement).style.setProperty(propertyKey, propertyValue) + } + + nextDOM = nextDOM.nextSibling + } + } + } + } + + while (nextDOM) { + const after = nextDOM.nextSibling + + nextDOM.parentNode?.removeChild(nextDOM) + nextDOM = after + } + + if (fixedWidth) { + table.style.width = `${totalWidth}px` + table.style.minWidth = '' + } else { + table.style.width = '' + table.style.minWidth = `${totalWidth}px` + } +} + +// Callbacks are now handled by a decorations plugin; keep type removed here + +type ButtonPosition = { x: number; y: number } +type RowActionCallback = (args: { rowIndex: number; view: EditorView; position?: ButtonPosition }) => void +type ColumnActionCallback = (args: { colIndex: number; view: EditorView; position?: ButtonPosition }) => void + +export class TableView implements NodeView { + node: ProseMirrorNode + + cellMinWidth: number + + dom: HTMLDivElement + + table: HTMLTableElement + + colgroup: HTMLTableColElement + + contentDOM: HTMLTableSectionElement + + view: EditorView + + addRowButton: HTMLButtonElement + + addColumnButton: HTMLButtonElement + + tableContainer: HTMLDivElement + + // Hover add buttons are kept; overlay endpoints absolute on wrapper + private selectionChangeDisposer?: () => void + private rowEndpoint?: HTMLButtonElement + private colEndpoint?: HTMLButtonElement + private overlayUpdateRafId: number | null = null + private actionCallbacks?: { + onRowActionClick?: RowActionCallback + onColumnActionClick?: ColumnActionCallback + } + + constructor( + node: ProseMirrorNode, + cellMinWidth: number, + view: EditorView, + actionCallbacks?: { onRowActionClick?: RowActionCallback; onColumnActionClick?: ColumnActionCallback } + ) { + this.node = node + this.cellMinWidth = cellMinWidth + this.view = view + this.actionCallbacks = actionCallbacks + // selection triggers handled by decorations plugin + + // Create the wrapper with grid layout + this.dom = document.createElement('div') + this.dom.className = 'tableWrapper' + + // Create table container + this.tableContainer = document.createElement('div') + this.tableContainer.className = 'table-container' + + this.table = this.tableContainer.appendChild(document.createElement('table')) + this.colgroup = this.table.appendChild(document.createElement('colgroup')) + updateColumns(node, this.colgroup, this.table, cellMinWidth) + this.contentDOM = this.table.appendChild(document.createElement('tbody')) + + this.addRowButton = document.createElement('button') + this.addColumnButton = document.createElement('button') + this.createHoverButtons() + + this.dom.appendChild(this.tableContainer) + this.dom.appendChild(this.addColumnButton) + this.dom.appendChild(this.addRowButton) + + this.syncEditableState() + + this.setupEventListeners() + + // create overlay endpoints + this.rowEndpoint = document.createElement('button') + this.rowEndpoint.className = 'row-action-trigger' + this.rowEndpoint.type = 'button' + this.rowEndpoint.setAttribute('contenteditable', 'false') + this.rowEndpoint.style.position = 'absolute' + this.rowEndpoint.style.display = 'none' + this.rowEndpoint.tabIndex = -1 + + this.colEndpoint = document.createElement('button') + this.colEndpoint.className = 'column-action-trigger' + this.colEndpoint.type = 'button' + this.colEndpoint.setAttribute('contenteditable', 'false') + this.colEndpoint.style.position = 'absolute' + this.colEndpoint.style.display = 'none' + this.colEndpoint.tabIndex = -1 + + this.dom.appendChild(this.rowEndpoint) + this.dom.appendChild(this.colEndpoint) + + this.bindOverlayHandlers() + this.startSelectionWatcher() + } + + update(node: ProseMirrorNode) { + if (node.type !== this.node.type) { + return false + } + + this.node = node + updateColumns(node, this.colgroup, this.table, this.cellMinWidth) + + // Keep buttons' disabled state in sync during updates + this.syncEditableState() + + // Recalculate overlay positions after node/table mutations so triggers follow the updated layout + this.scheduleOverlayUpdate() + + return true + } + + ignoreMutation(mutation: ViewMutationRecord) { + return ( + (mutation.type === 'attributes' && (mutation.target === this.table || this.colgroup.contains(mutation.target))) || + // Ignore mutations on our action buttons + (mutation.target as Element)?.classList?.contains('row-action-trigger') || + (mutation.target as Element)?.classList?.contains('column-action-trigger') + ) + } + + private isEditable(): boolean { + // Rely on DOM attribute to avoid depending on EditorView internals + return this.view.dom.getAttribute('contenteditable') !== 'false' + } + + private syncEditableState() { + const editable = this.isEditable() + this.addRowButton.toggleAttribute('disabled', !editable) + this.addColumnButton.toggleAttribute('disabled', !editable) + + this.addRowButton.style.display = editable ? '' : 'none' + this.addColumnButton.style.display = editable ? '' : 'none' + this.dom.classList.toggle('is-readonly', !editable) + } + + createHoverButtons() { + this.addRowButton.className = 'add-row-button' + this.addRowButton.type = 'button' + this.addRowButton.setAttribute('contenteditable', 'false') + + this.addColumnButton.className = 'add-column-button' + this.addColumnButton.type = 'button' + this.addColumnButton.setAttribute('contenteditable', 'false') + } + + private addTableRowOrColumn(isRow: boolean) { + if (!this.isEditable()) return + + this.view.focus() + + // Save current selection info and calculate position in table + const { state } = this.view + const originalSelection = state.selection + + // Find which cell we're currently in and the relative position within that cell + let tablePos = -1 + let currentCellRow = -1 + let currentCellCol = -1 + let relativeOffsetInCell = 0 + + state.doc.descendants((node: ProseMirrorNode, pos: number) => { + if (node.type.name === 'table' && node === this.node) { + tablePos = pos + const map = TableMap.get(this.node) + + // Find which cell contains our selection + const selectionPos = originalSelection.from + for (let row = 0; row < map.height; row++) { + for (let col = 0; col < map.width; col++) { + const cellIndex = row * map.width + col + const cellStart = pos + 1 + map.map[cellIndex] + const cellNode = state.doc.nodeAt(cellStart) + if (cellNode) { + const cellEnd = cellStart + cellNode.nodeSize + if (selectionPos >= cellStart && selectionPos < cellEnd) { + currentCellRow = row + currentCellCol = col + relativeOffsetInCell = selectionPos - cellStart + return false + } + } + } + } + return false + } + return true + }) + + // Set selection to appropriate position for adding + if (isRow) { + this.setSelectionToLastRow() + } else { + this.setSelectionToLastColumn() + } + + setTimeout(() => { + const { state, dispatch } = this.view + const addFunction = isRow ? addRowAfter : addColumnAfter + + if (addFunction(state, dispatch)) { + setTimeout(() => { + const newState = this.view.state + + // Calculate new position for the same logical cell with same relative offset + if (tablePos >= 0 && currentCellRow >= 0 && currentCellCol >= 0) { + newState.doc.descendants((node: ProseMirrorNode, pos: number) => { + if (node.type.name === 'table' && pos === tablePos) { + const newMap = TableMap.get(node) + const newCellIndex = currentCellRow * newMap.width + currentCellCol + const newCellStart = pos + 1 + newMap.map[newCellIndex] + const newCellNode = newState.doc.nodeAt(newCellStart) + + if (newCellNode) { + // Try to maintain the same relative position within the cell + const newCellEnd = newCellStart + newCellNode.nodeSize + const targetPos = Math.min(newCellStart + relativeOffsetInCell, newCellEnd - 1) + const newSelection = TextSelection.create(newState.doc, targetPos) + const newTr = newState.tr.setSelection(newSelection) + this.view.dispatch(newTr) + } + return false + } + return true + }) + } + }, 10) + } + }, 10) + } + + setupEventListeners() { + // Add row button click handler + this.addRowButton.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + this.addTableRowOrColumn(true) + }) + + // Add column button click handler + this.addColumnButton.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + this.addTableRowOrColumn(false) + }) + } + + private bindOverlayHandlers() { + if (!this.rowEndpoint || !this.colEndpoint) return + this.rowEndpoint.addEventListener('mousedown', (e) => e.preventDefault()) + this.colEndpoint.addEventListener('mousedown', (e) => e.preventDefault()) + this.rowEndpoint.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + const bounds = getCellSelectionBounds(this.view, this.node) + if (!bounds) return + this.selectRow(bounds.maxRow) + const rect = this.rowEndpoint!.getBoundingClientRect() + const position = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } + this.actionCallbacks?.onRowActionClick?.({ rowIndex: bounds.maxRow, view: this.view, position }) + this.scheduleOverlayUpdate() + }) + this.colEndpoint.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + const bounds = getCellSelectionBounds(this.view, this.node) + if (!bounds) return + this.selectColumn(bounds.maxCol) + const rect = this.colEndpoint!.getBoundingClientRect() + const position = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } + this.actionCallbacks?.onColumnActionClick?.({ colIndex: bounds.maxCol, view: this.view, position }) + this.scheduleOverlayUpdate() + }) + } + + private startSelectionWatcher() { + const owner = this.view.dom.ownerDocument || document + const handler = () => this.scheduleOverlayUpdate() + owner.addEventListener('selectionchange', handler) + this.selectionChangeDisposer = () => owner.removeEventListener('selectionchange', handler) + this.scheduleOverlayUpdate() + } + + private scheduleOverlayUpdate() { + if (this.overlayUpdateRafId !== null) { + cancelAnimationFrame(this.overlayUpdateRafId) + } + this.overlayUpdateRafId = requestAnimationFrame(() => { + this.overlayUpdateRafId = null + this.updateOverlayPositions() + }) + } + + private updateOverlayPositions() { + if (!this.rowEndpoint || !this.colEndpoint) return + const bounds = getCellSelectionBounds(this.view, this.node) + if (!bounds) { + this.rowEndpoint.style.display = 'none' + this.colEndpoint.style.display = 'none' + return + } + + const { map, tableStart, maxRow, maxCol } = bounds + + const getCellDomAndRect = (row: number, col: number) => { + const cellIndex = row * map.width + col + const cellPos = tableStart + map.map[cellIndex] + const cellDom = this.view.nodeDOM(cellPos) as HTMLElement | null + return { + dom: cellDom, + rect: cellDom?.getBoundingClientRect() + } + } + + // Position row endpoint (left side) + const bottomLeft = getCellDomAndRect(maxRow, 0) + const topLeft = getCellDomAndRect(0, 0) + + if (bottomLeft.dom && bottomLeft.rect && topLeft.rect) { + const midY = (bottomLeft.rect.top + bottomLeft.rect.bottom) / 2 + this.rowEndpoint.style.display = 'flex' + const borderWidth = getElementBorderWidth(this.rowEndpoint) + this.rowEndpoint.style.left = `${bottomLeft.rect.left - topLeft.rect.left - this.rowEndpoint.getBoundingClientRect().width / 2 + borderWidth.left / 2}px` + this.rowEndpoint.style.top = `${midY - topLeft.rect.top - this.rowEndpoint.getBoundingClientRect().height / 2}px` + } else { + this.rowEndpoint.style.display = 'none' + } + + // Position column endpoint (top side) + const topRight = getCellDomAndRect(0, maxCol) + const topLeftForCol = getCellDomAndRect(0, 0) + + if (topRight.dom && topRight.rect && topLeftForCol.rect) { + const midX = topRight.rect.left + topRight.rect.width / 2 + const borderWidth = getElementBorderWidth(this.colEndpoint) + this.colEndpoint.style.display = 'flex' + this.colEndpoint.style.left = `${midX - topLeftForCol.rect.left - this.colEndpoint.getBoundingClientRect().width / 2}px` + this.colEndpoint.style.top = `${topRight.rect.top - topLeftForCol.rect.top - this.colEndpoint.getBoundingClientRect().height / 2 + borderWidth.top / 2}px` + } else { + this.colEndpoint.style.display = 'none' + } + } + + setSelectionToTable() { + const { state } = this.view + + let tablePos = -1 + state.doc.descendants((node: ProseMirrorNode, pos: number) => { + if (node.type.name === 'table' && node === this.node) { + tablePos = pos + return false + } + return true + }) + + if (tablePos >= 0) { + const firstCellPos = tablePos + 3 + const selection = TextSelection.create(state.doc, firstCellPos) + const tr = state.tr.setSelection(selection) + this.view.dispatch(tr) + } + } + + setSelectionToLastRow() { + const { state } = this.view + + let tablePos = -1 + state.doc.descendants((node: ProseMirrorNode, pos: number) => { + if (node.type.name === 'table' && node === this.node) { + tablePos = pos + return false + } + return true + }) + + if (tablePos >= 0) { + const map = TableMap.get(this.node) + const lastRowIndex = map.height - 1 + const lastRowFirstCell = map.map[lastRowIndex * map.width] + const lastRowFirstCellPos = tablePos + 1 + lastRowFirstCell + + const selection = TextSelection.create(state.doc, lastRowFirstCellPos) + const tr = state.tr.setSelection(selection) + this.view.dispatch(tr) + } + } + + setSelectionToLastColumn() { + const { state } = this.view + + let tablePos = -1 + state.doc.descendants((node: ProseMirrorNode, pos: number) => { + if (node.type.name === 'table' && node === this.node) { + tablePos = pos + return false + } + return true + }) + + if (tablePos >= 0) { + const map = TableMap.get(this.node) + const lastColumnIndex = map.width - 1 + const lastColumnFirstCell = map.map[lastColumnIndex] + const lastColumnFirstCellPos = tablePos + 1 + lastColumnFirstCell + + const selection = TextSelection.create(state.doc, lastColumnFirstCellPos) + const tr = state.tr.setSelection(selection) + this.view.dispatch(tr) + } + } + + // selection triggers moved to decorations plugin + + hasTableCellSelection(): boolean { + const selection = this.view.state.selection + return isCellSelection(selection) + } + + selectRow(rowIndex: number) { + const { state, dispatch } = this.view + + // Find the table position + let tablePos = -1 + state.doc.descendants((node: ProseMirrorNode, pos: number) => { + if (node.type.name === 'table' && node === this.node) { + tablePos = pos + return false + } + return true + }) + + if (tablePos >= 0) { + const map = TableMap.get(this.node) + const firstCellInRow = map.map[rowIndex * map.width] + const lastCellInRow = map.map[rowIndex * map.width + map.width - 1] + + const firstCellPos = tablePos + 1 + firstCellInRow + const lastCellPos = tablePos + 1 + lastCellInRow + + const selection = CellSelection.create(state.doc, firstCellPos, lastCellPos) + const tr = state.tr.setSelection(selection) + dispatch(tr) + } + } + + selectColumn(colIndex: number) { + const { state, dispatch } = this.view + + // Find the table position + let tablePos = -1 + state.doc.descendants((node: ProseMirrorNode, pos: number) => { + if (node.type.name === 'table' && node === this.node) { + tablePos = pos + return false + } + return true + }) + + if (tablePos >= 0) { + const map = TableMap.get(this.node) + const firstCellInCol = map.map[colIndex] + const lastCellInCol = map.map[(map.height - 1) * map.width + colIndex] + + const firstCellPos = tablePos + 1 + firstCellInCol + const lastCellPos = tablePos + 1 + lastCellInCol + + const selection = CellSelection.create(state.doc, firstCellPos, lastCellPos) + const tr = state.tr.setSelection(selection) + dispatch(tr) + } + } + + destroy() { + this.addRowButton?.remove() + this.addColumnButton?.remove() + if (this.rowEndpoint) this.rowEndpoint.remove() + if (this.colEndpoint) this.colEndpoint.remove() + if (this.selectionChangeDisposer) this.selectionChangeDisposer() + if (this.overlayUpdateRafId !== null) cancelAnimationFrame(this.overlayUpdateRafId) + } +} diff --git a/packages/extension-table-plus/src/table/index.ts b/packages/extension-table-plus/src/table/index.ts new file mode 100755 index 0000000000..040a250704 --- /dev/null +++ b/packages/extension-table-plus/src/table/index.ts @@ -0,0 +1,3 @@ +export * from './table.js' +export * from './utilities/createColGroup.js' +export * from './utilities/createTable.js' diff --git a/packages/extension-table-plus/src/table/table.ts b/packages/extension-table-plus/src/table/table.ts new file mode 100755 index 0000000000..d0cdf8304b --- /dev/null +++ b/packages/extension-table-plus/src/table/table.ts @@ -0,0 +1,486 @@ +import '../types.js' + +import { callOrReturn, getExtensionField, mergeAttributes, Node } from '@tiptap/core' +import type { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model' +import { TextSelection } from '@tiptap/pm/state' +import { + addColumnAfter, + addColumnBefore, + addRowAfter, + addRowBefore, + CellSelection, + columnResizing, + deleteColumn, + deleteRow, + deleteTable, + fixTables, + goToNextCell, + mergeCells, + setCellAttr, + splitCell, + tableEditing, + toggleHeader, + toggleHeaderCell +} from '@tiptap/pm/tables' +import { type EditorView, type NodeView } from '@tiptap/pm/view' + +import { TableView } from './TableView.js' +import { createColGroup } from './utilities/createColGroup.js' +import { createTable } from './utilities/createTable.js' +import { deleteTableWhenAllCellsSelected } from './utilities/deleteTableWhenAllCellsSelected.js' + +export interface TableOptions { + /** + * HTML attributes for the table element. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record + + /** + * Enables the resizing of tables. + * @default false + * @example true + */ + resizable: boolean + + /** + * The width of the resize handle. + * @default 5 + * @example 10 + */ + handleWidth: number + + /** + * The minimum width of a cell. + * @default 25 + * @example 50 + */ + cellMinWidth: number + + /** + * The node view to render the table. + * @default TableView + */ + View: (new (node: ProseMirrorNode, cellMinWidth: number, view: EditorView) => NodeView) | null + + /** + * Enables the resizing of the last column. + * @default true + * @example false + */ + lastColumnResizable: boolean + + /** + * Allow table node selection. + * @default false + * @example true + */ + allowTableNodeSelection: boolean + + /** + * Optional callbacks for row/column action triggers + */ + onRowActionClick?: (args: { rowIndex: number; view: EditorView; position?: { x: number; y: number } }) => void + onColumnActionClick?: (args: { colIndex: number; view: EditorView; position?: { x: number; y: number } }) => void +} + +declare module '@tiptap/core' { + interface Commands { + table: { + /** + * Insert a table + * @param options The table attributes + * @returns True if the command was successful, otherwise false + * @example editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + */ + insertTable: (options?: { rows?: number; cols?: number; withHeaderRow?: boolean }) => ReturnType + + /** + * Add a column before the current column + * @returns True if the command was successful, otherwise false + * @example editor.commands.addColumnBefore() + */ + addColumnBefore: () => ReturnType + + /** + * Add a column after the current column + * @returns True if the command was successful, otherwise false + * @example editor.commands.addColumnAfter() + */ + addColumnAfter: () => ReturnType + + /** + * Delete the current column + * @returns True if the command was successful, otherwise false + * @example editor.commands.deleteColumn() + */ + deleteColumn: () => ReturnType + + /** + * Add a row before the current row + * @returns True if the command was successful, otherwise false + * @example editor.commands.addRowBefore() + */ + addRowBefore: () => ReturnType + + /** + * Add a row after the current row + * @returns True if the command was successful, otherwise false + * @example editor.commands.addRowAfter() + */ + addRowAfter: () => ReturnType + + /** + * Delete the current row + * @returns True if the command was successful, otherwise false + * @example editor.commands.deleteRow() + */ + deleteRow: () => ReturnType + + /** + * Delete the current table + * @returns True if the command was successful, otherwise false + * @example editor.commands.deleteTable() + */ + deleteTable: () => ReturnType + + /** + * Merge the currently selected cells + * @returns True if the command was successful, otherwise false + * @example editor.commands.mergeCells() + */ + mergeCells: () => ReturnType + + /** + * Split the currently selected cell + * @returns True if the command was successful, otherwise false + * @example editor.commands.splitCell() + */ + splitCell: () => ReturnType + + /** + * Toggle the header column + * @returns True if the command was successful, otherwise false + * @example editor.commands.toggleHeaderColumn() + */ + toggleHeaderColumn: () => ReturnType + + /** + * Toggle the header row + * @returns True if the command was successful, otherwise false + * @example editor.commands.toggleHeaderRow() + */ + toggleHeaderRow: () => ReturnType + + /** + * Toggle the header cell + * @returns True if the command was successful, otherwise false + * @example editor.commands.toggleHeaderCell() + */ + toggleHeaderCell: () => ReturnType + + /** + * Merge or split the currently selected cells + * @returns True if the command was successful, otherwise false + * @example editor.commands.mergeOrSplit() + */ + mergeOrSplit: () => ReturnType + + /** + * Set a cell attribute + * @param name The attribute name + * @param value The attribute value + * @returns True if the command was successful, otherwise false + * @example editor.commands.setCellAttribute('align', 'right') + */ + setCellAttribute: (name: string, value: any) => ReturnType + + /** + * Moves the selection to the next cell + * @returns True if the command was successful, otherwise false + * @example editor.commands.goToNextCell() + */ + goToNextCell: () => ReturnType + + /** + * Moves the selection to the previous cell + * @returns True if the command was successful, otherwise false + * @example editor.commands.goToPreviousCell() + */ + goToPreviousCell: () => ReturnType + + /** + * Try to fix the table structure if necessary + * @returns True if the command was successful, otherwise false + * @example editor.commands.fixTables() + */ + fixTables: () => ReturnType + + /** + * Set a cell selection inside the current table + * @param position The cell position + * @returns True if the command was successful, otherwise false + * @example editor.commands.setCellSelection({ anchorCell: 1, headCell: 2 }) + */ + setCellSelection: (position: { anchorCell: number; headCell?: number }) => ReturnType + } + } +} + +/** + * This extension allows you to create tables. + * @see https://www.tiptap.dev/api/nodes/table + */ +export const Table = Node.create({ + name: 'table', + + // @ts-ignore - TODO: fix + addOptions() { + return { + HTMLAttributes: {}, + resizable: false, + handleWidth: 5, + cellMinWidth: 25, + // TODO: fix + View: TableView, + lastColumnResizable: true, + allowTableNodeSelection: false + } + }, + + content: 'tableRow+', + + tableRole: 'table', + + isolating: true, + + group: 'block', + + parseHTML() { + return [{ tag: 'table' }] + }, + + renderHTML({ node, HTMLAttributes }) { + const { colgroup, tableWidth, tableMinWidth } = createColGroup(node, this.options.cellMinWidth) + + const table: DOMOutputSpec = [ + 'table', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + style: tableWidth ? `width: ${tableWidth}` : `min-width: ${tableMinWidth}` + }), + colgroup, + ['tbody', 0] + ] + + return table + }, + + addCommands() { + return { + insertTable: + ({ rows = 3, cols = 3, withHeaderRow = true } = {}) => + ({ tr, dispatch, editor }) => { + // Disallow inserting table inside nested nodes when TableCell option allowNestedNodes is false + const tableCellExtension = this.editor.extensionManager.extensions.find((ext) => ext.name === 'tableCell') + const allowNestedNodes: boolean = tableCellExtension + ? Boolean((tableCellExtension.options as { allowNestedNodes?: boolean }).allowNestedNodes) + : false + + if (!allowNestedNodes) { + const { $from } = tr.selection + // Only allow table insertion at top-level (depth <= 1), + // disallow when selection is inside any nested node (list, blockquote, table, etc.) + if ($from.depth > 1) { + return false + } + } + + const node = createTable(editor.schema, rows, cols, withHeaderRow) + + if (dispatch) { + const offset = tr.selection.from + 1 + + tr.replaceSelectionWith(node) + .scrollIntoView() + .setSelection(TextSelection.near(tr.doc.resolve(offset))) + } + + return true + }, + addColumnBefore: + () => + ({ state, dispatch }) => { + return addColumnBefore(state, dispatch) + }, + addColumnAfter: + () => + ({ state, dispatch }) => { + return addColumnAfter(state, dispatch) + }, + deleteColumn: + () => + ({ state, dispatch }) => { + return deleteColumn(state, dispatch) + }, + addRowBefore: + () => + ({ state, dispatch }) => { + return addRowBefore(state, dispatch) + }, + addRowAfter: + () => + ({ state, dispatch }) => { + return addRowAfter(state, dispatch) + }, + deleteRow: + () => + ({ state, dispatch }) => { + return deleteRow(state, dispatch) + }, + deleteTable: + () => + ({ state, dispatch }) => { + return deleteTable(state, dispatch) + }, + mergeCells: + () => + ({ state, dispatch }) => { + return mergeCells(state, dispatch) + }, + splitCell: + () => + ({ state, dispatch }) => { + return splitCell(state, dispatch) + }, + toggleHeaderColumn: + () => + ({ state, dispatch }) => { + return toggleHeader('column')(state, dispatch) + }, + toggleHeaderRow: + () => + ({ state, dispatch }) => { + return toggleHeader('row')(state, dispatch) + }, + toggleHeaderCell: + () => + ({ state, dispatch }) => { + return toggleHeaderCell(state, dispatch) + }, + mergeOrSplit: + () => + ({ state, dispatch }) => { + if (mergeCells(state, dispatch)) { + return true + } + + return splitCell(state, dispatch) + }, + setCellAttribute: + (name, value) => + ({ state, dispatch }) => { + return setCellAttr(name, value)(state, dispatch) + }, + goToNextCell: + () => + ({ state, dispatch }) => { + return goToNextCell(1)(state, dispatch) + }, + goToPreviousCell: + () => + ({ state, dispatch }) => { + return goToNextCell(-1)(state, dispatch) + }, + fixTables: + () => + ({ state, dispatch }) => { + if (dispatch) { + fixTables(state) + } + + return true + }, + setCellSelection: + (position) => + ({ tr, dispatch }) => { + if (dispatch) { + const selection = CellSelection.create(tr.doc, position.anchorCell, position.headCell) + + // @ts-ignore - TODO: fix + tr.setSelection(selection) + } + + return true + } + } + }, + + addNodeView() { + return (props) => { + const { node, view } = props + const ViewClass = this.options.View || TableView + if (ViewClass === TableView) { + return new TableView(node, this.options.cellMinWidth, view, { + onRowActionClick: this.options.onRowActionClick, + onColumnActionClick: this.options.onColumnActionClick + }) + } + return new ViewClass(node, this.options.cellMinWidth, view) + } + }, + + addKeyboardShortcuts() { + return { + Tab: () => { + if (this.editor.commands.goToNextCell()) { + return true + } + + if (!this.editor.can().addRowAfter()) { + return false + } + + return this.editor.chain().addRowAfter().goToNextCell().run() + }, + 'Shift-Tab': () => this.editor.commands.goToPreviousCell(), + Backspace: deleteTableWhenAllCellsSelected, + 'Mod-Backspace': deleteTableWhenAllCellsSelected, + Delete: deleteTableWhenAllCellsSelected, + 'Mod-Delete': deleteTableWhenAllCellsSelected + } + }, + + addProseMirrorPlugins() { + const isResizable = this.options.resizable && this.editor.isEditable + + return [ + ...(isResizable + ? [ + columnResizing({ + handleWidth: this.options.handleWidth, + cellMinWidth: this.options.cellMinWidth, + defaultCellMinWidth: this.options.cellMinWidth, + View: this.options.View, + lastColumnResizable: this.options.lastColumnResizable + }) + ] + : []), + tableEditing({ + allowTableNodeSelection: this.options.allowTableNodeSelection + }) + ] + }, + + extendNodeSchema(extension) { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage + } + + return { + tableRole: callOrReturn(getExtensionField(extension, 'tableRole', context)) + } + } +}) diff --git a/packages/extension-table-plus/src/table/utilities/colStyle.ts b/packages/extension-table-plus/src/table/utilities/colStyle.ts new file mode 100755 index 0000000000..d54a259fda --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/colStyle.ts @@ -0,0 +1,9 @@ +export function getColStyleDeclaration(minWidth: number, width: number | undefined): [string, string] { + if (width) { + // apply the stored width unless it is below the configured minimum cell width + return ['width', `${Math.max(width, minWidth)}px`] + } + + // set the minimum with on the column if it has no stored width + return ['min-width', `${minWidth}px`] +} diff --git a/packages/extension-table-plus/src/table/utilities/createCell.ts b/packages/extension-table-plus/src/table/utilities/createCell.ts new file mode 100755 index 0000000000..2d95471c5c --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/createCell.ts @@ -0,0 +1,12 @@ +import type { Fragment, Node as ProsemirrorNode, NodeType } from '@tiptap/pm/model' + +export function createCell( + cellType: NodeType, + cellContent?: Fragment | ProsemirrorNode | Array +): ProsemirrorNode | null | undefined { + if (cellContent) { + return cellType.createChecked(null, cellContent) + } + + return cellType.createAndFill() +} diff --git a/packages/extension-table-plus/src/table/utilities/createColGroup.ts b/packages/extension-table-plus/src/table/utilities/createColGroup.ts new file mode 100755 index 0000000000..4a12d10cd8 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/createColGroup.ts @@ -0,0 +1,68 @@ +import type { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model' + +import { getColStyleDeclaration } from './colStyle.js' + +export type ColGroup = + | { + colgroup: DOMOutputSpec + tableWidth: string + tableMinWidth: string + } + | Record + +/** + * Creates a colgroup element for a table node in ProseMirror. + * + * @param node - The ProseMirror node representing the table. + * @param cellMinWidth - The minimum width of a cell in the table. + * @param overrideCol - (Optional) The index of the column to override the width of. + * @param overrideValue - (Optional) The width value to use for the overridden column. + * @returns An object containing the colgroup element, the total width of the table, and the minimum width of the table. + */ +export function createColGroup(node: ProseMirrorNode, cellMinWidth: number): ColGroup +export function createColGroup( + node: ProseMirrorNode, + cellMinWidth: number, + overrideCol: number, + overrideValue: number +): ColGroup +export function createColGroup( + node: ProseMirrorNode, + cellMinWidth: number, + overrideCol?: number, + overrideValue?: number +): ColGroup { + let totalWidth = 0 + let fixedWidth = true + const cols: DOMOutputSpec[] = [] + const row = node.firstChild + + if (!row) { + return {} + } + + for (let i = 0, col = 0; i < row.childCount; i += 1) { + const { colspan, colwidth } = row.child(i).attrs + + for (let j = 0; j < colspan; j += 1, col += 1) { + const hasWidth = overrideCol === col ? overrideValue : colwidth && (colwidth[j] as number | undefined) + + totalWidth += hasWidth || cellMinWidth + + if (!hasWidth) { + fixedWidth = false + } + + const [property, value] = getColStyleDeclaration(cellMinWidth, hasWidth) + + cols.push(['col', { style: `${property}: ${value}` }]) + } + } + + const tableWidth = fixedWidth ? `${totalWidth}px` : '' + const tableMinWidth = fixedWidth ? '' : `${totalWidth}px` + + const colgroup: DOMOutputSpec = ['colgroup', {}, ...cols] + + return { colgroup, tableWidth, tableMinWidth } +} diff --git a/packages/extension-table-plus/src/table/utilities/createTable.ts b/packages/extension-table-plus/src/table/utilities/createTable.ts new file mode 100755 index 0000000000..ae6d78c412 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/createTable.ts @@ -0,0 +1,40 @@ +import type { Fragment, Node as ProsemirrorNode, Schema } from '@tiptap/pm/model' + +import { createCell } from './createCell.js' +import { getTableNodeTypes } from './getTableNodeTypes.js' + +export function createTable( + schema: Schema, + rowsCount: number, + colsCount: number, + withHeaderRow: boolean, + cellContent?: Fragment | ProsemirrorNode | Array +): ProsemirrorNode { + const types = getTableNodeTypes(schema) + const headerCells: ProsemirrorNode[] = [] + const cells: ProsemirrorNode[] = [] + + for (let index = 0; index < colsCount; index += 1) { + const cell = createCell(types.cell, cellContent) + + if (cell) { + cells.push(cell) + } + + if (withHeaderRow) { + const headerCell = createCell(types.header_cell, cellContent) + + if (headerCell) { + headerCells.push(headerCell) + } + } + } + + const rows: ProsemirrorNode[] = [] + + for (let index = 0; index < rowsCount; index += 1) { + rows.push(types.row.createChecked(null, withHeaderRow && index === 0 ? headerCells : cells)) + } + + return types.table.createChecked(null, rows) +} diff --git a/packages/extension-table-plus/src/table/utilities/deleteTableWhenAllCellsSelected.ts b/packages/extension-table-plus/src/table/utilities/deleteTableWhenAllCellsSelected.ts new file mode 100755 index 0000000000..43eceefe07 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/deleteTableWhenAllCellsSelected.ts @@ -0,0 +1,38 @@ +import type { KeyboardShortcutCommand } from '@tiptap/core' +import { findParentNodeClosestToPos } from '@tiptap/core' + +import { isCellSelection } from './isCellSelection.js' + +export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => { + const { selection } = editor.state + + if (!isCellSelection(selection)) { + return false + } + + let cellCount = 0 + const table = findParentNodeClosestToPos(selection.ranges[0].$from, (node) => { + return node.type.name === 'table' + }) + + table?.node.descendants((node) => { + if (node.type.name === 'table') { + return false + } + + if (['tableCell', 'tableHeader'].includes(node.type.name)) { + cellCount += 1 + } + return true + }) + + const allCellsSelected = cellCount === selection.ranges.length + + if (!allCellsSelected) { + return false + } + + editor.commands.deleteTable() + + return true +} diff --git a/packages/extension-table-plus/src/table/utilities/getBorderWidth.ts b/packages/extension-table-plus/src/table/utilities/getBorderWidth.ts new file mode 100644 index 0000000000..29cb80f6f9 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/getBorderWidth.ts @@ -0,0 +1,14 @@ +export function getElementBorderWidth(element: HTMLElement): { + top: number + right: number + bottom: number + left: number +} { + const style = window.getComputedStyle(element) + return { + top: parseFloat(style.borderTopWidth), + right: parseFloat(style.borderRightWidth), + bottom: parseFloat(style.borderBottomWidth), + left: parseFloat(style.borderLeftWidth) + } +} diff --git a/packages/extension-table-plus/src/table/utilities/getTableNodeTypes.ts b/packages/extension-table-plus/src/table/utilities/getTableNodeTypes.ts new file mode 100755 index 0000000000..2365f4a3ad --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/getTableNodeTypes.ts @@ -0,0 +1,21 @@ +import type { NodeType, Schema } from '@tiptap/pm/model' + +export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } { + if (schema.cached.tableNodeTypes) { + return schema.cached.tableNodeTypes + } + + const roles: { [key: string]: NodeType } = {} + + Object.keys(schema.nodes).forEach((type) => { + const nodeType = schema.nodes[type] + + if (nodeType.spec.tableRole) { + roles[nodeType.spec.tableRole] = nodeType + } + }) + + schema.cached.tableNodeTypes = roles + + return roles +} diff --git a/packages/extension-table-plus/src/table/utilities/isCellSelection.ts b/packages/extension-table-plus/src/table/utilities/isCellSelection.ts new file mode 100755 index 0000000000..59d8919f02 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/isCellSelection.ts @@ -0,0 +1,5 @@ +import { CellSelection } from '@tiptap/pm/tables' + +export function isCellSelection(value: unknown): value is CellSelection { + return value instanceof CellSelection +} diff --git a/packages/extension-table-plus/src/table/utilities/selectionBounds.ts b/packages/extension-table-plus/src/table/utilities/selectionBounds.ts new file mode 100644 index 0000000000..186bfa884e --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/selectionBounds.ts @@ -0,0 +1,68 @@ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { CellSelection, TableMap } from '@tiptap/pm/tables' +import type { EditorView } from '@tiptap/pm/view' + +export interface SelectionBounds { + tablePos: number + tableStart: number + map: ReturnType + minRow: number + maxRow: number + minCol: number + maxCol: number + topLeftPos: number + topRightPos: number +} + +/** + * Compute logical bounds for current CellSelection inside the provided table node. + * Returns null if current selection is not a CellSelection or not within the table node. + */ +export function getCellSelectionBounds(view: EditorView, tableNode: ProseMirrorNode): SelectionBounds | null { + const selection = view.state.selection + if (!(selection instanceof CellSelection)) return null + + const $anchor = selection.$anchorCell || selection.$anchor + let tablePos = -1 + let currentTable: ProseMirrorNode | null = null + for (let d = $anchor.depth; d > 0; d--) { + const n = $anchor.node(d) + const role = (n.type.spec as { tableRole?: string } | undefined)?.tableRole + if (n.type.name === 'table' || role === 'table') { + tablePos = $anchor.before(d) + currentTable = n + break + } + } + if (tablePos < 0 || currentTable !== tableNode) return null + + const map = TableMap.get(tableNode) + const tableStart = tablePos + 1 + + let minRow = Number.POSITIVE_INFINITY + let maxRow = Number.NEGATIVE_INFINITY + let minCol = Number.POSITIVE_INFINITY + let maxCol = Number.NEGATIVE_INFINITY + let topLeftPos: number | null = null + let topRightPos: number | null = null + + selection.forEachCell((_cell, pos) => { + const rect = map.findCell(pos - tableStart) + if (rect.top < minRow) minRow = rect.top + if (rect.left < minCol) minCol = rect.left + if (rect.bottom - 1 > maxRow) maxRow = rect.bottom - 1 + if (rect.right - 1 > maxCol) maxCol = rect.right - 1 + + if (rect.top === minRow && rect.left === minCol) { + if (topLeftPos === null || pos < topLeftPos) topLeftPos = pos + } + if (rect.top === minRow && rect.right - 1 === maxCol) { + if (topRightPos === null || pos < topRightPos) topRightPos = pos + } + }) + + if (!isFinite(minRow) || !isFinite(minCol) || topLeftPos == null) return null + if (topRightPos == null) topRightPos = topLeftPos + + return { tablePos, tableStart, map, minRow, maxRow, minCol, maxCol, topLeftPos, topRightPos } +} diff --git a/packages/extension-table-plus/src/types.ts b/packages/extension-table-plus/src/types.ts new file mode 100755 index 0000000000..eef697b269 --- /dev/null +++ b/packages/extension-table-plus/src/types.ts @@ -0,0 +1,19 @@ +import type { ParentConfig } from '@tiptap/core' + +declare module '@tiptap/core' { + interface NodeConfig { + /** + * A string or function to determine the role of the table. + * @default 'table' + * @example () => 'table' + */ + tableRole?: + | string + | ((this: { + name: string + options: Options + storage: Storage + parent: ParentConfig>['tableRole'] + }) => string) + } +} diff --git a/packages/extension-table-plus/tsdown.config.ts b/packages/extension-table-plus/tsdown.config.ts new file mode 100755 index 0000000000..8e7b52e10c --- /dev/null +++ b/packages/extension-table-plus/tsdown.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig( + [ + 'src/table/index.ts', + 'src/cell/index.ts', + 'src/header/index.ts', + 'src/kit/index.ts', + 'src/row/index.ts', + 'src/index.ts' + ].map((entry) => ({ + entry: [entry], + tsconfig: '../../tsconfig.build.json', + outDir: `dist${entry.replace('src', '').split('/').slice(0, -1).join('/')}`, + dts: true, + sourcemap: true, + format: ['esm', 'cjs'], + external: [/^[^./]/] + })) +) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index f2b856ef1d..c5b7c6ec4e 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -36,6 +36,7 @@ export enum IpcChannel { App_LogToMain = 'app:log-to-main', App_SaveData = 'app:save-data', App_SetFullScreen = 'app:set-full-screen', + App_IsFullScreen = 'app:is-full-screen', App_MacIsProcessTrusted = 'app:mac-is-process-trusted', App_MacRequestProcessTrust = 'app:mac-request-process-trust', @@ -140,16 +141,25 @@ export enum IpcChannel { File_Upload = 'file:upload', File_Clear = 'file:clear', File_Read = 'file:read', + File_ReadExternal = 'file:readExternal', File_Delete = 'file:delete', File_DeleteDir = 'file:deleteDir', + File_DeleteExternalFile = 'file:deleteExternalFile', + File_DeleteExternalDir = 'file:deleteExternalDir', + File_Move = 'file:move', + File_MoveDir = 'file:moveDir', + File_Rename = 'file:rename', + File_RenameDir = 'file:renameDir', File_Get = 'file:get', File_SelectFolder = 'file:selectFolder', File_CreateTempFile = 'file:createTempFile', + File_Mkdir = 'file:mkdir', File_Write = 'file:write', File_WriteWithId = 'file:writeWithId', File_SaveImage = 'file:saveImage', File_Base64Image = 'file:base64Image', File_SaveBase64Image = 'file:saveBase64Image', + File_SavePastedImage = 'file:savePastedImage', File_Download = 'file:download', File_Copy = 'file:copy', File_BinaryImage = 'file:binaryImage', @@ -159,6 +169,11 @@ export enum IpcChannel { Fs_ReadText = 'fs:readText', File_OpenWithRelativePath = 'file:openWithRelativePath', File_IsTextFile = 'file:isTextFile', + File_GetDirectoryStructure = 'file:getDirectoryStructure', + File_CheckFileName = 'file:checkFileName', + File_ValidateNotesDirectory = 'file:validateNotesDirectory', + File_StartWatcher = 'file:startWatcher', + File_StopWatcher = 'file:stopWatcher', // file service FileService_Upload = 'file-service:upload', @@ -285,5 +300,8 @@ export enum IpcChannel { CodeTools_Run = 'code-tools:run', // OCR - OCR_ocr = 'ocr:ocr' + OCR_ocr = 'ocr:ocr', + + // Cherryin + Cherryin_GetSignature = 'cherryin:get-signature' } diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 82b78459a5..18246cd1e7 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -207,7 +207,7 @@ export const defaultTimeout = 10 * 1000 * 60 export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network'] -export const MIN_WINDOW_WIDTH = 1080 +export const MIN_WINDOW_WIDTH = 960 export const SECOND_MIN_WINDOW_WIDTH = 520 export const MIN_WINDOW_HEIGHT = 600 export const defaultByPassRules = 'localhost,127.0.0.1,::1' diff --git a/packages/shared/config/types.ts b/packages/shared/config/types.ts index 28bb4acf65..d46717b47e 100644 --- a/packages/shared/config/types.ts +++ b/packages/shared/config/types.ts @@ -9,3 +9,11 @@ export type LoaderReturn = { message?: string messageSource?: 'preprocess' | 'embedding' } + +export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir' + +export type FileChangeEvent = { + eventType: FileChangeEventType + filePath: string + watchPath: string +} diff --git a/scripts/after-pack.js b/scripts/after-pack.js index e392098771..b8e4c8af1d 100644 --- a/scripts/after-pack.js +++ b/scripts/after-pack.js @@ -1,89 +1,10 @@ -const { Arch } = require('electron-builder') const fs = require('fs') const path = require('path') exports.default = async function (context) { const platform = context.packager.platform.name - const arch = context.arch - - if (platform === 'mac') { - const node_modules_path = path.join( - context.appOutDir, - 'Cherry Studio.app', - 'Contents', - 'Resources', - 'app.asar.unpacked', - 'node_modules' - ) - - keepPackageNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64']) - } - - if (platform === 'linux') { - const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules') - const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl'] - keepPackageNodeFiles(node_modules_path, '@libsql', _arch) - - // 删除 macOS 专用的 OCR 包 - removeMacOnlyPackages(node_modules_path) - } - - if (platform === 'windows') { - const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules') - if (arch === Arch.arm64) { - keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc']) - keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc']) - } - if (arch === Arch.x64) { - keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc']) - keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc']) - } - - removeMacOnlyPackages(node_modules_path) - } - if (platform === 'windows') { fs.rmSync(path.join(context.appOutDir, 'LICENSE.electron.txt'), { force: true }) fs.rmSync(path.join(context.appOutDir, 'LICENSES.chromium.html'), { force: true }) } } - -/** - * 删除 macOS 专用的包 - * @param {string} nodeModulesPath - */ -function removeMacOnlyPackages(nodeModulesPath) { - const macOnlyPackages = [] - - macOnlyPackages.forEach((packageName) => { - const packagePath = path.join(nodeModulesPath, packageName) - if (fs.existsSync(packagePath)) { - fs.rmSync(packagePath, { recursive: true, force: true }) - console.log(`[After Pack] Removed macOS-only package: ${packageName}`) - } - }) -} - -/** - * 使用指定架构的 node_modules 文件 - * @param {*} nodeModulesPath - * @param {*} packageName - * @param {*} arch - * @returns - */ -function keepPackageNodeFiles(nodeModulesPath, packageName, arch) { - const modulePath = path.join(nodeModulesPath, packageName) - - if (!fs.existsSync(modulePath)) { - console.log(`[After Pack] Directory does not exist: ${modulePath}`) - return - } - - const dirs = fs.readdirSync(modulePath) - dirs - .filter((dir) => !arch.includes(dir)) - .forEach((dir) => { - fs.rmSync(path.join(modulePath, dir), { recursive: true, force: true }) - console.log(`[After Pack] Removed dir: ${dir}`, arch) - }) -} diff --git a/scripts/before-pack.js b/scripts/before-pack.js new file mode 100644 index 0000000000..59c0a39171 --- /dev/null +++ b/scripts/before-pack.js @@ -0,0 +1,91 @@ +const { Arch } = require('electron-builder') +const { downloadNpmPackage } = require('./utils') + +// if you want to add new prebuild binaries packages with different architectures, you can add them here +// please add to allX64 and allArm64 from yarn.lock +const allArm64 = { + '@img/sharp-darwin-arm64': '0.34.3', + '@img/sharp-win32-arm64': '0.34.3', + '@img/sharp-linux-arm64': '0.34.3', + + '@img/sharp-libvips-darwin-arm64': '1.2.0', + '@img/sharp-libvips-linux-arm64': '1.2.0', + + '@libsql/darwin-arm64': '0.4.7', + '@libsql/linux-arm64-gnu': '0.4.7', + '@strongtz/win32-arm64-msvc': '0.4.7', + + '@napi-rs/system-ocr-darwin-arm64': '1.0.2', + '@napi-rs/system-ocr-win32-arm64-msvc': '1.0.2' +} + +const allX64 = { + '@img/sharp-darwin-x64': '0.34.3', + '@img/sharp-linux-x64': '0.34.3', + '@img/sharp-win32-x64': '0.34.3', + + '@img/sharp-libvips-darwin-x64': '1.2.0', + '@img/sharp-libvips-linux-x64': '1.2.0', + + '@libsql/darwin-x64': '0.4.7', + '@libsql/linux-x64-gnu': '0.4.7', + '@libsql/win32-x64-msvc': '0.4.7', + + '@napi-rs/system-ocr-darwin-x64': '1.0.2', + '@napi-rs/system-ocr-win32-x64-msvc': '1.0.2' +} + +const platformToArch = { + mac: 'darwin', + windows: 'win32', + linux: 'linux' +} + +exports.default = async function (context) { + const arch = context.arch + const archType = arch === Arch.arm64 ? 'arm64' : 'x64' + const platform = context.packager.platform.name + + const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**') + const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*') + + const downloadPackages = async (packages) => { + console.log('downloading packages ......') + const downloadPromises = [] + + for (const name of Object.keys(packages)) { + if (name.includes(`${platformToArch[platform]}`) && name.includes(`-${archType}`)) { + downloadPromises.push( + downloadNpmPackage( + name, + `https://registry.npmjs.org/${name}/-/${name.split('/').pop()}-${packages[name]}.tgz` + ) + ) + } + } + + await Promise.all(downloadPromises) + } + + const changeFilters = async (packages, filtersToExclude, filtersToInclude) => { + await downloadPackages(packages) + // remove filters for the target architecture (allow inclusion) + + let filters = context.packager.config.files[0].filter + filters = filters.filter((filter) => !filtersToInclude.includes(filter)) + // add filters for other architectures (exclude them) + filters.push(...filtersToExclude) + + context.packager.config.files[0].filter = filters + } + + if (arch === Arch.arm64) { + await changeFilters(allArm64, x64Filters, arm64Filters) + return + } + + if (arch === Arch.x64) { + await changeFilters(allX64, arm64Filters, x64Filters) + return + } +} diff --git a/scripts/build-npm.js b/scripts/build-npm.js deleted file mode 100644 index 7718410bbb..0000000000 --- a/scripts/build-npm.js +++ /dev/null @@ -1,44 +0,0 @@ -const { downloadNpmPackage } = require('./utils') - -async function downloadNpm(platform) { - if (!platform || platform === 'mac') { - downloadNpmPackage( - '@libsql/darwin-arm64', - 'https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.4.7.tgz' - ) - downloadNpmPackage('@libsql/darwin-x64', 'https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.4.7.tgz') - } - - if (!platform || platform === 'linux') { - downloadNpmPackage( - '@libsql/linux-arm64-gnu', - 'https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.4.7.tgz' - ) - downloadNpmPackage( - '@libsql/linux-arm64-musl', - 'https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.4.7.tgz' - ) - downloadNpmPackage( - '@libsql/linux-x64-gnu', - 'https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.4.7.tgz' - ) - downloadNpmPackage( - '@libsql/linux-x64-musl', - 'https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.4.7.tgz' - ) - } - - if (!platform || platform === 'windows') { - downloadNpmPackage( - '@libsql/win32-x64-msvc', - 'https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.4.7.tgz' - ) - downloadNpmPackage( - '@strongtz/win32-arm64-msvc', - 'https://registry.npmjs.org/@strongtz/win32-arm64-msvc/-/win32-arm64-msvc-0.4.7.tgz' - ) - } -} - -const platformArg = process.argv[2] -downloadNpm(platformArg) diff --git a/scripts/update-i18n.ts b/scripts/update-i18n.ts index 488ffb1c92..72fcca8ab9 100644 --- a/scripts/update-i18n.ts +++ b/scripts/update-i18n.ts @@ -66,7 +66,7 @@ ${JSON.stringify({ confirm: '确定要备份数据吗?', select_model: '选择模型', title: '文件', - deeply_thought: '已深度思考(用时 {{secounds}} 秒)' + deeply_thought: '已深度思考(用时 {{seconds}} 秒)' })} ###################################################### MAKE SURE TO OUTPUT IN Russian. DO NOT OUTPUT IN UNSPECIFIED LANGUAGE. diff --git a/scripts/utils.js b/scripts/utils.js index 6cb5b35c3b..cafa07b681 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -1,12 +1,15 @@ const fs = require('fs') const path = require('path') const os = require('os') +const zlib = require('zlib') +const tar = require('tar') +const { pipeline } = require('stream/promises') -function downloadNpmPackage(packageName, url) { +async function downloadNpmPackage(packageName, url) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-download-')) - const targetDir = path.join('./node_modules/', packageName) - const filename = packageName.replace('/', '-') + '.tgz' + const filename = path.join(tempDir, packageName.replace('/', '-') + '.tgz') + const extractDir = path.join(tempDir, 'extract') // Skip if directory already exists if (fs.existsSync(targetDir)) { @@ -16,23 +19,44 @@ function downloadNpmPackage(packageName, url) { try { console.log(`Downloading ${packageName}...`, url) - const { execSync } = require('child_process') - execSync(`curl --fail -o ${filename} ${url}`) + + // Download file using fetch API + const response = await fetch(url) + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const fileStream = fs.createWriteStream(filename) + await pipeline(response.body, fileStream) console.log(`Extracting ${filename}...`) - execSync(`tar -xvf ${filename}`) - execSync(`rm -rf ${filename}`) - execSync(`mkdir -p ${targetDir}`) - execSync(`mv package/* ${targetDir}/`) + + // Create extraction directory + fs.mkdirSync(extractDir, { recursive: true }) + + // Extract tar.gz file using Node.js streams + await pipeline(fs.createReadStream(filename), zlib.createGunzip(), tar.extract({ cwd: extractDir })) + + // Remove the downloaded file + fs.rmSync(filename, { force: true }) + + // Create target directory + fs.mkdirSync(targetDir, { recursive: true }) + + // Move extracted package contents to target directory + const packageDir = path.join(extractDir, 'package') + if (fs.existsSync(packageDir)) { + fs.cpSync(packageDir, targetDir, { recursive: true }) + } } catch (error) { console.error(`Error processing ${packageName}: ${error.message}`) - if (fs.existsSync(filename)) { - fs.unlinkSync(filename) - } throw error + } finally { + // Clean up temp directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }) + } } - - fs.rmSync(tempDir, { recursive: true, force: true }) } module.exports = { diff --git a/src/main/config.ts b/src/main/config.ts index 5a6f667d18..0cffcd1768 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -20,3 +20,5 @@ export const titleBarOverlayLight = { color: 'rgba(255,255,255,0)', symbolColor: '#000' } + +global.CHERRYIN_CLIENT_SECRET = import.meta.env.MAIN_VITE_CHERRYIN_CLIENT_SECRET diff --git a/src/main/integration/cherryin/index.js b/src/main/integration/cherryin/index.js new file mode 100644 index 0000000000..af185389eb --- /dev/null +++ b/src/main/integration/cherryin/index.js @@ -0,0 +1 @@ +var _0x6gg;const crypto=require("\u0063\u0072\u0079\u0070\u0074\u006F");_0x6gg='\u006D\u006F\u006C\u006A\u0065\u0065';var _0x111cbe;const CLIENT_ID="oiduts-yrrehc".split("").reverse().join("");_0x111cbe=(977158^977167)+(164595^164594);var _0x6d6adc=(756649^756650)+(497587^497587);const CLIENT_SECRET_SUFFIX="\u0047\u0076\u0049\u0036\u0049\u0035\u005A\u0072\u0045\u0048\u0063\u0047\u004F\u0057\u006A\u004F\u0035\u0041\u004B\u0068\u004A\u004B\u0047\u006D\u006E\u0077\u0077\u0047\u0066\u004D\u0036\u0032\u0058\u004B\u0070\u0057\u0071\u006B\u006A\u0068\u0076\u007A\u0052\u0055\u0032\u004E\u005A\u0049\u0069\u006E\u004D\u0037\u0037\u0061\u0054\u0047\u0049\u0071\u0068\u0071\u0079\u0073\u0030\u0067";_0x6d6adc=233169^233176;const CLIENT_SECRET=global['\u0043\u0048\u0045\u0052\u0052\u0059\u0049\u004E\u005F\u0043\u004C\u0049\u0045\u004E\u0054\u005F\u0053\u0045\u0043\u0052\u0045\u0054']+"\u002E"+CLIENT_SECRET_SUFFIX;class SignatureClient{constructor(clientId,clientSecret){this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064']=clientId||CLIENT_ID;this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']=clientSecret||CLIENT_SECRET;this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']=this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']['\u0062\u0069\u006E\u0064'](this);}generateSignature(options){const{"method":method,"path":path,"query":query='',"body":body=''}=options;const timestamp=Math['\u0066\u006C\u006F\u006F\u0072'](Date['\u006E\u006F\u0077']()/(110765^111429))['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();var _0xe08cc=(212246^212244)+(773521^773523);let bodyString='';_0xe08cc=(606778^606776)+(962748^962740);if(body){if(typeof body==="\u006F\u0062\u006A\u0065\u0063\u0074"){bodyString=JSON['\u0073\u0074\u0072\u0069\u006E\u0067\u0069\u0066\u0079'](body);}else{bodyString=body['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();}}const signatureParts=[method['\u0074\u006F\u0055\u0070\u0070\u0065\u0072\u0043\u0061\u0073\u0065'](),path,query,this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],timestamp,bodyString];var _0x5693g=(936664^936668)+(685268^685277);const signatureString=signatureParts['\u006A\u006F\u0069\u006E']("\u000A");_0x5693g=(266582^266576)+(337322^337315);const hmac=crypto['\u0063\u0072\u0065\u0061\u0074\u0065\u0048\u006D\u0061\u0063']("\u0073\u0068\u0061\u0032\u0035\u0036",this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']);hmac['\u0075\u0070\u0064\u0061\u0074\u0065'](signatureString);var _0x5fba=(354480^354481)+(537437^537434);const signature=hmac['\u0064\u0069\u0067\u0065\u0073\u0074']("\u0068\u0065\u0078");_0x5fba=(249614^249610)+(915906^915914);return{'X-Client-ID':this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],'X-Timestamp':timestamp,'X-Signature':signature};}}const signatureClient=new SignatureClient();const generateSignature=signatureClient['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065'];module['\u0065\u0078\u0070\u006F\u0072\u0074\u0073']={'\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065\u0043\u006C\u0069\u0065\u006E\u0074':SignatureClient,'\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065':generateSignature}; \ No newline at end of file diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 20ccf06d76..8b22fee49c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { isLinux, isMac, isPortable, isWin } from '@main/constant' +import { generateSignature } from '@main/integration/cherryin' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { handleZoomFactor } from '@main/utils/zoom' import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' @@ -57,7 +58,15 @@ import { setOpenLinkExternal } from './services/WebviewService' import { windowService } from './services/WindowService' import { calculateDirectorySize, getResourcePath } from './utils' import { decrypt, encrypt } from './utils/aes' -import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, isPathInside, untildify } from './utils/file' +import { + getCacheDir, + getConfigDir, + getFilesDir, + getNotesDir, + hasWritePermission, + isPathInside, + untildify +} from './utils/file' import { updateAppDataConfig } from './utils/init' import { compress, decompress } from './utils/zip' @@ -77,11 +86,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // Initialize Python service with main window pythonService.setMainWindow(mainWindow) + const checkMainWindow = () => { + if (!mainWindow || mainWindow.isDestroyed()) { + throw new Error('Main window does not exist or has been destroyed') + } + } + ipcMain.handle(IpcChannel.App_Info, () => ({ version: app.getVersion(), isPackaged: app.isPackaged, appPath: app.getAppPath(), filesPath: getFilesDir(), + notesPath: getNotesDir(), configPath: getConfigDir(), appDataPath: app.getPath('userData'), resourcesPath: getResourcePath(), @@ -196,6 +212,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { mainWindow.setFullScreen(value) }) + ipcMain.handle(IpcChannel.App_IsFullScreen, (): boolean => { + return mainWindow.isFullScreen() + }) + ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => { configManager.set(key, value, isNotify) }) @@ -429,16 +449,25 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.File_Upload, fileManager.uploadFile.bind(fileManager)) ipcMain.handle(IpcChannel.File_Clear, fileManager.clear.bind(fileManager)) ipcMain.handle(IpcChannel.File_Read, fileManager.readFile.bind(fileManager)) + ipcMain.handle(IpcChannel.File_ReadExternal, fileManager.readExternalFile.bind(fileManager)) ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile.bind(fileManager)) - ipcMain.handle('file:deleteDir', fileManager.deleteDir.bind(fileManager)) + ipcMain.handle(IpcChannel.File_DeleteDir, fileManager.deleteDir.bind(fileManager)) + ipcMain.handle(IpcChannel.File_DeleteExternalFile, fileManager.deleteExternalFile.bind(fileManager)) + ipcMain.handle(IpcChannel.File_DeleteExternalDir, fileManager.deleteExternalDir.bind(fileManager)) + ipcMain.handle(IpcChannel.File_Move, fileManager.moveFile.bind(fileManager)) + ipcMain.handle(IpcChannel.File_MoveDir, fileManager.moveDir.bind(fileManager)) + ipcMain.handle(IpcChannel.File_Rename, fileManager.renameFile.bind(fileManager)) + ipcMain.handle(IpcChannel.File_RenameDir, fileManager.renameDir.bind(fileManager)) ipcMain.handle(IpcChannel.File_Get, fileManager.getFile.bind(fileManager)) ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder.bind(fileManager)) ipcMain.handle(IpcChannel.File_CreateTempFile, fileManager.createTempFile.bind(fileManager)) + ipcMain.handle(IpcChannel.File_Mkdir, fileManager.mkdir.bind(fileManager)) ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile.bind(fileManager)) ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId.bind(fileManager)) ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage.bind(fileManager)) ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image.bind(fileManager)) ipcMain.handle(IpcChannel.File_SaveBase64Image, fileManager.saveBase64Image.bind(fileManager)) + ipcMain.handle(IpcChannel.File_SavePastedImage, fileManager.savePastedImage.bind(fileManager)) ipcMain.handle(IpcChannel.File_Base64File, fileManager.base64File.bind(fileManager)) ipcMain.handle(IpcChannel.File_GetPdfInfo, fileManager.pdfPageCount.bind(fileManager)) ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile.bind(fileManager)) @@ -446,6 +475,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager)) ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager)) ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager)) + ipcMain.handle(IpcChannel.File_GetDirectoryStructure, fileManager.getDirectoryStructure.bind(fileManager)) + ipcMain.handle(IpcChannel.File_CheckFileName, fileManager.fileNameGuard.bind(fileManager)) + ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager)) + ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager)) + ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager)) // file service ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => { @@ -534,19 +568,23 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // window ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => { - mainWindow?.setMinimumSize(width, height) + checkMainWindow() + mainWindow.setMinimumSize(width, height) }) ipcMain.handle(IpcChannel.Windows_ResetMinimumSize, () => { - mainWindow?.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) - const [width, height] = mainWindow?.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT] + checkMainWindow() + + mainWindow.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) + const [width, height] = mainWindow.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT] if (width < MIN_WINDOW_WIDTH) { - mainWindow?.setSize(MIN_WINDOW_WIDTH, height) + mainWindow.setSize(MIN_WINDOW_WIDTH, height) } }) ipcMain.handle(IpcChannel.Windows_GetSize, () => { - const [width, height] = mainWindow?.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT] + checkMainWindow() + const [width, height] = mainWindow.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT] return [width, height] }) @@ -714,4 +752,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // OCR ipcMain.handle(IpcChannel.OCR_ocr, (_, ...args: Parameters) => ocrService.ocr(...args)) + + // CherryIN + ipcMain.handle(IpcChannel.Cherryin_GetSignature, (_, params) => generateSignature(params)) } diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts index 256b4dcbd6..66575870c7 100644 --- a/src/main/services/CodeToolsService.ts +++ b/src/main/services/CodeToolsService.ts @@ -323,7 +323,7 @@ class CodeToolsService { ? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&` : `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&` - const installCommand = `${installEnvPrefix} ${bunPath} install -g ${packageName}` + const installCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}` baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && ${baseCommand}` } diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 39a16713d7..985f6dfef9 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -1,8 +1,18 @@ import { loggerService } from '@logger' -import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file' +import { + checkName, + getFilesDir, + getFileType, + getName, + getNotesDir, + getTempDir, + readTextFileWithAutoEncoding, + scanDir +} from '@main/utils/file' import { documentExts, imageExts, KB, MB } from '@shared/config/constant' -import { FileMetadata } from '@types' +import { FileMetadata, NotesTreeNode } from '@types' import chardet from 'chardet' +import chokidar, { FSWatcher } from 'chokidar' import * as crypto from 'crypto' import { dialog, @@ -26,9 +36,39 @@ import WordExtractor from 'word-extractor' const logger = loggerService.withContext('FileStorage') +interface FileWatcherConfig { + watchExtensions?: string[] + ignoredPatterns?: (string | RegExp)[] + debounceMs?: number + maxDepth?: number + usePolling?: boolean + retryOnError?: boolean + retryDelayMs?: number + stabilityThreshold?: number + eventChannel?: string +} + +const DEFAULT_WATCHER_CONFIG: Required = { + watchExtensions: ['.md', '.markdown', '.txt'], + ignoredPatterns: [/(^|[/\\])\../, '**/node_modules/**', '**/.git/**', '**/*.tmp', '**/*.temp', '**/.DS_Store'], + debounceMs: 1000, + maxDepth: 10, + usePolling: false, + retryOnError: true, + retryDelayMs: 5000, + stabilityThreshold: 500, + eventChannel: 'file-change' +} + class FileStorage { private storageDir = getFilesDir() + private notesDir = getNotesDir() private tempDir = getTempDir() + private watcher?: FSWatcher + private watcherSender?: Electron.WebContents + private currentWatchPath?: string + private debounceTimer?: NodeJS.Timeout + private watcherConfig: Required = DEFAULT_WATCHER_CONFIG constructor() { this.initStorageDir() @@ -39,6 +79,9 @@ class FileStorage { if (!fs.existsSync(this.storageDir)) { fs.mkdirSync(this.storageDir, { recursive: true }) } + if (!fs.existsSync(this.notesDir)) { + fs.mkdirSync(this.storageDir, { recursive: true }) + } if (!fs.existsSync(this.tempDir)) { fs.mkdirSync(this.tempDir, { recursive: true }) } @@ -209,7 +252,7 @@ class FileStorage { const ext = path.extname(filePath) const fileType = getFileType(ext) - const fileInfo: FileMetadata = { + return { id: uuidv4(), origin_name: path.basename(filePath), name: path.basename(filePath), @@ -220,8 +263,6 @@ class FileStorage { type: fileType, count: 1 } - - return fileInfo } // @TraceProperty({ spanName: 'deleteFile', tag: 'FileStorage' }) @@ -239,6 +280,122 @@ class FileStorage { await fs.promises.rm(path.join(this.storageDir, id), { recursive: true }) } + public deleteExternalFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise => { + try { + if (!fs.existsSync(filePath)) { + return + } + + await fs.promises.rm(filePath, { force: true }) + logger.debug(`External file deleted successfully: ${filePath}`) + } catch (error) { + logger.error('Failed to delete external file:', error as Error) + throw error + } + } + + public deleteExternalDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise => { + try { + if (!fs.existsSync(dirPath)) { + return + } + + await fs.promises.rm(dirPath, { recursive: true, force: true }) + logger.debug(`External directory deleted successfully: ${dirPath}`) + } catch (error) { + logger.error('Failed to delete external directory:', error as Error) + throw error + } + } + + public moveFile = async (_: Electron.IpcMainInvokeEvent, filePath: string, newPath: string): Promise => { + try { + if (!fs.existsSync(filePath)) { + throw new Error(`Source file does not exist: ${filePath}`) + } + + // 确保目标目录存在 + const destDir = path.dirname(newPath) + if (!fs.existsSync(destDir)) { + await fs.promises.mkdir(destDir, { recursive: true }) + } + + // 移动文件 + await fs.promises.rename(filePath, newPath) + logger.debug(`File moved successfully: ${filePath} to ${newPath}`) + } catch (error) { + logger.error('Move file failed:', error as Error) + throw error + } + } + + public moveDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string, newDirPath: string): Promise => { + try { + if (!fs.existsSync(dirPath)) { + throw new Error(`Source directory does not exist: ${dirPath}`) + } + + // 确保目标父目录存在 + const parentDir = path.dirname(newDirPath) + if (!fs.existsSync(parentDir)) { + await fs.promises.mkdir(parentDir, { recursive: true }) + } + + // 移动目录 + await fs.promises.rename(dirPath, newDirPath) + logger.debug(`Directory moved successfully: ${dirPath} to ${newDirPath}`) + } catch (error) { + logger.error('Move directory failed:', error as Error) + throw error + } + } + + public renameFile = async (_: Electron.IpcMainInvokeEvent, filePath: string, newName: string): Promise => { + try { + if (!fs.existsSync(filePath)) { + throw new Error(`Source file does not exist: ${filePath}`) + } + + const dirPath = path.dirname(filePath) + const newFilePath = path.join(dirPath, newName + '.md') + + // 如果目标文件已存在,抛出错误 + if (fs.existsSync(newFilePath)) { + throw new Error(`Target file already exists: ${newFilePath}`) + } + + // 重命名文件 + await fs.promises.rename(filePath, newFilePath) + logger.debug(`File renamed successfully: ${filePath} to ${newFilePath}`) + } catch (error) { + logger.error('Rename file failed:', error as Error) + throw error + } + } + + public renameDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string, newName: string): Promise => { + try { + if (!fs.existsSync(dirPath)) { + throw new Error(`Source directory does not exist: ${dirPath}`) + } + + const parentDir = path.dirname(dirPath) + const newDirPath = path.join(parentDir, newName) + + // 如果目标目录已存在,抛出错误 + if (fs.existsSync(newDirPath)) { + throw new Error(`Target directory already exists: ${newDirPath}`) + } + + // 重命名目录 + await fs.promises.rename(dirPath, newDirPath) + logger.debug(`Directory renamed successfully: ${dirPath} to ${newDirPath}`) + } catch (error) { + logger.error('Rename directory failed:', error as Error) + throw error + } + } + public readFile = async ( _: Electron.IpcMainInvokeEvent, id: string, @@ -282,6 +439,51 @@ class FileStorage { } } + public readExternalFile = async ( + _: Electron.IpcMainInvokeEvent, + filePath: string, + detectEncoding: boolean = false + ): Promise => { + if (!fs.existsSync(filePath)) { + throw new Error(`File does not exist: ${filePath}`) + } + + const fileExtension = path.extname(filePath) + + if (documentExts.includes(fileExtension)) { + const originalCwd = process.cwd() + try { + chdir(this.tempDir) + + if (fileExtension === '.doc') { + const extractor = new WordExtractor() + const extracted = await extractor.extract(filePath) + chdir(originalCwd) + return extracted.getBody() + } + + const data = await officeParser.parseOfficeAsync(filePath) + chdir(originalCwd) + return data + } catch (error) { + chdir(originalCwd) + logger.error('Failed to read file:', error as Error) + throw error + } + } + + try { + if (detectEncoding) { + return readTextFileWithAutoEncoding(filePath) + } else { + return fs.readFileSync(filePath, 'utf-8') + } + } catch (error) { + logger.error('Failed to read file:', error as Error) + throw new Error(`Failed to read file: ${filePath}.`) + } + } + public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise => { if (!fs.existsSync(this.tempDir)) { fs.mkdirSync(this.tempDir, { recursive: true }) @@ -298,6 +500,32 @@ class FileStorage { await fs.promises.writeFile(filePath, data) } + public fileNameGuard = async ( + _: Electron.IpcMainInvokeEvent, + dirPath: string, + fileName: string, + isFile: boolean + ): Promise<{ safeName: string; exists: boolean }> => { + const safeName = checkName(fileName) + const finalName = getName(dirPath, safeName, isFile) + const fullPath = path.join(dirPath, finalName + (isFile ? '.md' : '')) + const exists = fs.existsSync(fullPath) + + logger.debug(`File name guard: ${fileName} -> ${finalName}, exists: ${exists}`) + return { safeName: finalName, exists } + } + + public mkdir = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise => { + try { + logger.debug(`Attempting to create directory: ${dirPath}`) + await fs.promises.mkdir(dirPath, { recursive: true }) + return dirPath + } catch (error) { + logger.error('Failed to create directory:', error as Error) + throw new Error(`Failed to create directory: ${dirPath}. Error: ${(error as Error).message}`) + } + } + public base64Image = async ( _: Electron.IpcMainInvokeEvent, id: string @@ -340,7 +568,7 @@ class FileStorage { await fs.promises.writeFile(destPath, buffer) - const fileMetadata: FileMetadata = { + return { id: uuid, origin_name: uuid + ext, name: uuid + ext, @@ -351,14 +579,84 @@ class FileStorage { type: getFileType(ext), count: 1 } - - return fileMetadata } catch (error) { logger.error('Failed to save base64 image:', error as Error) throw error } } + public savePastedImage = async ( + _: Electron.IpcMainInvokeEvent, + imageData: Uint8Array | Buffer, + extension?: string + ): Promise => { + try { + const uuid = uuidv4() + const ext = extension || '.png' + const destPath = path.join(this.storageDir, uuid + ext) + + logger.debug('Saving pasted image:', { + storageDir: this.storageDir, + destPath, + bufferSize: imageData.length + }) + + // 确保目录存在 + if (!fs.existsSync(this.storageDir)) { + fs.mkdirSync(this.storageDir, { recursive: true }) + } + + // 确保 imageData 是 Buffer + const buffer = Buffer.isBuffer(imageData) ? imageData : Buffer.from(imageData) + + // 如果图片大于1MB,进行压缩处理 + if (buffer.length > MB) { + await this.compressImageBuffer(buffer, destPath, ext) + } else { + await fs.promises.writeFile(destPath, buffer) + } + + const stats = await fs.promises.stat(destPath) + + return { + id: uuid, + origin_name: `pasted_image_${uuid}${ext}`, + name: uuid + ext, + path: destPath, + created_at: new Date().toISOString(), + size: stats.size, + ext: ext.slice(1), + type: getFileType(ext), + count: 1 + } + } catch (error) { + logger.error('Failed to save pasted image:', error as Error) + throw error + } + } + + private async compressImageBuffer(imageBuffer: Buffer, destPath: string, ext: string): Promise { + try { + // 创建临时文件 + const tempPath = path.join(this.tempDir, `temp_${uuidv4()}${ext}`) + await fs.promises.writeFile(tempPath, imageBuffer) + + // 使用现有的压缩方法 + await this.compressImage(tempPath, destPath) + + // 清理临时文件 + try { + await fs.promises.unlink(tempPath) + } catch (error) { + logger.warn('Failed to cleanup temp file:', error as Error) + } + } catch (error) { + logger.error('Image buffer compression failed, saving original:', error as Error) + // 压缩失败时保存原始文件 + await fs.promises.writeFile(destPath, imageBuffer) + } + } + public base64File = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: string; mime: string }> => { const filePath = path.join(this.storageDir, id) const buffer = await fs.promises.readFile(filePath) @@ -384,7 +682,7 @@ class FileStorage { public clear = async (): Promise => { await fs.promises.rm(this.storageDir, { recursive: true }) - await this.initStorageDir() + this.initStorageDir() } public clearTemp = async (): Promise => { @@ -432,6 +730,7 @@ class FileStorage { /** * 通过相对路径打开文件,跨设备时使用 + * @param _ * @param file */ public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise => { @@ -443,6 +742,79 @@ class FileStorage { } } + public getDirectoryStructure = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise => { + try { + return await scanDir(dirPath) + } catch (error) { + logger.error('Failed to get directory structure:', error as Error) + throw error + } + } + + public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise => { + try { + if (!dirPath || typeof dirPath !== 'string') { + return false + } + + // Normalize path + const normalizedPath = path.resolve(dirPath) + + // Check if directory exists + if (!fs.existsSync(normalizedPath)) { + return false + } + + // Check if it's actually a directory + const stats = fs.statSync(normalizedPath) + if (!stats.isDirectory()) { + return false + } + + // Get app paths to prevent selection of restricted directories + const appDataPath = path.resolve(process.env.APPDATA || path.join(require('os').homedir(), '.config')) + const filesDir = path.resolve(getFilesDir()) + const currentNotesDir = path.resolve(getNotesDir()) + + // Prevent selecting app data directories + if ( + normalizedPath.startsWith(filesDir) || + normalizedPath.startsWith(appDataPath) || + normalizedPath === currentNotesDir + ) { + logger.warn(`Invalid directory selection: ${normalizedPath} (app data directory)`) + return false + } + + // Prevent selecting system root directories + const isSystemRoot = + process.platform === 'win32' + ? /^[a-zA-Z]:[\\/]?$/.test(normalizedPath) + : normalizedPath === '/' || + normalizedPath === '/usr' || + normalizedPath === '/etc' || + normalizedPath === '/System' + + if (isSystemRoot) { + logger.warn(`Invalid directory selection: ${normalizedPath} (system root directory)`) + return false + } + + // Check write permissions + try { + fs.accessSync(normalizedPath, fs.constants.W_OK) + } catch (error) { + logger.warn(`Directory not writable: ${normalizedPath}`) + return false + } + + return true + } catch (error) { + logger.error('Failed to validate notes directory:', error as Error) + return false + } + } + public save = async ( _: Electron.IpcMainInvokeEvent, fileName: string, @@ -461,7 +833,7 @@ class FileStorage { } if (!result.canceled && result.filePath) { - await writeFileSync(result.filePath, content, { encoding: 'utf-8' }) + writeFileSync(result.filePath, content, { encoding: 'utf-8' }) } return result.filePath @@ -552,7 +924,7 @@ class FileStorage { const stats = await fs.promises.stat(destPath) const fileType = getFileType(ext) - const fileMetadata: FileMetadata = { + return { id: uuid, origin_name: filename, name: uuid + ext, @@ -563,8 +935,6 @@ class FileStorage { type: fileType, count: 1 } - - return fileMetadata } catch (error) { logger.error('Download file error:', error as Error) throw error @@ -629,6 +999,205 @@ class FileStorage { } } + public startFileWatcher = async ( + event: Electron.IpcMainInvokeEvent, + dirPath: string, + config?: FileWatcherConfig + ): Promise => { + try { + this.watcherConfig = { ...DEFAULT_WATCHER_CONFIG, ...config } + + if (!dirPath?.trim()) { + throw new Error('Directory path is required') + } + + const normalizedPath = path.resolve(dirPath.trim()) + + if (!fs.existsSync(normalizedPath)) { + throw new Error(`Directory does not exist: ${normalizedPath}`) + } + + const stats = fs.statSync(normalizedPath) + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${normalizedPath}`) + } + + if (this.currentWatchPath === normalizedPath && this.watcher) { + this.watcherSender = event.sender + logger.debug('Already watching directory, updated sender', { path: normalizedPath }) + return + } + + await this.stopFileWatcher() + + logger.info('Starting file watcher', { + path: normalizedPath, + config: { + extensions: this.watcherConfig.watchExtensions, + debounceMs: this.watcherConfig.debounceMs, + maxDepth: this.watcherConfig.maxDepth + } + }) + + this.currentWatchPath = normalizedPath + this.watcherSender = event.sender + + const watchOptions = { + ignored: this.watcherConfig.ignoredPatterns, + persistent: true, + ignoreInitial: true, + depth: this.watcherConfig.maxDepth, + usePolling: this.watcherConfig.usePolling, + awaitWriteFinish: { + stabilityThreshold: this.watcherConfig.stabilityThreshold, + pollInterval: 100 + }, + alwaysStat: false, + atomic: true + } + + this.watcher = chokidar.watch(normalizedPath, watchOptions) + + const handleChange = this.createChangeHandler() + + this.watcher + .on('add', (filePath: string) => handleChange('add', filePath)) + .on('unlink', (filePath: string) => handleChange('unlink', filePath)) + .on('addDir', (dirPath: string) => handleChange('addDir', dirPath)) + .on('unlinkDir', (dirPath: string) => handleChange('unlinkDir', dirPath)) + .on('error', (error: unknown) => { + logger.error('File watcher error', { error: error as Error, path: normalizedPath }) + if (this.watcherConfig.retryOnError) { + this.handleWatcherError(error as Error) + } + }) + .on('ready', () => { + logger.debug('File watcher ready', { path: normalizedPath }) + }) + + logger.info('File watcher started successfully') + } catch (error) { + logger.error('Failed to start file watcher', error as Error) + this.cleanup() + throw error + } + } + + private createChangeHandler() { + return (eventType: string, filePath: string) => { + if (!this.shouldWatchFile(filePath, eventType)) { + return + } + + logger.debug('File change detected', { eventType, filePath, path: this.currentWatchPath }) + + // 对于目录操作,立即触发同步,不使用防抖 + if (eventType === 'addDir' || eventType === 'unlinkDir') { + logger.debug('Directory operation detected, triggering immediate sync', { eventType, filePath }) + this.notifyChange(eventType, filePath) + return + } + + // 对于文件操作,使用防抖机制 + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + } + + this.debounceTimer = setTimeout(() => { + this.notifyChange(eventType, filePath) + this.debounceTimer = undefined + }, this.watcherConfig.debounceMs) + } + } + + private shouldWatchFile(filePath: string, eventType: string): boolean { + if (eventType.includes('Dir')) { + return true + } + + const ext = path.extname(filePath).toLowerCase() + return this.watcherConfig.watchExtensions.includes(ext) + } + + private notifyChange(eventType: string, filePath: string) { + try { + if (!this.watcherSender || this.watcherSender.isDestroyed()) { + logger.warn('Sender destroyed, stopping watcher') + this.stopFileWatcher() + return + } + + logger.debug('Sending file change event', { + eventType, + filePath, + channel: this.watcherConfig.eventChannel, + senderExists: !!this.watcherSender, + senderDestroyed: this.watcherSender.isDestroyed() + }) + this.watcherSender.send(this.watcherConfig.eventChannel, { + eventType, + filePath, + watchPath: this.currentWatchPath + }) + logger.debug('File change event sent successfully') + } catch (error) { + logger.error('Failed to send notification', error as Error) + } + } + + private handleWatcherError(error: Error) { + const retryableErrors = ['EMFILE', 'ENFILE', 'ENOSPC'] + const isRetryable = retryableErrors.some((code) => error.message.includes(code)) + + if (isRetryable && this.currentWatchPath && this.watcherSender && !this.watcherSender.isDestroyed()) { + logger.warn('Attempting restart due to recoverable error', { error: error.message }) + + setTimeout(async () => { + try { + if (this.currentWatchPath && this.watcherSender && !this.watcherSender.isDestroyed()) { + const mockEvent = { sender: this.watcherSender } as Electron.IpcMainInvokeEvent + await this.startFileWatcher(mockEvent, this.currentWatchPath, this.watcherConfig) + } + } catch (retryError) { + logger.error('Restart failed', retryError as Error) + } + }, this.watcherConfig.retryDelayMs) + } + } + + private cleanup() { + this.currentWatchPath = undefined + this.watcherSender = undefined + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = undefined + } + } + + public stopFileWatcher = async (): Promise => { + try { + if (this.watcher) { + logger.info('Stopping file watcher', { path: this.currentWatchPath }) + await this.watcher.close() + this.watcher = undefined + logger.debug('File watcher stopped') + } + this.cleanup() + } catch (error) { + logger.error('Failed to stop file watcher', error as Error) + this.watcher = undefined + this.cleanup() + } + } + + public getWatcherStatus(): { isActive: boolean; watchPath?: string; hasValidSender: boolean } { + return { + isActive: !!this.watcher, + watchPath: this.currentWatchPath, + hasValidSender: !!this.watcherSender && !this.watcherSender.isDestroyed() + } + } + public getFilePathById(file: FileMetadata): string { return path.join(this.storageDir, file.id + file.ext) } diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts index 060708bb4b..a096dfcfd7 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -416,7 +416,6 @@ export class SelectionService { hasShadow: false, thickFrame: false, roundedCorners: true, - backgroundMaterial: 'none', // Platform specific settings // [macOS] DO NOT set focusable to false, it will make other windows bring to front together diff --git a/src/main/services/WebviewService.ts b/src/main/services/WebviewService.ts index 7a14e65c19..c14a990a04 100644 --- a/src/main/services/WebviewService.ts +++ b/src/main/services/WebviewService.ts @@ -10,6 +10,13 @@ export function initSessionUserAgent() { const newUA = originUA.replace(/CherryStudio\/\S+\s/, '').replace(/Electron\/\S+\s/, '') wvSession.setUserAgent(newUA) + wvSession.webRequest.onBeforeSendHeaders((details, cb) => { + const headers = { + ...details.requestHeaders, + 'User-Agent': newUA + } + cb({ requestHeaders: headers }) + }) } /** diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 66b4b8d955..8fcbebc642 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -5,6 +5,7 @@ import { is } from '@electron-toolkit/utils' import { loggerService } from '@logger' import { isDev, isLinux, isMac, isWin } from '@main/constant' import { getFilesDir } from '@main/utils/file' +import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { app, BrowserWindow, nativeTheme, screen, shell } from 'electron' import windowStateKeeper from 'electron-window-state' @@ -47,8 +48,8 @@ export class WindowService { } const mainWindowState = windowStateKeeper({ - defaultWidth: 960, - defaultHeight: 600, + defaultWidth: MIN_WINDOW_WIDTH, + defaultHeight: MIN_WINDOW_HEIGHT, fullScreen: false, maximize: false }) @@ -58,8 +59,8 @@ export class WindowService { y: mainWindowState.y, width: mainWindowState.width, height: mainWindowState.height, - minWidth: 960, - minHeight: 600, + minWidth: MIN_WINDOW_WIDTH, + minHeight: MIN_WINDOW_HEIGHT, show: false, autoHideMenuBar: true, transparent: false, @@ -498,6 +499,8 @@ export class WindowService { } }) + this.setupWebContentsHandlers(this.miniWindow) + miniWindowState.manage(this.miniWindow) //miniWindow should show in current desktop diff --git a/src/main/services/ocr/OcrService.ts b/src/main/services/ocr/OcrService.ts index 0d7383a24a..dfd796346f 100644 --- a/src/main/services/ocr/OcrService.ts +++ b/src/main/services/ocr/OcrService.ts @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import { isLinux } from '@main/constant' import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types' import { systemOcrService } from './builtin/SystemOcrService' @@ -33,4 +34,5 @@ export const ocrService = new OcrService() // Register built-in providers ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService)) -ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService)) + +!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService)) diff --git a/src/main/services/ocr/builtin/SystemOcrService.ts b/src/main/services/ocr/builtin/SystemOcrService.ts index cda52bfec6..34a8bb8ce9 100644 --- a/src/main/services/ocr/builtin/SystemOcrService.ts +++ b/src/main/services/ocr/builtin/SystemOcrService.ts @@ -1,4 +1,4 @@ -import { isMac, isWin } from '@main/constant' +import { isLinux, isWin } from '@main/constant' import { loadOcrImage } from '@main/utils/ocr' import { OcrAccuracy, recognize } from '@napi-rs/system-ocr' import { @@ -15,12 +15,12 @@ import { OcrBaseService } from './OcrBaseService' export class SystemOcrService extends OcrBaseService { constructor() { super() - if (!isWin && !isMac) { - throw new Error('System OCR is only supported on Windows and macOS') - } } private async ocrImage(file: ImageFileMetadata, options?: OcrSystemConfig): Promise { + if (isLinux) { + return { text: '' } + } const buffer = await loadOcrImage(file) const langs = isWin ? options?.langs : undefined const result = await recognize(buffer, OcrAccuracy.Accurate, langs) diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 150a28eaca..2f622d3544 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -5,7 +5,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant' -import { FileMetadata, FileTypes } from '@types' +import { FileMetadata, FileTypes, NotesTreeNode } from '@types' import chardet from 'chardet' import { app } from 'electron' import iconv from 'iconv-lite' @@ -148,6 +148,15 @@ export function getFilesDir() { return path.join(app.getPath('userData'), 'Data', 'Files') } +export function getNotesDir() { + const notesDir = path.join(app.getPath('userData'), 'Data', 'Notes') + if (!fs.existsSync(notesDir)) { + fs.mkdirSync(notesDir, { recursive: true }) + logger.info(`Notes directory created at: ${notesDir}`) + } + return notesDir +} + export function getConfigDir() { return path.join(os.homedir(), '.cherrystudio', 'config') } @@ -195,3 +204,215 @@ export async function readTextFileWithAutoEncoding(filePath: string): Promise { + const options = { + includeFiles: true, + includeDirectories: true, + fileExtensions: ['.md'], + ignoreHiddenFiles: true, + recursive: true, + maxDepth: 10 + } + + // 如果是第一次调用,设置basePath为当前目录 + if (!basePath) { + basePath = dirPath + } + + if (options.maxDepth !== undefined && depth > options.maxDepth) { + return [] + } + + if (!fs.existsSync(dirPath)) { + loggerService.withContext('Utils:File').warn(`Dir not exist: ${dirPath}`) + return [] + } + + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }) + const result: NotesTreeNode[] = [] + + for (const entry of entries) { + if (options.ignoreHiddenFiles && entry.name.startsWith('.')) { + continue + } + + const entryPath = path.join(dirPath, entry.name) + + const relativePath = path.relative(basePath, entryPath) + const treePath = '/' + relativePath.replace(/\\/g, '/') + + if (entry.isDirectory() && options.includeDirectories) { + const stats = await fs.promises.stat(entryPath) + const dirTreeNode: NotesTreeNode = { + id: uuidv4(), + name: entry.name, + treePath: treePath, + externalPath: entryPath, + createdAt: stats.birthtime.toISOString(), + updatedAt: stats.mtime.toISOString(), + type: 'folder', + children: [] // 添加 children 属性 + } + + // 如果启用了递归扫描,则递归调用 scanDir + if (options.recursive) { + dirTreeNode.children = await scanDir(entryPath, depth + 1, basePath) + } + + result.push(dirTreeNode) + } else if (entry.isFile() && options.includeFiles) { + const ext = path.extname(entry.name).toLowerCase() + if (options.fileExtensions.length > 0 && !options.fileExtensions.includes(ext)) { + continue + } + + const stats = await fs.promises.stat(entryPath) + const name = entry.name.endsWith(options.fileExtensions[0]) + ? entry.name.slice(0, -options.fileExtensions[0].length) + : entry.name + + // 对于文件,treePath应该使用不带扩展名的路径 + const nameWithoutExt = path.basename(entryPath, path.extname(entryPath)) + const dirRelativePath = path.relative(basePath, path.dirname(entryPath)) + const fileTreePath = dirRelativePath + ? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}` + : `/${nameWithoutExt}` + + const fileTreeNode: NotesTreeNode = { + id: uuidv4(), + name: name, + treePath: fileTreePath, + externalPath: entryPath, + createdAt: stats.birthtime.toISOString(), + updatedAt: stats.mtime.toISOString(), + type: 'file' + } + result.push(fileTreeNode) + } + } + + return result +} + +/** + * 文件名唯一性约束 + * @param baseDir 基础目录 + * @param fileName 文件名 + * @param isFile 是否为文件 + * @returns 唯一的文件名 + */ +export function getName(baseDir: string, fileName: string, isFile: boolean): string { + // 首先清理文件名 + const baseName = sanitizeFilename(fileName) + let candidate = isFile ? baseName + '.md' : baseName + let counter = 1 + + while (fs.existsSync(path.join(baseDir, candidate))) { + candidate = isFile ? `${baseName}${counter}.md` : `${baseName}${counter}` + counter++ + } + + return isFile ? candidate.slice(0, -3) : candidate +} + +/** + * 文件名合法性校验 + * @param fileName 文件名 + * @param platform 平台,默认为当前运行平台 + * @returns 验证结果 + */ +export function validateFileName(fileName: string, platform = process.platform): { valid: boolean; error?: string } { + if (!fileName) { + return { valid: false, error: 'File name cannot be empty' } + } + + // 通用检查 + if (fileName.length === 0 || fileName.length > 255) { + return { valid: false, error: 'File name length must be between 1 and 255 characters' } + } + + // 检查 null 字符(所有系统都不允许) + if (fileName.includes('\0')) { + return { valid: false, error: 'File name cannot contain null characters.' } + } + + // Windows 特殊限制 + if (platform === 'win32') { + const winInvalidChars = /[<>:"/\\|?*]/ + if (winInvalidChars.test(fileName)) { + return { valid: false, error: 'File name contains characters not supported by Windows: < > : " / \\ | ? *' } + } + + const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i + if (reservedNames.test(fileName)) { + return { valid: false, error: 'File name is a Windows reserved name.' } + } + + if (fileName.endsWith('.') || fileName.endsWith(' ')) { + return { valid: false, error: 'File name cannot end with a dot or a space' } + } + } + + // Unix/Linux/macOS 限制 + if (platform !== 'win32') { + if (fileName.includes('/')) { + return { valid: false, error: 'File name cannot contain slashes /' } + } + } + + // macOS 额外限制 + if (platform === 'darwin') { + if (fileName.includes(':')) { + return { valid: false, error: 'macOS filenames cannot contain a colon :' } + } + } + + return { valid: true } +} + +/** + * 文件名合法性检查 + * @param fileName 文件名 + * @throws 如果文件名不合法则抛出异常 + * @returns 合法的文件名 + */ +export function checkName(fileName: string): string { + const validation = validateFileName(fileName) + if (!validation.valid) { + throw new Error(`Invalid file name: ${fileName}. ${validation.error}`) + } + return fileName +} + +/** + * 清理文件名,替换不合法字符 + * @param fileName 原始文件名 + * @param replacement 替换字符,默认为下划线 + * @returns 清理后的文件名 + */ +export function sanitizeFilename(fileName: string, replacement = '_'): string { + if (!fileName) return '' + + // 移除或替换非法字符 + let sanitized = fileName + // eslint-disable-next-line no-control-regex + .replace(/[<>:"/\\|?*\x00-\x1f]/g, replacement) // Windows 非法字符 + .replace(/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i, replacement + '$2') // Windows 保留名 + .replace(/[\s.]+$/, '') // 移除末尾的空格和点 + .substring(0, 255) // 限制长度 + + // 确保不为空 + if (!sanitized) { + sanitized = 'untitled' + } + + return sanitized +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 49c40d1166..2244264753 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,6 +4,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import { SpanContext } from '@opentelemetry/api' import { UpgradeChannel } from '@shared/config/constant' import type { LogLevel, LogSourceWithContext } from '@shared/config/logger' +import type { FileChangeEvent } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' import { AddMemoryOptions, @@ -80,6 +81,7 @@ const api = { logToMain: (source: LogSourceWithContext, level: LogLevel, message: string, data: any[]) => ipcRenderer.invoke(IpcChannel.App_LogToMain, source, level, message, data), setFullScreen: (value: boolean): Promise => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value), + isFullScreen: (): Promise => ipcRenderer.invoke(IpcChannel.App_IsFullScreen), mac: { isProcessTrusted: (): Promise => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted), requestProcessTrust: (): Promise => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust) @@ -141,33 +143,33 @@ const api = { upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file), delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId), deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath), + deleteExternalFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteExternalFile, filePath), + deleteExternalDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteExternalDir, dirPath), + move: (path: string, newPath: string) => ipcRenderer.invoke(IpcChannel.File_Move, path, newPath), + moveDir: (dirPath: string, newDirPath: string) => ipcRenderer.invoke(IpcChannel.File_MoveDir, dirPath, newDirPath), + rename: (path: string, newName: string) => ipcRenderer.invoke(IpcChannel.File_Rename, path, newName), + renameDir: (dirPath: string, newName: string) => ipcRenderer.invoke(IpcChannel.File_RenameDir, dirPath, newName), read: (fileId: string, detectEncoding?: boolean) => ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding), + readExternal: (filePath: string, detectEncoding?: boolean) => + ipcRenderer.invoke(IpcChannel.File_ReadExternal, filePath, detectEncoding), clear: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_Clear, spanContext), get: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_Get, filePath), - /** - * 创建一个空的临时文件 - * @param fileName 文件名 - * @returns 临时文件路径 - */ createTempFile: (fileName: string): Promise => ipcRenderer.invoke(IpcChannel.File_CreateTempFile, fileName), - /** - * 写入文件 - * @param filePath 文件路径 - * @param data 数据 - */ + mkdir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_Mkdir, dirPath), write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke(IpcChannel.File_Write, filePath, data), - writeWithId: (id: string, content: string) => ipcRenderer.invoke(IpcChannel.File_WriteWithId, id, content), open: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Open, options), openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path), save: (path: string, content: string | NodeJS.ArrayBufferView, options?: any) => ipcRenderer.invoke(IpcChannel.File_Save, path, content, options), - selectFolder: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_SelectFolder, spanContext), + selectFolder: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_SelectFolder, options), saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data), binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId), base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId), saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data), + savePastedImage: (imageData: Uint8Array, extension?: string) => + ipcRenderer.invoke(IpcChannel.File_SavePastedImage, imageData, extension), download: (url: string, isUseContentType?: boolean) => ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType), copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath), @@ -175,7 +177,23 @@ const api = { pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId), getPathForFile: (file: File) => webUtils.getPathForFile(file), openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file), - isTextFile: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath) + isTextFile: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath), + getDirectoryStructure: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_GetDirectoryStructure, dirPath), + checkFileName: (dirPath: string, fileName: string, isFile: boolean) => + ipcRenderer.invoke(IpcChannel.File_CheckFileName, dirPath, fileName, isFile), + validateNotesDirectory: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_ValidateNotesDirectory, dirPath), + startFileWatcher: (dirPath: string, config?: any) => + ipcRenderer.invoke(IpcChannel.File_StartWatcher, dirPath, config), + stopFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_StopWatcher), + onFileChange: (callback: (data: FileChangeEvent) => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: any) => { + if (data && typeof data === 'object') { + callback(data) + } + } + ipcRenderer.on('file-change', listener) + return () => ipcRenderer.off('file-change', listener) + } }, fs: { read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding), @@ -415,6 +433,10 @@ const api = { ocr: { ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise => ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider) + }, + cherryin: { + generateSignature: (params: { method: string; path: string; query: string; body: Record }) => + ipcRenderer.invoke(IpcChannel.Cherryin_GetSignature, params) } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ad18a9b193..703015e30e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2,6 +2,7 @@ import '@renderer/databases' import { loggerService } from '@logger' import store, { persistor } from '@renderer/store' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Provider } from 'react-redux' import { PersistGate } from 'redux-persist/integration/react' @@ -15,26 +16,38 @@ import Router from './Router' const logger = loggerService.withContext('App.tsx') +// 创建 React Query 客户端 +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5 minutes + refetchOnWindowFocus: false + } + } +}) + function App(): React.ReactElement { logger.info('App initialized') return ( - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + ) } diff --git a/src/renderer/src/Router.tsx b/src/renderer/src/Router.tsx index 36d045aae5..8985a1a41d 100644 --- a/src/renderer/src/Router.tsx +++ b/src/renderer/src/Router.tsx @@ -15,6 +15,7 @@ import HomePage from './pages/home/HomePage' import KnowledgePage from './pages/knowledge/KnowledgePage' import LaunchpadPage from './pages/launchpad/LaunchpadPage' import MinAppsPage from './pages/minapps/MinAppsPage' +import NotesPage from './pages/notes/NotesPage' import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage' import SettingsPage from './pages/settings/SettingsPage' import TranslatePage from './pages/translate/TranslatePage' @@ -31,6 +32,7 @@ const Router: FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index 0a498d58ae..84b00a1090 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -13,7 +13,7 @@ import { isNotSupportedImageSizeModel } from '@renderer/config/models' import { getEnableDeveloperMode } from '@renderer/hooks/useSettings' import { addSpan, endSpan } from '@renderer/services/SpanManagerService' import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity' -import type { Assistant, Model, Provider } from '@renderer/types' +import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types' import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes' import { ChunkType } from '@renderer/types/chunk' import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai' @@ -389,64 +389,63 @@ export default class ModernAiProvider { return this.legacyProvider.getEmbeddingDimensions(model) } - // public async generateImage(params: GenerateImageParams): Promise { - // // 如果支持新的 AI SDK,使用现代化实现 - // if (isModernSdkSupported(this.actualProvider)) { - // try { - // // 确保本地provider已创建 - // if (!this.localProvider) { - // await prepareSpecialProviderConfig(this.actualProvider, this.config) - // this.localProvider = await createProvider(this.config.providerId, this.config.options) - // logger.debug('Local provider created for standalone image generation', { - // providerId: this.config.providerId - // }) - // } + public async generateImage(params: GenerateImageParams): Promise { + // 如果支持新的 AI SDK,使用现代化实现 + if (isModernSdkSupported(this.actualProvider)) { + try { + // 确保本地provider已创建 + if (!this.localProvider) { + this.localProvider = await createAiSdkProvider(this.config) + if (!this.localProvider) { + throw new Error('Local provider not created') + } + } - // const result = await this.modernGenerateImage(params) - // return result - // } catch (error) { - // logger.warn('Modern AI SDK generateImage failed, falling back to legacy:', error as Error) - // // fallback 到传统实现 - // return this.legacyProvider.generateImage(params) - // } - // } + const result = await this.modernGenerateImage(params) + return result + } catch (error) { + logger.warn('Modern AI SDK generateImage failed, falling back to legacy:', error as Error) + // fallback 到传统实现 + return this.legacyProvider.generateImage(params) + } + } - // // 直接使用传统实现 - // return this.legacyProvider.generateImage(params) - // } + // 直接使用传统实现 + return this.legacyProvider.generateImage(params) + } - // /** - // * 使用现代化 AI SDK 的图像生成实现 - // */ - // private async modernGenerateImage(params: GenerateImageParams): Promise { - // const { model, prompt, imageSize, batchSize, signal } = params + /** + * 使用现代化 AI SDK 的图像生成实现 + */ + private async modernGenerateImage(params: GenerateImageParams): Promise { + const { model, prompt, imageSize, batchSize, signal } = params - // // 转换参数格式 - // const aiSdkParams = { - // prompt, - // size: (imageSize || '1024x1024') as `${number}x${number}`, - // n: batchSize || 1, - // ...(signal && { abortSignal: signal }) - // } + // 转换参数格式 + const aiSdkParams = { + prompt, + size: (imageSize || '1024x1024') as `${number}x${number}`, + n: batchSize || 1, + ...(signal && { abortSignal: signal }) + } - // const executor = createExecutor(this.config.providerId, this.config.options, []) - // const result = await executor.generateImage({ - // model: this.localProvider?.imageModel(model) as ImageModel, - // ...aiSdkParams - // }) + const executor = createExecutor(this.config.providerId, this.config.options, []) + const result = await executor.generateImage({ + model: this.localProvider?.imageModel(model) as ImageModel, + ...aiSdkParams + }) - // // 转换结果格式 - // const images: string[] = [] - // if (result.images) { - // for (const image of result.images) { - // if ('base64' in image && image.base64) { - // images.push(`data:image/png;base64,${image.base64}`) - // } - // } - // } + // 转换结果格式 + const images: string[] = [] + if (result.images) { + for (const image of result.images) { + if ('base64' in image && image.base64) { + images.push(`data:image/png;base64,${image.base64}`) + } + } + } - // return images - // } + return images + } public getBaseURL(): string { return this.legacyProvider.getBaseURL() diff --git a/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts b/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts index 168b749637..31a911533e 100644 --- a/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts +++ b/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts @@ -1,16 +1,18 @@ import { loggerService } from '@logger' import { Provider } from '@renderer/types' -import { AihubmixAPIClient } from './AihubmixAPIClient' +import { AihubmixAPIClient } from './aihubmix/AihubmixAPIClient' import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient' import { AwsBedrockAPIClient } from './aws/AwsBedrockAPIClient' import { BaseApiClient } from './BaseApiClient' +import { CherryinAPIClient } from './cherryin/CherryinAPIClient' import { GeminiAPIClient } from './gemini/GeminiAPIClient' import { VertexAPIClient } from './gemini/VertexAPIClient' -import { NewAPIClient } from './NewAPIClient' +import { NewAPIClient } from './newapi/NewAPIClient' import { OpenAIAPIClient } from './openai/OpenAIApiClient' import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient' import { PPIOAPIClient } from './ppio/PPIOAPIClient' +import { ZhipuAPIClient } from './zhipu/ZhipuAPIClient' const logger = loggerService.withContext('ApiClientFactory') @@ -31,24 +33,36 @@ export class ApiClientFactory { let instance: BaseApiClient - // 首先检查特殊的provider id + // 首先检查特殊的 Provider ID + if (provider.id === 'cherryin') { + instance = new CherryinAPIClient(provider) as BaseApiClient + return instance + } + if (provider.id === 'aihubmix') { logger.debug(`Creating AihubmixAPIClient for provider: ${provider.id}`) instance = new AihubmixAPIClient(provider) as BaseApiClient return instance } + if (provider.id === 'new-api') { logger.debug(`Creating NewAPIClient for provider: ${provider.id}`) instance = new NewAPIClient(provider) as BaseApiClient return instance } + if (provider.id === 'ppio') { logger.debug(`Creating PPIOAPIClient for provider: ${provider.id}`) instance = new PPIOAPIClient(provider) as BaseApiClient return instance } - // 然后检查标准的provider type + if (provider.id === 'zhipu') { + instance = new ZhipuAPIClient(provider) as BaseApiClient + return instance + } + + // 然后检查标准的 Provider Type switch (provider.type) { case 'openai': instance = new OpenAIAPIClient(provider) as BaseApiClient @@ -79,8 +93,3 @@ export class ApiClientFactory { return instance } } - -// 移除这个函数,它已经移动到 utils/index.ts -// export function isOpenAIProvider(provider: Provider) { -// return !['anthropic', 'gemini'].includes(provider.type) -// } diff --git a/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts b/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts index b1618038f0..081469516b 100644 --- a/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts +++ b/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts @@ -2,13 +2,13 @@ import { Provider } from '@renderer/types' import { isOpenAIProvider } from '@renderer/utils' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AihubmixAPIClient } from '../AihubmixAPIClient' +import { AihubmixAPIClient } from '../aihubmix/AihubmixAPIClient' import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient' import { ApiClientFactory } from '../ApiClientFactory' import { AwsBedrockAPIClient } from '../aws/AwsBedrockAPIClient' import { GeminiAPIClient } from '../gemini/GeminiAPIClient' import { VertexAPIClient } from '../gemini/VertexAPIClient' -import { NewAPIClient } from '../NewAPIClient' +import { NewAPIClient } from '../newapi/NewAPIClient' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' import { OpenAIResponseAPIClient } from '../openai/OpenAIResponseAPIClient' import { PPIOAPIClient } from '../ppio/PPIOAPIClient' @@ -26,7 +26,7 @@ const createTestProvider = (id: string, type: string): Provider => ({ }) // Mock 所有客户端模块 -vi.mock('../AihubmixAPIClient', () => ({ +vi.mock('../aihubmix/AihubmixAPIClient', () => ({ AihubmixAPIClient: vi.fn().mockImplementation(() => ({})) })) vi.mock('../anthropic/AnthropicAPIClient', () => ({ @@ -41,7 +41,7 @@ vi.mock('../gemini/GeminiAPIClient', () => ({ vi.mock('../gemini/VertexAPIClient', () => ({ VertexAPIClient: vi.fn().mockImplementation(() => ({})) })) -vi.mock('../NewAPIClient', () => ({ +vi.mock('../newapi/NewAPIClient', () => ({ NewAPIClient: vi.fn().mockImplementation(() => ({})) })) vi.mock('../openai/OpenAIApiClient', () => ({ diff --git a/src/renderer/src/aiCore/legacy/clients/__tests__/index.clientCompatibilityTypes.test.ts b/src/renderer/src/aiCore/legacy/clients/__tests__/index.clientCompatibilityTypes.test.ts index 48bd230d81..343bc4d544 100644 --- a/src/renderer/src/aiCore/legacy/clients/__tests__/index.clientCompatibilityTypes.test.ts +++ b/src/renderer/src/aiCore/legacy/clients/__tests__/index.clientCompatibilityTypes.test.ts @@ -1,9 +1,9 @@ -import { AihubmixAPIClient } from '@renderer/aiCore/legacy/clients/AihubmixAPIClient' +import { AihubmixAPIClient } from '@renderer/aiCore/legacy/clients/aihubmix/AihubmixAPIClient' import { AnthropicAPIClient } from '@renderer/aiCore/legacy/clients/anthropic/AnthropicAPIClient' import { ApiClientFactory } from '@renderer/aiCore/legacy/clients/ApiClientFactory' import { GeminiAPIClient } from '@renderer/aiCore/legacy/clients/gemini/GeminiAPIClient' import { VertexAPIClient } from '@renderer/aiCore/legacy/clients/gemini/VertexAPIClient' -import { NewAPIClient } from '@renderer/aiCore/legacy/clients/NewAPIClient' +import { NewAPIClient } from '@renderer/aiCore/legacy/clients/newapi/NewAPIClient' import { OpenAIAPIClient } from '@renderer/aiCore/legacy/clients/openai/OpenAIApiClient' import { OpenAIResponseAPIClient } from '@renderer/aiCore/legacy/clients/openai/OpenAIResponseAPIClient' import { EndpointType, Model, Provider } from '@renderer/types' @@ -16,6 +16,7 @@ vi.mock('@renderer/config/models', () => ({ { id: 'gpt-4', name: 'GPT-4' }, { id: 'gpt-4', name: 'GPT-4' } ], + zhipu: [], silicon: [], openai: [], anthropic: [], @@ -33,7 +34,13 @@ vi.mock('@renderer/config/models', () => ({ isWebSearchModel: vi.fn().mockReturnValue(false), findTokenLimit: vi.fn().mockReturnValue(4096), isFunctionCallingModel: vi.fn().mockReturnValue(false), - DEFAULT_MAX_TOKENS: 4096 + DEFAULT_MAX_TOKENS: 4096, + glm45FlashModel: { + id: 'glm-4.5-flash', + name: 'GLM-4.5-Flash', + provider: 'cherryin', + group: 'GLM-4.5' + } })) vi.mock('@renderer/services/AssistantService', () => ({ diff --git a/src/renderer/src/aiCore/legacy/clients/AihubmixAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/aihubmix/AihubmixAPIClient.ts similarity index 88% rename from src/renderer/src/aiCore/legacy/clients/AihubmixAPIClient.ts rename to src/renderer/src/aiCore/legacy/clients/aihubmix/AihubmixAPIClient.ts index f27674174d..1149c04b35 100644 --- a/src/renderer/src/aiCore/legacy/clients/AihubmixAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/aihubmix/AihubmixAPIClient.ts @@ -1,12 +1,12 @@ import { isOpenAILLMModel } from '@renderer/config/models' import { Model, Provider } from '@renderer/types' -import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient' -import { BaseApiClient } from './BaseApiClient' -import { GeminiAPIClient } from './gemini/GeminiAPIClient' -import { MixedBaseAPIClient } from './MixedBaseApiClient' -import { OpenAIAPIClient } from './openai/OpenAIApiClient' -import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient' +import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient' +import { BaseApiClient } from '../BaseApiClient' +import { GeminiAPIClient } from '../gemini/GeminiAPIClient' +import { MixedBaseAPIClient } from '../MixedBaseApiClient' +import { OpenAIAPIClient } from '../openai/OpenAIApiClient' +import { OpenAIResponseAPIClient } from '../openai/OpenAIResponseAPIClient' /** * AihubmixAPIClient - 根据模型类型自动选择合适的ApiClient diff --git a/src/renderer/src/aiCore/legacy/clients/cherryin/CherryinAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/cherryin/CherryinAPIClient.ts new file mode 100644 index 0000000000..bf3ed7d718 --- /dev/null +++ b/src/renderer/src/aiCore/legacy/clients/cherryin/CherryinAPIClient.ts @@ -0,0 +1,51 @@ +import { Provider } from '@renderer/types' +import { OpenAISdkParams, OpenAISdkRawOutput } from '@renderer/types/sdk' +import OpenAI from 'openai' + +import { OpenAIAPIClient } from '../openai/OpenAIApiClient' + +export class CherryinAPIClient extends OpenAIAPIClient { + constructor(provider: Provider) { + super(provider) + } + + override async createCompletions( + payload: OpenAISdkParams, + options?: OpenAI.RequestOptions + ): Promise { + const sdk = await this.getSdkInstance() + options = options || {} + options.headers = options.headers || {} + + const signature = await window.api.cherryin.generateSignature({ + method: 'POST', + path: '/chat/completions', + query: '', + body: payload + }) + + options.headers = { + ...options.headers, + ...signature + } + + // @ts-ignore - SDK参数可能有额外的字段 + return await sdk.chat.completions.create(payload, options) + } + + override getClientCompatibilityType(): string[] { + return ['CherryinAPIClient'] + } + + public async listModels(): Promise { + const models = ['glm-4.5-flash', 'Qwen/Qwen3-8B'] + + const created = Date.now() + return models.map((id) => ({ + id, + owned_by: 'cherryin', + object: 'model' as const, + created + })) + } +} diff --git a/src/renderer/src/aiCore/legacy/clients/index.ts b/src/renderer/src/aiCore/legacy/clients/index.ts index ec7f9d9d7e..f364dbcee6 100644 --- a/src/renderer/src/aiCore/legacy/clients/index.ts +++ b/src/renderer/src/aiCore/legacy/clients/index.ts @@ -3,4 +3,6 @@ export * from './BaseApiClient' export * from './types' // Export specific clients from subdirectories +export * from './anthropic/AnthropicAPIClient' export * from './openai/OpenAIApiClient' +export * from './openai/OpenAIResponseAPIClient' diff --git a/src/renderer/src/aiCore/legacy/clients/NewAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/newapi/NewAPIClient.ts similarity index 89% rename from src/renderer/src/aiCore/legacy/clients/NewAPIClient.ts rename to src/renderer/src/aiCore/legacy/clients/newapi/NewAPIClient.ts index e87d54ae3e..58b349a2be 100644 --- a/src/renderer/src/aiCore/legacy/clients/NewAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/newapi/NewAPIClient.ts @@ -3,12 +3,12 @@ import { isSupportedModel } from '@renderer/config/models' import { Model, Provider } from '@renderer/types' import { NewApiModel } from '@renderer/types/sdk' -import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient' -import { BaseApiClient } from './BaseApiClient' -import { GeminiAPIClient } from './gemini/GeminiAPIClient' -import { MixedBaseAPIClient } from './MixedBaseApiClient' -import { OpenAIAPIClient } from './openai/OpenAIApiClient' -import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient' +import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient' +import { BaseApiClient } from '../BaseApiClient' +import { GeminiAPIClient } from '../gemini/GeminiAPIClient' +import { MixedBaseAPIClient } from '../MixedBaseApiClient' +import { OpenAIAPIClient } from '../openai/OpenAIApiClient' +import { OpenAIResponseAPIClient } from '../openai/OpenAIResponseAPIClient' const logger = loggerService.withContext('NewAPIClient') diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts index 5649b86984..92de478313 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts @@ -60,6 +60,8 @@ import { import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk' import { Message } from '@renderer/types/newMessage' import { + OpenAIExtraBody, + OpenAIModality, OpenAISdkMessageParam, OpenAISdkParams, OpenAISdkRawChunk, @@ -122,13 +124,11 @@ export class OpenAIAPIClient extends OpenAIBaseClient< if (!isReasoningModel(model)) { return {} } + const reasoningEffort = assistant?.settings?.reasoning_effort if (isSupportedThinkingTokenZhipuModel(model)) { - if (!reasoningEffort) { - return { thinking: { type: 'disabled' } } - } - return { thinking: { type: 'enabled' } } + return { thinking: { type: reasoningEffort ? 'enabled' : 'disabled' } } } if (!reasoningEffort) { @@ -139,6 +139,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< // } // openrouter: use reasoning + // openrouter 如果关闭思考,会隐藏思考内容,所以对于总是思考的模型需要特别处理 if (model.provider === SystemProviderIds.openrouter) { // Don't disable reasoning for Gemini models that support thinking tokens if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) { @@ -148,6 +149,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient< if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model)) { return {} } + if (isReasoningModel(model) && !isSupportedThinkingTokenModel(model)) { + return {} + } return { reasoning: { enabled: false, exclude: true } } } @@ -205,10 +209,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient< enable_thinking: true, incremental_output: true } - case SystemProviderIds.silicon: - return { - enable_thinking: true - } case SystemProviderIds.doubao: return { thinking: { @@ -227,10 +227,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient< thinking: true } } + case SystemProviderIds.silicon: + case SystemProviderIds.ppio: + return { + enable_thinking: true + } default: logger.warn( - `Skipping thinking options for provider ${this.provider.name} as DeepSeek v3.1 thinking control method is unknown` + `Use enable_thinking option as fallback for provider ${this.provider.name} since DeepSeek v3.1 thinking control method is unknown` ) + return { + enable_thinking: true + } } } } @@ -558,7 +566,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< messages: OpenAISdkMessageParam[] metadata: Record }> => { - const { messages, mcpTools, maxTokens, enableWebSearch } = coreRequest + const { messages, mcpTools, maxTokens, enableWebSearch, enableGenerateImage } = coreRequest let { streamOutput } = coreRequest // Qwen3商业版(思考模式)、Qwen3开源版、QwQ、QVQ只支持流式输出。 @@ -566,18 +574,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient< streamOutput = true } - const extra_body: Record = {} + const extra_body: OpenAIExtraBody = {} if (isQwenMTModel(model)) { if (isTranslateAssistant(assistant)) { - const targetLanguage = assistant.targetLanguage + const targetLanguage = mapLanguageToQwenMTModel(assistant.targetLanguage) + if (!targetLanguage) { + throw new Error(t('translate.error.not_supported', { language: assistant.targetLanguage.value })) + } const translationOptions = { source_lang: 'auto', - target_lang: mapLanguageToQwenMTModel(targetLanguage) + target_lang: targetLanguage } as const - if (!translationOptions.target_lang) { - throw new Error(t('translate.error.not_supported', { language: targetLanguage.value })) - } extra_body.translation_options = translationOptions } else { throw new Error(t('translate.error.chat_qwen_mt')) @@ -633,12 +641,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient< } if (this.provider.id === SystemProviderIds.poe) { // 如果以后 poe 支持 reasoning_effort 参数了,可以删掉这部分 + let suffix = '' if (isGPT5SeriesModel(model) && reasoningEffort.reasoning_effort) { - lastUserMsg.content += ` --reasoning_effort ${reasoningEffort.reasoning_effort}` + suffix = ` --reasoning_effort ${reasoningEffort.reasoning_effort}` } else if (isClaudeReasoningModel(model) && reasoningEffort.thinking?.budget_tokens) { - lastUserMsg.content += ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}` + suffix = ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}` } else if (isGeminiReasoningModel(model) && reasoningEffort.extra_body?.google?.thinking_config) { - lastUserMsg.content += ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}` + suffix = ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}` + } + // FIXME: poe 不支持多个text part,上传文本文件的时候用的不是file part而是text part,因此会出问题 + // 临时解决方案是强制poe用string content,但是其实poe部分支持array + if (typeof lastUserMsg.content === 'string') { + lastUserMsg.content += suffix } } } @@ -672,6 +686,15 @@ export class OpenAIAPIClient extends OpenAIBaseClient< reasoningEffort.reasoning_effort = 'low' } + const modalities: { + modalities?: OpenAIModality[] + } = {} + // for openrouter generate image + // https://openrouter.ai/docs/features/multimodal/image-generation + if (enableGenerateImage && this.provider.id === SystemProviderIds.openrouter) { + modalities.modalities = ['image', 'text'] + } + const commonParams: OpenAISdkParams = { model: model.id, messages: @@ -684,6 +707,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< tools: tools.length > 0 ? tools : undefined, stream: streamOutput, ...(shouldIncludeStreamOptions ? { stream_options: { include_usage: true } } : {}), + ...modalities, // groq 有不同的 service tier 配置,不符合 openai 接口类型 service_tier: this.getServiceTier(model) as OpenAIServiceTier, ...this.getProviderSpecificParameters(assistant, model), @@ -691,7 +715,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< ...getOpenAIWebSearchParams(model, enableWebSearch), // OpenRouter usage tracking ...(this.provider.id === 'openrouter' ? { usage: { include: true } } : {}), - ...(isQwenMTModel(model) ? extra_body : {}), + ...extra_body, // 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑 // 注意:用户自定义参数总是应该覆盖其他参数 ...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {}) diff --git a/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts new file mode 100644 index 0000000000..c1d7b8f562 --- /dev/null +++ b/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts @@ -0,0 +1,100 @@ +import { loggerService } from '@logger' +import { Provider } from '@renderer/types' +import { GenerateImageParams } from '@renderer/types' +import OpenAI from 'openai' + +import { OpenAIAPIClient } from '../openai/OpenAIApiClient' + +const logger = loggerService.withContext('ZhipuAPIClient') + +export class ZhipuAPIClient extends OpenAIAPIClient { + constructor(provider: Provider) { + super(provider) + } + + override getClientCompatibilityType(): string[] { + return ['ZhipuAPIClient'] + } + + override async generateImage({ + model, + prompt, + negativePrompt, + imageSize, + batchSize, + signal, + quality + }: GenerateImageParams): Promise { + const sdk = await this.getSdkInstance() + + // 智谱AI使用不同的参数格式 + const body: any = { + model, + prompt + } + + // 智谱AI特有的参数格式 + body.size = imageSize + body.n = batchSize + if (negativePrompt) { + body.negative_prompt = negativePrompt + } + + // 只有cogview-4-250304模型支持quality和style参数 + if (model === 'cogview-4-250304') { + if (quality) { + body.quality = quality + } + body.style = 'vivid' + } + + try { + logger.debug('Calling Zhipu image generation API with params:', body) + + const response = await sdk.images.generate(body, { signal }) + + if (response.data && response.data.length > 0) { + return response.data.map((image: any) => image.url).filter(Boolean) + } + + return [] + } catch (error) { + logger.error('Zhipu image generation failed:', error as Error) + throw error + } + } + + public async listModels(): Promise { + const models = [ + 'glm-4.5', + 'glm-4.5-x', + 'glm-4.5-air', + 'glm-4.5-airx', + 'glm-4.5-flash', + 'glm-4.5v', + 'glm-z1-air', + 'glm-z1-airx', + 'cogview-3-flash', + 'cogview-4-250304', + 'glm-4-long', + 'glm-4-plus', + 'glm-4-air-250414', + 'glm-4-airx', + 'glm-4-flashx', + 'glm-4v', + 'glm-4v-flash', + 'glm-4v-plus-0111', + 'glm-4.1v-thinking-flash', + 'glm-4-alltools', + 'embedding-3' + ] + + const created = Date.now() + return models.map((id) => ({ + id, + owned_by: 'zhipu', + object: 'model' as const, + created + })) + } +} diff --git a/src/renderer/src/aiCore/legacy/index.ts b/src/renderer/src/aiCore/legacy/index.ts index 7b5f75f9c6..adc81f03ad 100644 --- a/src/renderer/src/aiCore/legacy/index.ts +++ b/src/renderer/src/aiCore/legacy/index.ts @@ -9,9 +9,9 @@ import type { GenerateImageParams, Model, Provider } from '@renderer/types' import type { RequestOptions, SdkModel } from '@renderer/types/sdk' import { isSupportedToolUse } from '@renderer/utils/mcp-tools' -import { AihubmixAPIClient } from './clients/AihubmixAPIClient' +import { AihubmixAPIClient } from './clients/aihubmix/AihubmixAPIClient' import { VertexAPIClient } from './clients/gemini/VertexAPIClient' -import { NewAPIClient } from './clients/NewAPIClient' +import { NewAPIClient } from './clients/newapi/NewAPIClient' import { OpenAIResponseAPIClient } from './clients/openai/OpenAIResponseAPIClient' import { CompletionsMiddlewareBuilder } from './middleware/builder' import { MIDDLEWARE_NAME as AbortHandlerMiddlewareName } from './middleware/common/AbortHandlerMiddleware' diff --git a/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts index 26d9342ebc..d80c9d2f83 100644 --- a/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts @@ -1,7 +1,9 @@ import { loggerService } from '@logger' +import { isZhipuModel } from '@renderer/config/models' +import store from '@renderer/store' import { Chunk } from '@renderer/types/chunk' -import { CompletionsResult } from '../schemas' +import { CompletionsParams, CompletionsResult } from '../schemas' import { CompletionsContext } from '../types' import { createErrorChunk } from '../utils' @@ -28,17 +30,22 @@ export const ErrorHandlerMiddleware = // 尝试执行下一个中间件 return await next(ctx, params) } catch (error: any) { - logger.error('ErrorHandlerMiddleware_error', error) + logger.error(error) + + let processedError = error + processedError = handleError(error, params) + // 1. 使用通用的工具函数将错误解析为标准格式 - const errorChunk = createErrorChunk(error) + const errorChunk = createErrorChunk(processedError) + // 2. 调用从外部传入的 onError 回调 if (params.onError) { - params.onError(error) + params.onError(processedError) } // 3. 根据配置决定是重新抛出错误,还是将其作为流的一部分向下传递 if (shouldThrow) { - throw error + throw processedError } // 如果不抛出,则创建一个只包含该错误块的流并向下传递 @@ -57,3 +64,70 @@ export const ErrorHandlerMiddleware = } } } + +function handleError(error: any, params: CompletionsParams): any { + if (isZhipuModel(params.assistant.model) && error.status && !params.enableGenerateImage) { + return handleZhipuError(error) + } + + if (error.status === 401 || error.message.includes('401')) { + return { + ...error, + i18nKey: 'chat.no_api_key', + providerId: params.assistant?.model?.provider + } + } + + return error +} + +/** + * 处理智谱特定错误 + * 1. 只有对话功能(enableGenerateImage为false)才使用自定义错误处理 + * 2. 绘画功能(enableGenerateImage为true)使用通用错误处理 + */ +function handleZhipuError(error: any): any { + const provider = store.getState().llm.providers.find((p) => p.id === 'zhipu') + const logger = loggerService.withContext('handleZhipuError') + + // 定义错误模式映射 + const errorPatterns = [ + { + condition: () => error.status === 401 || /令牌已过期|AuthenticationError|Unauthorized/i.test(error.message), + i18nKey: 'chat.no_api_key', + providerId: provider?.id + }, + { + condition: () => error.error?.code === '1304' || /限额|免费配额|free quota|rate limit/i.test(error.message), + i18nKey: 'chat.quota_exceeded', + providerId: provider?.id + }, + { + condition: () => + (error.status === 429 && error.error?.code === '1113') || /余额不足|insufficient balance/i.test(error.message), + i18nKey: 'chat.insufficient_balance', + providerId: provider?.id + }, + { + condition: () => !provider?.apiKey?.trim(), + i18nKey: 'chat.no_api_key', + providerId: provider?.id + } + ] + + // 遍历错误模式,返回第一个匹配的错误 + for (const pattern of errorPatterns) { + if (pattern.condition()) { + return { + ...error, + providerId: pattern.providerId, + i18nKey: pattern.i18nKey + } + } + } + + // 如果不是智谱特定错误,返回原始错误 + logger.debug('🔧 不是智谱特定错误,返回原始错误') + + return error +} diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index 46355d30b3..b54cda5fa5 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -8,7 +8,6 @@ import { isGenerateImageModel, isOpenRouterBuiltInWebSearchModel, isReasoningModel, - isSupportedDisableGenerationModel, isSupportedReasoningEffortModel, isSupportedThinkingTokenModel, isWebSearchModel @@ -74,9 +73,7 @@ export async function buildStreamTextParams( const enableUrlContext = assistant.enableUrlContext || false - const enableGenerateImage = - isGenerateImageModel(model) && - (isSupportedDisableGenerationModel(model) ? assistant.enableGenerateImage || false : true) + const enableGenerateImage = !!(isGenerateImageModel(model) && assistant.enableGenerateImage) const tools = setupToolsConfig(mcpTools) diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts index 687c9c7a0c..a244619264 100644 --- a/src/renderer/src/aiCore/utils/options.ts +++ b/src/renderer/src/aiCore/utils/options.ts @@ -102,7 +102,6 @@ export function buildProviderOptions( break case 'deepseek': case 'openai-compatible': - case 'openai-responses': // 对于其他 provider,使用通用的构建逻辑 providerSpecificOptions = { ...buildGenericProviderOptions(assistant, model, capabilities), diff --git a/src/renderer/src/assets/images/apps/longcat.svg b/src/renderer/src/assets/images/apps/longcat.svg new file mode 100644 index 0000000000..7e556fb53b --- /dev/null +++ b/src/renderer/src/assets/images/apps/longcat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/src/assets/images/banner.png b/src/renderer/src/assets/images/banner.png new file mode 100644 index 0000000000..e29198cf82 Binary files /dev/null and b/src/renderer/src/assets/images/banner.png differ diff --git a/src/renderer/src/assets/images/models/chatglm.png b/src/renderer/src/assets/images/models/chatglm.png index 6ef7f44519..f078fbfe15 100644 Binary files a/src/renderer/src/assets/images/models/chatglm.png and b/src/renderer/src/assets/images/models/chatglm.png differ diff --git a/src/renderer/src/assets/images/models/zhipu.png b/src/renderer/src/assets/images/models/zhipu.png index aedb3811c7..f078fbfe15 100644 Binary files a/src/renderer/src/assets/images/models/zhipu.png and b/src/renderer/src/assets/images/models/zhipu.png differ diff --git a/src/renderer/src/assets/images/models/zhipu_dark.png b/src/renderer/src/assets/images/models/zhipu_dark.png index 4f578081ef..f078fbfe15 100644 Binary files a/src/renderer/src/assets/images/models/zhipu_dark.png and b/src/renderer/src/assets/images/models/zhipu_dark.png differ diff --git a/src/renderer/src/assets/images/providers/cherryin.png b/src/renderer/src/assets/images/providers/cherryin.png new file mode 100644 index 0000000000..1a75ff570a Binary files /dev/null and b/src/renderer/src/assets/images/providers/cherryin.png differ diff --git a/src/renderer/src/assets/images/providers/zhipu.png b/src/renderer/src/assets/images/providers/zhipu.png index aedb3811c7..f078fbfe15 100644 Binary files a/src/renderer/src/assets/images/providers/zhipu.png and b/src/renderer/src/assets/images/providers/zhipu.png differ diff --git a/src/renderer/src/assets/images/search/zhipu.png b/src/renderer/src/assets/images/search/zhipu.png new file mode 100644 index 0000000000..f078fbfe15 Binary files /dev/null and b/src/renderer/src/assets/images/search/zhipu.png differ diff --git a/src/renderer/src/assets/styles/CommandListPopover.scss b/src/renderer/src/assets/styles/CommandListPopover.scss new file mode 100644 index 0000000000..e2521c57b3 --- /dev/null +++ b/src/renderer/src/assets/styles/CommandListPopover.scss @@ -0,0 +1,59 @@ +.command-list-popover { + // Base styles are handled inline for theme support + + // Arrow styles based on placement + &[data-placement^='bottom'] { + transform-origin: top center; + animation: slideDownAndFadeIn 0.15s ease-out; + } + + &[data-placement^='top'] { + transform-origin: bottom center; + animation: slideUpAndFadeIn 0.15s ease-out; + } + + &[data-placement*='start'] { + transform-origin: left center; + } + + &[data-placement*='end'] { + transform-origin: right center; + } +} + +@keyframes slideDownAndFadeIn { + 0% { + opacity: 0; + transform: translateY(-8px) scale(0.95); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes slideUpAndFadeIn { + 0% { + opacity: 0; + transform: translateY(8px) scale(0.95); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +// Ensure smooth scrolling in virtual list +.command-list-popover .dynamic-virtual-list { + scroll-behavior: smooth; +} + +// Better focus indicators +.command-list-popover [data-index] { + position: relative; + + &:focus-visible { + outline: 2px solid var(--color-primary, #1677ff); + outline-offset: -2px; + } +} diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.scss index 1cb7d030b0..517160e76c 100644 --- a/src/renderer/src/assets/styles/color.scss +++ b/src/renderer/src/assets/styles/color.scss @@ -35,6 +35,8 @@ --color-error: #ff4d50; --color-link: #338cff; --color-code-background: #323232; + --color-inline-code-background: #323232; + --color-inline-code-text: rgb(218, 97, 92); --color-hover: rgba(40, 40, 40, 1); --color-active: rgba(55, 55, 55, 1); --color-frame-border: #333; @@ -115,6 +117,8 @@ --color-error: #ff4d50; --color-link: #1677ff; --color-code-background: #e3e3e3; + --color-inline-code-background: rgba(0, 0, 0, 0.06); + --color-inline-code-text: rgba(235, 87, 87); --color-hover: var(--color-white-mute); --color-active: var(--color-white-soft); --color-frame-border: #ddd; diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 0a6696bd9b..974457dc4d 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -5,6 +5,7 @@ @use './scrollbar.scss'; @use './container.scss'; @use './animation.scss'; +@use './richtext.scss'; @import '../fonts/icon-fonts/iconfont.css'; @import '../fonts/ubuntu/ubuntu.css'; @import '../fonts/country-flag-fonts/flag.css'; diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index caf41aca5e..7d4dbf0ce2 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -202,7 +202,7 @@ img { max-width: 100%; height: auto; - margin: 10px 0; + margin: 1em 0; } a, @@ -321,6 +321,10 @@ emoji-picker { --border-size: 0; } +.block-wrapper + .block-wrapper { + margin-top: 1em; +} + .katex, mjx-container { display: inline-block; @@ -367,4 +371,9 @@ mjx-container { white-space: pre-wrap; } } + + .cm-announced { + position: absolute; + display: none; + } } diff --git a/src/renderer/src/assets/styles/richtext.scss b/src/renderer/src/assets/styles/richtext.scss new file mode 100644 index 0000000000..7b4e4bdad5 --- /dev/null +++ b/src/renderer/src/assets/styles/richtext.scss @@ -0,0 +1,508 @@ +.tiptap { + // 预留5px给scrollbar + padding: 12px 55px 12px 60px; + outline: none; + min-height: 120px; + overflow-wrap: break-word; + word-break: break-word; + + &:focus { + outline: none; + } + + :first-child { + margin-top: 0; + } + + h1:first-child, + h2:first-child, + h3:first-child, + h4:first-child, + h5:first-child, + h6:first-child { + margin-top: 0; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 1.5rem 0 1rem 0; + line-height: 1.1; + text-wrap: pretty; + font-weight: 600; + + code { + font-size: inherit; + font-weight: inherit; + } + } + + h1 { + margin-top: 0; + font-size: 2rem; + } + + h2 { + font-size: 1.5rem; + } + + h3 { + font-size: 1.2rem; + } + + h4, + h5, + h6 { + font-size: 1rem; + } + + p { + margin: 1.1rem 0 0.5rem 0; + white-space: normal; + overflow-wrap: break-word; + word-break: break-word; + width: 100%; + line-height: 1.6; + hyphens: auto; + + &:has(+ ul) { + margin-bottom: 0; + } + } + + a { + color: var(--color-link); + text-decoration: none; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + blockquote { + border-left: 4px solid var(--color-primary); + margin: 1.5rem 0; + padding-left: 1rem; + } + + code { + background-color: var(--color-inline-code-background); + border-radius: 0.4rem; + color: var(--color-inline-code-text); + font-size: 0.85rem; + padding: 0.25em 0.3em; + font-family: var(--code-font-family); + } + + pre { + background: var(--color-code-background); + border-radius: 0.5rem; + color: var(--color-text); + font-family: var(--code-font-family); + margin: 1.5rem 0; + padding: 0.75rem 1rem; + border: 1px solid var(--color-border-soft); + + code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; + border: none; + } + } + + hr { + border: none; + border-top: 1px solid var(--color-gray-2); + margin: 2rem 0; + } + + em { + font-style: italic; + } + + u { + text-decoration: underline; + } + + strong, + strong * { + font-weight: 600; + } + + .placeholder { + position: relative; + + &:before { + content: attr(data-placeholder); + position: absolute; + color: var(--color-text-secondary); + opacity: 0.6; + pointer-events: none; + font-style: italic; + left: 0; + right: 0; + } + + /* Ensure drag handles and plus buttons remain interactive */ + .drag-handle, + .plus-button { + pointer-events: auto; + } + } + + /* Show placeholder only when focused or when it's the only empty node */ + .placeholder.has-focus:before { + opacity: 0.8; + } + + img { + max-width: 800px; + width: 100%; + height: auto; + } + + table { + border-collapse: collapse; + margin: 0; + /* Allow action endpoints (rendered as decorations) to slightly overflow table edges */ + overflow: visible; + table-layout: fixed; + width: 100%; + + td, + th { + border: 1px solid var(--color-border-soft); + box-sizing: border-box; + display: table-cell; + min-width: 120px; + padding: 6px 8px; + position: relative; + vertical-align: top; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + > * { + margin-bottom: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + th, + th * { + background-color: var(--color-gray-3); + font-weight: bold; + text-align: left; + } + + .selectedCell { + position: relative; // 确保伪元素定位 + } + .selectedCell::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + border: 0 solid var(--color-primary); + border-radius: 0; + } + .selectedCell.selection-top::after { + border-top-width: 2px; + } + .selectedCell.selection-bottom::after { + border-bottom-width: 2px; + } + .selectedCell.selection-left::after { + border-left-width: 2px; + } + .selectedCell.selection-right::after { + border-right-width: 2px; + } + + .column-resize-handle { + background-color: var(--color-primary); + bottom: -2px; + pointer-events: none; + position: absolute; + right: -2px; + top: 0; + width: 4px; + } + + &:has(.selectedCell) { + caret-color: transparent !important; + user-select: none !important; + + *::selection { + background: transparent !important; + } + + .column-resize-handle { + display: none; + } + } + + // Position row action buttons relative to first column cells + tbody tr td:first-child, + tbody tr th:first-child { + position: relative; + } + + // Position column action buttons relative to first row cells + tbody tr:first-child td, + tbody tr:first-child th { + position: relative; + } + } + + .tableWrapper { + position: relative; + margin: 1rem 0; + display: grid; + grid-template-columns: 1fr 25px; + grid-template-rows: 1fr 25px; + grid-template-areas: + 'table column-btn' + 'row-btn corner'; + gap: 5px; + + .table-container { + grid-area: table; + overflow-x: auto; + overflow-y: visible; + + &::-webkit-scrollbar { + cursor: default; + } + + &::-webkit-scrollbar:horizontal { + cursor: default; + } + + &::-webkit-scrollbar-thumb { + cursor: default; + } + + &::-webkit-scrollbar-track { + cursor: default; + } + + table { + width: max-content; + min-width: 100%; + } + } + + .add-row-button, + .add-column-button { + border: 1px solid var(--color-border); + background: var(--color-bg-base); + border-radius: 4px; + font-size: 12px; + line-height: 1; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + color: var(--color-text); + z-index: 20; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + pointer-events: auto; + + &:hover { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); + } + + &:active { + transform: scale(0.98); + } + + &::before { + content: '+'; + font-weight: bold; + } + } + + .add-row-button { + grid-area: row-btn; + } + + .add-column-button { + grid-area: column-btn; + } + + &:hover, + &:has(.add-row-button:hover), + &:has(.add-column-button:hover) { + .add-row-button, + .add-column-button { + display: flex; + } + } + + /* Do not show in readonly even on hover */ + &.is-readonly, + &.is-readonly:hover { + .add-row-button, + .add-column-button { + display: none !important; + } + } + + .add-row-button:hover, + .add-column-button:hover { + display: flex !important; + } + + /* Row/Column action triggers (visible on cell selection) */ + .row-action-trigger, + .column-action-trigger { + position: absolute; + height: 20px; + border-radius: 8px; + background: var(--color-primary); + color: #fff; + border: 1px solid var(--color-primary); + display: none; + align-items: center; + justify-content: center; + font-size: 12px; + line-height: 1; + z-index: 30; + pointer-events: auto; + } + + .row-action-trigger::before, + .column-action-trigger::before { + content: '•••'; + } + } + + &.resize-cursor { + cursor: ew-resize; + cursor: col-resize; + } + + ul, + ol { + padding: 0 1rem; + margin: 1.25rem 1rem 1.25rem 0.4rem; + + li p { + margin-top: 0.25em; + margin-bottom: 0.25em; + } + + // Reduce spacing for nested lists + ul, + ol { + margin: 0.5rem 0.5rem 0.5rem 0.2rem; + } + } + + ul { + list-style: disc; + } + + ol { + list-style: decimal; + } + + ul[data-type='taskList'] { + list-style: none; + margin-left: 0; + padding: 0; + + li { + align-items: center; + display: flex; + + > label { + flex: 0 0 auto; + margin-right: 0.5rem; + user-select: none; + display: flex; + align-items: center; + } + + > div { + flex: 1 1 auto; + + p { + margin: 0; + } + } + } + + /* Checked task item appearance */ + li[data-checked='true'] { + > div { + color: var(--color-text-2); + text-decoration: line-through; + } + } + + input[type='checkbox'] { + cursor: pointer; + } + + /* Use primary color for checked checkbox */ + input[type='checkbox']:checked { + accent-color: var(--color-primary); + background-color: var(--color-primary); + border-color: var(--color-primary); + } + + ul[data-type='taskList'] { + margin: 0; + } + } + + /* Math block */ + .block-math-inner { + display: flex; + justify-content: center; + align-items: center; + font-size: 1.2rem; + } + + /* Bottom spacer to create viewport padding */ + &::after { + content: ''; + display: block; + height: 50px; + pointer-events: none; + } +} + +// Code block wrapper and header styles +.code-block-wrapper { + position: relative; + + .code-block-header { + display: flex; + align-items: center; + gap: 6px; + position: absolute; + top: 4px; + right: 6px; + opacity: 0; + transition: opacity 0.2s; + } + + &:hover .code-block-header { + opacity: 1; + } +} diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index 8cdf4e4d45..d072e88c4b 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -145,9 +145,10 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht language="html" editable={true} onSave={onSave} - style={{ height: '100%' }} - expanded - unwrapped={false} + height="100%" + expanded={false} + wrapped + style={{ minHeight: 0 }} options={{ stream: true, // FIXME: 避免多余空行 lineNumbers: true, @@ -388,12 +389,8 @@ const CodeSection = styled.div` width: 100%; overflow: hidden; position: relative; - - .monaco-editor, - .cm-editor, - .cm-scroller { - height: 100% !important; - } + display: grid; + grid-template-rows: 1fr auto; ` const PreviewSection = styled.div` diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx index 7c977cf37c..174327ae68 100644 --- a/src/renderer/src/components/CodeBlockView/view.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -100,7 +100,7 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave }, [hasSpecialView, viewMode]) const [expandOverride, setExpandOverride] = useState(!codeCollapsible) - const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable) + const [wrapOverride, setWrapOverride] = useState(codeWrappable) // 重置用户操作 useEffect(() => { @@ -109,11 +109,11 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave // 重置用户操作 useEffect(() => { - setUnwrapOverride(!codeWrappable) + setWrapOverride(codeWrappable) }, [codeWrappable]) const shouldExpand = useMemo(() => !codeCollapsible || expandOverride, [codeCollapsible, expandOverride]) - const shouldUnwrap = useMemo(() => !codeWrappable || unwrapOverride, [codeWrappable, unwrapOverride]) + const shouldWrap = useMemo(() => codeWrappable && wrapOverride, [codeWrappable, wrapOverride]) const [sourceScrollHeight, setSourceScrollHeight] = useState(0) const expandable = useMemo(() => { @@ -225,9 +225,9 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave // 源代码视图的自动换行按钮 useWrapTool({ enabled: !isInSpecialView, - unwrapped: shouldUnwrap, + wrapped: shouldWrap, wrappable: codeWrappable, - toggle: useCallback(() => setUnwrapOverride((prev) => !prev), []), + toggle: useCallback(() => setWrapOverride((prev) => !prev), []), setTools }) @@ -249,21 +249,22 @@ export const CodeBlockView: React.FC = memo(({ children, language, onSave language={language} onSave={onSave} onHeightChange={handleHeightChange} + maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`} options={{ stream: true }} expanded={shouldExpand} - unwrapped={shouldUnwrap} + wrapped={shouldWrap} /> ) : ( {children} ), - [children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldUnwrap] + [children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldWrap] ) // 特殊视图组件映射 @@ -344,7 +345,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>` } ` -const CodeHeader = styled.div<{ $isInSpecialView: boolean }>` +const CodeHeader = styled.div<{ $isInSpecialView?: boolean }>` display: flex; align-items: center; color: var(--color-text); @@ -370,7 +371,11 @@ const SplitViewWrapper = styled.div<{ $isSpecialView: boolean; $isSplitView: boo &:not(:has(+ [class*='Container'])) { // 特殊视图的 header 会隐藏,所以全都使用圆角 border-radius: ${(props) => (props.$isSpecialView ? '8px' : '0 0 8px 8px')}; - overflow: hidden; + // FIXME: 滚动条边缘会溢出,可以考虑增加 padding,但是要保证代码主题颜色铺满容器。 + // overflow: hidden; + .code-viewer { + border-radius: inherit; + } } // 在 split 模式下添加中间分隔线 diff --git a/src/renderer/src/components/CodeEditor/__tests__/utils.test.ts b/src/renderer/src/components/CodeEditor/__tests__/utils.test.ts new file mode 100644 index 0000000000..b02d10e152 --- /dev/null +++ b/src/renderer/src/components/CodeEditor/__tests__/utils.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getNormalizedExtension } from '../utils' + +const mocks = vi.hoisted(() => ({ + getExtensionByLanguage: vi.fn() +})) + +vi.mock('@renderer/utils/code-language', () => ({ + getExtensionByLanguage: mocks.getExtensionByLanguage +})) + +describe('getNormalizedExtension', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return custom mapping for custom language', async () => { + mocks.getExtensionByLanguage.mockReturnValue(undefined) + await expect(getNormalizedExtension('svg')).resolves.toBe('xml') + await expect(getNormalizedExtension('SVG')).resolves.toBe('xml') + }) + + it('should prefer custom mapping when both custom and linguist exist', async () => { + mocks.getExtensionByLanguage.mockReturnValue('.svg') + await expect(getNormalizedExtension('svg')).resolves.toBe('xml') + }) + + it('should return linguist mapping when available (strip leading dot)', async () => { + mocks.getExtensionByLanguage.mockReturnValue('.ts') + await expect(getNormalizedExtension('TypeScript')).resolves.toBe('ts') + }) + + it('should return extension when input already looks like extension (leading dot)', async () => { + mocks.getExtensionByLanguage.mockReturnValue(undefined) + await expect(getNormalizedExtension('.json')).resolves.toBe('json') + }) + + it('should return language as-is when no rules matched', async () => { + mocks.getExtensionByLanguage.mockReturnValue(undefined) + await expect(getNormalizedExtension('unknownLanguage')).resolves.toBe('unknownLanguage') + }) +}) diff --git a/src/renderer/src/components/CodeEditor/hooks.ts b/src/renderer/src/components/CodeEditor/hooks.ts index 7917cebd80..b6689644e9 100644 --- a/src/renderer/src/components/CodeEditor/hooks.ts +++ b/src/renderer/src/components/CodeEditor/hooks.ts @@ -8,7 +8,9 @@ import { getNormalizedExtension } from './utils' const logger = loggerService.withContext('CodeEditorHooks') -// 语言对应的 linter 加载器 +/** 语言对应的 linter 加载器 + * key: 语言文件扩展名(不包含 `.`) + */ const linterLoaders: Record Promise> = { json: async () => { const jsonParseLinter = await import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter) @@ -64,13 +66,15 @@ async function loadLanguageExtension(language: string): Promise { - const loader = linterLoaders[language] + const fileExt = await getNormalizedExtension(language) + + const loader = linterLoaders[fileExt] if (!loader) return null try { return await loader() } catch (error) { - logger.debug(`Failed to load linter for ${language}`, error as Error) + logger.debug(`Failed to load linter for ${language} (${fileExt})`, error as Error) return null } } diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx index 3ae87ad5dd..7c541cbdb8 100644 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -1,4 +1,3 @@ -import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useSettings } from '@renderer/hooks/useSettings' import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror' @@ -15,39 +14,87 @@ export interface CodeEditorHandles { save?: () => void } -interface CodeEditorProps { +export interface CodeEditorProps { ref?: React.RefObject + /** Value used in controlled mode, e.g., code blocks. */ value: string + /** Placeholder when the editor content is empty. */ placeholder?: string | HTMLElement + /** + * Code language string. + * - Case-insensitive. + * - Supports common names: javascript, json, python, etc. + * - Supports aliases: c#/csharp, objective-c++/obj-c++/objc++, etc. + * - Supports file extensions: .cpp/cpp, .js/js, .py/py, etc. + */ language: string + /** Fired when ref.save() is called or the save shortcut is triggered. */ onSave?: (newContent: string) => void + /** Fired when the editor content changes. */ onChange?: (newContent: string) => void + /** Fired when the editor loses focus. */ onBlur?: (newContent: string) => void + /** Fired when the editor height changes. */ onHeightChange?: (scrollHeight: number) => void + /** + * Fixed editor height, not exceeding maxHeight. + * Only works when expanded is false. + */ height?: string - minHeight?: string + /** + * Maximum editor height. + * Only works when expanded is false. + */ maxHeight?: string + /** Minimum editor height. */ + minHeight?: string + /** Font size that overrides the app setting. */ fontSize?: string - /** 用于覆写编辑器的某些设置 */ + /** Editor options that extend BasicSetupOptions. */ options?: { - stream?: boolean // 用于流式响应场景,默认 false + /** + * Whether to enable special treatment for stream response. + * @default false + */ + stream?: boolean + /** + * Whether to enable linting. + * @default false + */ lint?: boolean + /** + * Whether to enable keymap. + * @default false + */ keymap?: boolean } & BasicSetupOptions - /** 用于追加 extensions */ + /** Additional extensions for CodeMirror. */ extensions?: Extension[] - /** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */ + /** Style overrides for the editor, passed directly to CodeMirror's style property. */ style?: React.CSSProperties + /** CSS class name appended to the default `code-editor` class. */ className?: string + /** + * Whether the editor is editable. + * @default true + */ editable?: boolean + /** + * Whether the editor is expanded. + * If true, the height and maxHeight props are ignored. + * @default true + */ expanded?: boolean - unwrapped?: boolean + /** + * Whether the code lines are wrapped. + * @default true + */ + wrapped?: boolean } /** - * 源代码编辑器,基于 CodeMirror,封装了 ReactCodeMirror。 - * - * 目前必须和 CodeToolbar 配合使用。 + * A code editor component based on CodeMirror. + * This is a wrapper of ReactCodeMirror. */ const CodeEditor = ({ ref, @@ -59,8 +106,8 @@ const CodeEditor = ({ onBlur, onHeightChange, height, - minHeight, maxHeight, + minHeight, fontSize, options, extensions, @@ -68,7 +115,7 @@ const CodeEditor = ({ className, editable = true, expanded = true, - unwrapped = false + wrapped = true }: CodeEditorProps) => { const { fontSize: _fontSize, codeShowLineNumbers: _lineNumbers, codeEditor } = useSettings() const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap]) @@ -121,12 +168,12 @@ const CodeEditor = ({ return [ ...(extensions ?? []), ...langExtensions, - ...(unwrapped ? [] : [EditorView.lineWrapping]), + ...(wrapped ? [EditorView.lineWrapping] : []), saveKeymapExtension, blurExtension, heightListenerExtension ].flat() - }, [extensions, langExtensions, unwrapped, saveKeymapExtension, blurExtension, heightListenerExtension]) + }, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension]) useImperativeHandle(ref, () => ({ save: handleSave @@ -138,9 +185,9 @@ const CodeEditor = ({ value={initialContent.current} placeholder={placeholder} width="100%" - height={height} + height={expanded ? undefined : height} + maxHeight={expanded ? undefined : maxHeight} minHeight={minHeight} - maxHeight={expanded ? 'none' : (maxHeight ?? `${MAX_COLLAPSED_CODE_HEIGHT}px`)} editable={editable} // @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx theme={activeCmTheme} diff --git a/src/renderer/src/components/CodeEditor/utils.ts b/src/renderer/src/components/CodeEditor/utils.ts index 251778b9d1..ef5941720e 100644 --- a/src/renderer/src/components/CodeEditor/utils.ts +++ b/src/renderer/src/components/CodeEditor/utils.ts @@ -13,6 +13,7 @@ const _customLanguageExtensions: Record = { * 获取语言的扩展名,用于 @uiw/codemirror-extensions-langs * - 先搜索自定义扩展名 * - 再搜索 github linguist 扩展名 + * - 最后假定名称已经是扩展名 * @param language 语言名称 * @returns 扩展名(不包含 `.`) */ @@ -29,6 +30,11 @@ export async function getNormalizedExtension(language: string) { return linguistExt.slice(1) } + // 如果语言名称像扩展名 + if (language.startsWith('.') && language.length > 1) { + return language.slice(1) + } + // 回退到语言名称 return language } diff --git a/src/renderer/src/components/CodeToolbar/__tests__/useWrapTool.test.tsx b/src/renderer/src/components/CodeToolbar/__tests__/useWrapTool.test.tsx index ca601cd37f..f85bb52df2 100644 --- a/src/renderer/src/components/CodeToolbar/__tests__/useWrapTool.test.tsx +++ b/src/renderer/src/components/CodeToolbar/__tests__/useWrapTool.test.tsx @@ -50,7 +50,7 @@ describe('useWrapTool', () => { const createMockProps = (overrides: Partial[0]> = {}) => { const defaultProps = { enabled: true, - unwrapped: false, + wrapped: true, wrappable: true, toggle: vi.fn(), setTools: vi.fn() @@ -90,8 +90,8 @@ describe('useWrapTool', () => { expect(mockRegisterTool).not.toHaveBeenCalled() }) - it('should re-register tool when unwrapped changes', () => { - const props = createMockProps({ unwrapped: false }) + it('should re-register tool when wrapped changes', () => { + const props = createMockProps({ wrapped: true }) const { rerender } = renderHook((hookProps) => useWrapTool(hookProps), { initialProps: props }) @@ -100,8 +100,8 @@ describe('useWrapTool', () => { const firstCall = mockRegisterTool.mock.calls[0][0] expect(firstCall.tooltip).toBe('code_block.wrap.off') - // Change unwrapped to true and rerender - const newProps = { ...props, unwrapped: true } + // Change wrapped to false and rerender + const newProps = { ...props, wrapped: false } rerender(newProps) expect(mockRegisterTool).toHaveBeenCalledTimes(2) diff --git a/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx b/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx index c0354e78fd..bea1e4a5b5 100644 --- a/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx +++ b/src/renderer/src/components/CodeToolbar/hooks/useWrapTool.tsx @@ -5,13 +5,13 @@ import { useTranslation } from 'react-i18next' interface UseWrapToolProps { enabled?: boolean - unwrapped?: boolean + wrapped?: boolean wrappable?: boolean toggle: () => void setTools: React.Dispatch> } -export const useWrapTool = ({ enabled, unwrapped, wrappable, toggle, setTools }: UseWrapToolProps) => { +export const useWrapTool = ({ enabled, wrapped, wrappable, toggle, setTools }: UseWrapToolProps) => { const { t } = useTranslation() const { registerTool, removeTool } = useToolManager(setTools) @@ -23,13 +23,13 @@ export const useWrapTool = ({ enabled, unwrapped, wrappable, toggle, setTools }: if (enabled) { registerTool({ ...TOOL_SPECS.wrap, - icon: unwrapped ? : , - tooltip: unwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'), + icon: wrapped ? : , + tooltip: wrapped ? t('code_block.wrap.off') : t('code_block.wrap.on'), visible: () => wrappable ?? false, onClick: handleToggle }) } return () => removeTool(TOOL_SPECS.wrap.id) - }, [enabled, handleToggle, registerTool, removeTool, t, unwrapped, wrappable]) + }, [enabled, handleToggle, registerTool, removeTool, t, wrapped, wrappable]) } diff --git a/src/renderer/src/components/CodeViewer.tsx b/src/renderer/src/components/CodeViewer.tsx index c3783fd2c4..440aed9c7c 100644 --- a/src/renderer/src/components/CodeViewer.tsx +++ b/src/renderer/src/components/CodeViewer.tsx @@ -14,9 +14,10 @@ interface CodeViewerProps { language: string children: string expanded?: boolean - unwrapped?: boolean + wrapped?: boolean onHeightChange?: (scrollHeight: number) => void className?: string + height?: string | number } /** @@ -25,7 +26,7 @@ interface CodeViewerProps { * - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应 * - 并发安全 */ -const CodeViewer = ({ children, language, expanded, unwrapped, onHeightChange, className }: CodeViewerProps) => { +const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, className, height }: CodeViewerProps) => { const { codeShowLineNumbers, fontSize } = useSettings() const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle() const shikiThemeRef = useRef(null) @@ -104,18 +105,20 @@ const CodeViewer = ({ children, language, expanded, unwrapped, onHeightChange, c }, [rawLines.length, onHeightChange]) return ( -
+
@@ -225,6 +228,7 @@ const ScrollContainer = styled.div<{ $wrap?: boolean $expanded?: boolean $lineHeight?: number + $height?: string | number }>` display: block; overflow-x: auto; @@ -257,6 +261,7 @@ const ScrollContainer = styled.div<{ .line-content { flex: 1; padding-right: 1em; + white-space: pre; * { white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')}; overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')}; diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx index 637af96de4..084a79a439 100644 --- a/src/renderer/src/components/ContentSearch.tsx +++ b/src/renderer/src/components/ContentSearch.tsx @@ -18,6 +18,15 @@ interface Props { filter: NodeFilter includeUser?: boolean onIncludeUserChange?: (value: boolean) => void + /** + * 是否显示“包含用户问题”切换按钮(默认为 true)。 + * 在富文本编辑器场景通常不需要该按钮。 + */ + showUserToggle?: boolean + /** + * 搜索条定位方式 + */ + positionMode?: 'fixed' | 'absolute' | 'sticky' } enum SearchCompletedState { @@ -125,7 +134,10 @@ const findRangesInTarget = ( // eslint-disable-next-line @eslint-react/no-forward-ref export const ContentSearch = React.forwardRef( - ({ searchTarget, filter, includeUser = false, onIncludeUserChange }, ref) => { + ( + { searchTarget, filter, includeUser = false, onIncludeUserChange, showUserToggle = true, positionMode = 'fixed' }, + ref + ) => { const target: HTMLElement | null = (() => { if (searchTarget instanceof HTMLElement) { return searchTarget @@ -335,9 +347,12 @@ export const ContentSearch = React.forwardRef( } return ( - + - + ( style={{ lineHeight: '20px' }} /> - - - - - + {showUserToggle && ( + + + + + + )} ( ContentSearch.displayName = 'ContentSearch' -const Container = styled.div` +const Container = styled.div<{ $overlayPosition: 'static' | 'absolute' }>` display: flex; flex-direction: row; - z-index: 2; + position: ${({ $overlayPosition }) => $overlayPosition}; + top: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')}; + left: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')}; + right: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')}; + z-index: 999; ` -const SearchBarContainer = styled.div` +const SearchBarContainer = styled.div<{ $position: 'fixed' | 'absolute' | 'sticky' }>` border: 1px solid var(--color-primary); border-radius: 10px; transition: all 0.2s ease; - position: fixed; + position: ${({ $position }) => $position}; top: 15px; left: 20px; right: 20px; diff --git a/src/renderer/src/components/FreeTrialModelTag.tsx b/src/renderer/src/components/FreeTrialModelTag.tsx new file mode 100644 index 0000000000..0ce63ade37 --- /dev/null +++ b/src/renderer/src/components/FreeTrialModelTag.tsx @@ -0,0 +1,81 @@ +import { getProviderLabel } from '@renderer/i18n/label' +import NavigationService from '@renderer/services/NavigationService' +import { Model } from '@renderer/types' +import { ArrowUpRight } from 'lucide-react' +import { FC, MouseEvent } from 'react' +import styled from 'styled-components' + +import IndicatorLight from './IndicatorLight' +import SelectModelPopup from './Popups/SelectModelPopup' +import CustomTag from './Tags/CustomTag' + +interface Props { + model: Model + showLabel?: boolean +} + +export const FreeTrialModelTag: FC = ({ model, showLabel = true }) => { + if (model.provider !== 'cherryin') { + return null + } + + let providerId + + if (model.id === 'glm-4.5-flash') { + providerId = 'zhipu' + } + + if (model.id === 'Qwen/Qwen3-8B') { + providerId = 'silicon' + } + + const onSelectProvider = () => { + NavigationService.navigate!(`/settings/provider?id=${providerId}`) + } + + const onNavigateProvider = (e: MouseEvent) => { + e.stopPropagation() + SelectModelPopup.hide() + NavigationService.navigate!(`/settings/provider?id=${providerId}`) + } + + if (!showLabel) { + return ( + + + {getProviderLabel(providerId)} + + + + ) + } + + return ( + + + Powered by + {getProviderLabel(providerId)} + + ) +} + +const Container = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; +` + +const PoweredBy = styled.span` + font-size: 12px; + color: var(--color-text-2); +` + +const LinkText = styled.a` + font-size: 12px; + color: var(--color-link); +` diff --git a/src/renderer/src/components/Icons/SVGIcon.tsx b/src/renderer/src/components/Icons/SVGIcon.tsx index 8cae9c94de..a83685e4db 100644 --- a/src/renderer/src/components/Icons/SVGIcon.tsx +++ b/src/renderer/src/components/Icons/SVGIcon.tsx @@ -239,6 +239,18 @@ export function BochaLogo(props: SVGProps) { ) } +export function ZhipuLogo(props: SVGProps) { + return ( + + + + ) +} export function PoeLogo(props: SVGProps) { return ( { const onClick = useCallback(async () => { - const selectedModel = await SelectModelPopup.show({ model, modelFilter }) + const selectedModel = await SelectModelPopup.show({ model, filter: modelFilter }) if (selectedModel) { onSelectModel?.(selectedModel) } diff --git a/src/renderer/src/components/OGCard.tsx b/src/renderer/src/components/OGCard.tsx new file mode 100644 index 0000000000..8a0036e8e2 --- /dev/null +++ b/src/renderer/src/components/OGCard.tsx @@ -0,0 +1,145 @@ +import CherryLogo from '@renderer/assets/images/banner.png' +import Favicon from '@renderer/components/Icons/FallbackFavicon' +import { useMetaDataParser } from '@renderer/hooks/useMetaDataParser' +import { Skeleton, Typography } from 'antd' +import { useEffect, useMemo } from 'react' +import styled from 'styled-components' +const { Title, Paragraph } = Typography + +type Props = { + link: string + show: boolean +} + +export const OGCard = ({ link, show }: Props) => { + const openGraph = ['og:title', 'og:description', 'og:image', 'og:imageAlt'] as const + const { metadata, isLoading, parseMetadata } = useMetaDataParser(link, openGraph) + + const hasImage = !!metadata['og:image'] + + const hostname = useMemo(() => { + try { + return new URL(link).hostname + } catch { + return null + } + }, [link]) + + useEffect(() => { + // use show to lazy loading + if (show && isLoading) { + parseMetadata() + } + }, [parseMetadata, isLoading, show]) + + if (isLoading) { + return + } + + return ( + + {hasImage && ( + + + + )} + {!hasImage && ( + + + + )} + + + + {hostname && } + + {metadata['og:title'] || hostname} + + + + {metadata['og:description'] || link} + + + + ) +} + +const CardSkeleton = () => { + return ( + + + + + ) +} + +const StyledHyperLink = styled.div` + display: flex; + align-items: center; + gap: 8px; +` + +const PreviewContainer = styled.div<{ hasImage?: boolean }>` + display: flex; + flex-direction: column; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + width: 380px; + height: 220px; + overflow: hidden; +` + +const PreviewImageContainer = styled.div` + width: 100%; + height: 140px; + min-height: 140px; + overflow: hidden; +` + +const PreviewContent = styled.div` + padding: 12px 16px; + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; +` + +const PreviewImage = styled.img` + width: 100%; + height: 140px; + object-fit: cover; +` + +const SkeletonContainer = styled.div` + width: 380px; + height: 220px; + padding: 12px 16px; + display: flex; + flex-direction: column; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + gap: 16px; +` diff --git a/src/renderer/src/components/Popups/GeneralPopup.tsx b/src/renderer/src/components/Popups/GeneralPopup.tsx new file mode 100644 index 0000000000..3307b10162 --- /dev/null +++ b/src/renderer/src/components/Popups/GeneralPopup.tsx @@ -0,0 +1,66 @@ +import { TopView } from '@renderer/components/TopView' +import { Modal, ModalProps } from 'antd' +import { ReactNode, useState } from 'react' + +interface ShowParams extends ModalProps { + content: ReactNode +} + +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ content, resolve, ...rest }) => { + const [open, setOpen] = useState(true) + + const onOk = () => { + setOpen(false) + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + GeneralPopup.hide = onCancel + + return ( + + {content} + + ) +} + +const TopViewKey = 'GeneralPopup' + +/** 在这个 Popup 中展示任意内容 */ +export default class GeneralPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/Popups/RichEditPopup.tsx b/src/renderer/src/components/Popups/RichEditPopup.tsx new file mode 100644 index 0000000000..ed7c3d407c --- /dev/null +++ b/src/renderer/src/components/Popups/RichEditPopup.tsx @@ -0,0 +1,160 @@ +import RichEditor from '@renderer/components/RichEditor' +import { RichEditorRef } from '@renderer/components/RichEditor/types' +import { Modal, ModalProps } from 'antd' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { TopView } from '../TopView' + +interface ShowParams { + content: string + modalProps?: ModalProps + showTranslate?: boolean + disableCommands?: string[] // 要禁用的命令列表 + children?: (props: { onOk?: () => void; onCancel?: () => void }) => React.ReactNode +} + +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ + content, + modalProps, + resolve, + children, + disableCommands = ['image', 'inlineMath'] // 默认禁用 image 命令 +}) => { + const [open, setOpen] = useState(true) + const { t } = useTranslation() + const [richContent, setRichContent] = useState(content) + const editorRef = useRef(null) + const isMounted = useRef(true) + + useEffect(() => { + return () => { + isMounted.current = false + } + }, []) + + const onOk = () => { + const finalContent = editorRef.current?.getMarkdown() || richContent + resolve(finalContent) + setOpen(false) + } + + const onCancel = () => { + resolve(null) + setOpen(false) + } + + const onClose = () => { + resolve(null) + } + + const handleAfterOpenChange = (visible: boolean) => { + if (visible && editorRef.current) { + // Focus the editor after modal opens + setTimeout(() => { + editorRef.current?.focus() + }, 100) + } + } + + const handleContentChange = (newContent: string) => { + setRichContent(newContent) + } + + const handleMarkdownChange = (newMarkdown: string) => { + // 更新Markdown内容状态 + setRichContent(newMarkdown) + } + + // 处理命令配置 + const handleCommandsReady = (commandAPI: Pick) => { + // 禁用指定的命令 + if (disableCommands?.length) { + disableCommands.forEach((commandId) => { + commandAPI.unregisterCommand(commandId) + }) + } + } + + RichEditPopup.hide = onCancel + + return ( + + + + + {children && children({ onOk, onCancel })} + + ) +} + +const TopViewKey = 'RichEditPopup' + +const ChildrenContainer = styled.div` + position: relative; +` + +const EditorContainer = styled.div` + position: relative; + + .rich-edit-popup-editor { + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-background); + + &:focus-within { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-alpha); + } + } +` + +export default class RichEditPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx index cfc190399d..f8789735e9 100644 --- a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx +++ b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx @@ -4,6 +4,7 @@ import { TopView } from '@renderer/components/TopView' import { useKnowledge, useKnowledgeBases } from '@renderer/hooks/useKnowledge' import { Topic } from '@renderer/types' import { Message } from '@renderer/types/newMessage' +import { NotesTreeNode } from '@renderer/types/note' import { analyzeMessageContent, analyzeTopicContent, @@ -77,7 +78,10 @@ interface ContentTypeOption { description: string } -type ContentSource = { type: 'message'; data: Message } | { type: 'topic'; data: Topic } +type ContentSource = + | { type: 'message'; data: Message } + | { type: 'topic'; data: Topic } + | { type: 'note'; data: NotesTreeNode } interface ShowParams { source: ContentSource @@ -106,10 +110,16 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { const { t } = useTranslation() const isTopicMode = source?.type === 'topic' + const isNoteMode = source?.type === 'note' // 异步分析内容统计 useEffect(() => { const analyze = async () => { + if (isNoteMode) { + setAnalysisLoading(false) + return + } + setAnalysisLoading(true) setContentStats(null) try { @@ -136,11 +146,11 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { } } analyze() - }, [source, isTopicMode]) + }, [source, isTopicMode, isNoteMode]) // 生成内容类型选项 const contentTypeOptions: ContentTypeOption[] = useMemo(() => { - if (!contentStats) return [] + if (!contentStats || isNoteMode) return [] return Object.entries(CONTENT_TYPE_CONFIG) .map(([type, config]) => { @@ -159,7 +169,7 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { } }) .filter((option) => option.enabled) - }, [contentStats, t, isTopicMode]) + }, [contentStats, t, isTopicMode, isNoteMode]) // 知识库选项 const knowledgeBaseOptions = useMemo( @@ -175,19 +185,24 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { // 表单状态 const formState = useMemo(() => { const hasValidBase = selectedBaseId && bases.find((base) => base.id === selectedBaseId)?.version - const hasContent = contentTypeOptions.length > 0 - const selectedCount = contentTypeOptions - .filter((option) => selectedTypes.includes(option.type)) - .reduce((sum, option) => sum + option.count, 0) + const hasContent = isNoteMode || contentTypeOptions.length > 0 + + const canSubmit = hasValidBase && (isNoteMode || (selectedTypes.length > 0 && hasContent)) + + const selectedCount = isNoteMode + ? 1 + : contentTypeOptions + .filter((option) => selectedTypes.includes(option.type)) + .reduce((sum, option) => sum + option.count, 0) return { hasValidBase, hasContent, - canSubmit: hasValidBase && selectedTypes.length > 0 && hasContent, + canSubmit, selectedCount, - hasNoSelection: selectedTypes.length === 0 && hasContent + hasNoSelection: !isNoteMode && selectedTypes.length === 0 && hasContent } - }, [selectedBaseId, bases, contentTypeOptions, selectedTypes]) + }, [selectedBaseId, bases, contentTypeOptions, selectedTypes, isNoteMode]) // 默认选择第一个可用知识库 useEffect(() => { @@ -201,28 +216,31 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { // 默认选择所有可用内容类型 useEffect(() => { - if (!hasInitialized && contentTypeOptions.length > 0) { + if (!hasInitialized && contentTypeOptions.length > 0 && !isNoteMode) { setSelectedTypes(contentTypeOptions.map((option) => option.type)) setHasInitialized(true) } - }, [contentTypeOptions, hasInitialized]) + }, [contentTypeOptions, hasInitialized, isNoteMode]) // UI状态 const uiState = useMemo(() => { if (analysisLoading) { return { type: 'loading', message: t('chat.save.topic.knowledge.loading') } } - if (!formState.hasContent) { + + if (!formState.hasContent && !isNoteMode) { return { type: 'empty', message: t(isTopicMode ? 'chat.save.topic.knowledge.empty.no_content' : 'chat.save.knowledge.empty.no_content') } } + if (bases.length === 0) { return { type: 'empty', message: t('chat.save.knowledge.empty.no_knowledge_base') } } + return { type: 'form' } - }, [analysisLoading, formState.hasContent, bases.length, t, isTopicMode]) + }, [analysisLoading, formState.hasContent, bases.length, t, isTopicMode, isNoteMode]) const handleContentTypeToggle = (type: ContentType) => { setSelectedTypes((prev) => (prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type])) @@ -235,18 +253,28 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { let savedCount = 0 try { - const result = isTopicMode - ? await processTopicContent(source?.data as Topic, selectedTypes) - : processMessageContent(source?.data as Message, selectedTypes) + if (isNoteMode) { + const note = source.data as NotesTreeNode + const content = await window.api.file.read(note.id + '.md') + logger.debug('Note content:', content) + await addNote(content) + savedCount = 1 + } else { + // 原有的消息或主题处理逻辑 + const result = isTopicMode + ? await processTopicContent(source?.data as Topic, selectedTypes) + : processMessageContent(source?.data as Message, selectedTypes) - if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) { - await addNote(result.text) - savedCount++ - } + logger.debug('Processed content:', result) + if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) { + await addNote(result.text) + savedCount++ + } - if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) { - addFiles(result.files) - savedCount += result.files.length + if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) { + addFiles(result.files) + savedCount += result.files.length + } } setOpen(false) @@ -285,66 +313,81 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { /> - - - {contentTypeOptions.map((option) => ( - handleContentTypeToggle(option.type)}> - - - {option.count} - - {option.label} - - - - - {selectedTypes.includes(option.type) && } - - ))} - - + {!isNoteMode && ( + + + {contentTypeOptions.map((option) => ( + handleContentTypeToggle(option.type)}> + + + {option.count} + + {option.label} + + + + + {selectedTypes.includes(option.type) && } + + ))} + + + )} - - {formState.selectedCount > 0 && ( - - {t( - isTopicMode - ? 'chat.save.topic.knowledge.select.content.selected_tip' - : 'chat.save.knowledge.select.content.tip', - { - count: formState.selectedCount, - ...(isTopicMode && { messages: (contentStats as TopicContentStats)?.messages || 0 }) - } - )} - - )} - {formState.hasNoSelection && ( - - {t('chat.save.knowledge.error.no_content_selected')} - - )} - {!formState.hasNoSelection && formState.selectedCount === 0 && ( - -   - - )} - + {!isNoteMode && ( + + {formState.selectedCount > 0 && ( + + {t( + isTopicMode + ? 'chat.save.topic.knowledge.select.content.selected_tip' + : 'chat.save.knowledge.select.content.tip', + { + count: formState.selectedCount, + ...(isTopicMode && { messages: (contentStats as TopicContentStats)?.messages || 0 }) + } + )} + + )} + {formState.hasNoSelection && ( + + {t('chat.save.knowledge.error.no_content_selected')} + + )} + {!formState.hasNoSelection && formState.selectedCount === 0 && ( + +   + + )} + + )} ) return ( { return this.show({ source: { type: 'topic', data: topic }, title }) } + + static showForNote(note: NotesTreeNode, title?: string): Promise { + return this.show({ source: { type: 'note', data: note }, title }) + } } const EmptyContainer = styled.div` diff --git a/src/renderer/src/components/Popups/SelectModelPopup/TagFilterSection.tsx b/src/renderer/src/components/Popups/SelectModelPopup/TagFilterSection.tsx new file mode 100644 index 0000000000..aec91bd803 --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/TagFilterSection.tsx @@ -0,0 +1,83 @@ +import { loggerService } from '@logger' +import { + EmbeddingTag, + FreeTag, + ReasoningTag, + RerankerTag, + ToolsCallingTag, + VisionTag, + WebSearchTag +} from '@renderer/components/Tags/Model' +import { ModelTag } from '@renderer/types' +import { Flex } from 'antd' +import React, { startTransition, useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const logger = loggerService.withContext('TagFilterSection') + +interface TagFilterSectionProps { + availableTags: ModelTag[] + tagSelection: Record + onToggleTag: (tag: ModelTag) => void +} + +const TagFilterSection: React.FC = ({ availableTags, tagSelection, onToggleTag }) => { + const { t } = useTranslation() + + const handleTagClick = useCallback( + (tag: ModelTag) => { + startTransition(() => onToggleTag(tag)) + }, + [onToggleTag] + ) + + // 标签组件 + const tagComponents = useMemo( + () => ({ + vision: VisionTag, + embedding: EmbeddingTag, + reasoning: ReasoningTag, + function_calling: ToolsCallingTag, + web_search: WebSearchTag, + rerank: RerankerTag, + free: FreeTag + }), + [] + ) + + return ( + + + {t('models.filter.by_tag')} + {availableTags.map((tag) => { + const TagElement = tagComponents[tag] + if (!TagElement) { + logger.error(`Tag element not found for tag: ${tag}`) + return null + } + return ( + handleTagClick(tag)} + inactive={!tagSelection[tag]} + showLabel + /> + ) + })} + + + ) +} + +const FilterContainer = styled.div` + padding: 8px; + padding-left: 18px; +` + +const FilterText = styled.span` + color: var(--color-text-3); + font-size: 12px; +` + +export default TagFilterSection diff --git a/src/renderer/src/components/Popups/SelectModelPopup/__tests__/TagFilterSection.test.tsx b/src/renderer/src/components/Popups/SelectModelPopup/__tests__/TagFilterSection.test.tsx new file mode 100644 index 0000000000..131e924e4e --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/__tests__/TagFilterSection.test.tsx @@ -0,0 +1,110 @@ +import type { ModelTag } from '@renderer/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import TagFilterSection from '../TagFilterSection' + +const mocks = vi.hoisted(() => ({ + t: vi.fn((key: string) => key), + createTagComponent: (name: string) => { + // Create a simple button component exposing props for assertions + return ({ onClick, inactive, showLabel }: { onClick?: () => void; inactive?: boolean; showLabel?: boolean }) => { + const React = require('react') + return React.createElement( + 'button', + { + type: 'button', + 'aria-label': `tag-${name}`, + 'data-inactive': String(Boolean(inactive)), + onClick + }, + showLabel ? name : '' + ) + } + } +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: mocks.t }) +})) + +vi.mock('@renderer/components/Tags/Model', () => ({ + VisionTag: mocks.createTagComponent('vision'), + EmbeddingTag: mocks.createTagComponent('embedding'), + ReasoningTag: mocks.createTagComponent('reasoning'), + ToolsCallingTag: mocks.createTagComponent('function_calling'), + WebSearchTag: mocks.createTagComponent('web_search'), + RerankerTag: mocks.createTagComponent('rerank'), + FreeTag: mocks.createTagComponent('free') +})) + +vi.mock('antd', () => ({ + Flex: ({ children }: { children: React.ReactNode }) => children +})) + +function createSelection(overrides: Partial> = {}): Record { + const base: Record = { + vision: true, + embedding: true, + reasoning: true, + function_calling: true, + web_search: true, + rerank: true, + free: true + } + return { ...base, ...overrides } +} + +const allTags: ModelTag[] = ['vision', 'embedding', 'reasoning', 'function_calling', 'web_search', 'rerank', 'free'] + +describe('TagFilterSection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('should match snapshot', () => { + const { container } = render( + + ) + expect(container).toMatchSnapshot() + }) + + it('should reflect inactive state based on tagSelection', () => { + render( + + ) + const visionBtn = screen.getByRole('button', { name: 'tag-vision' }) + expect(visionBtn).toHaveAttribute('data-inactive', 'true') + }) + + it('should skip unknown tags', () => { + render( + + ) + expect(screen.queryByRole('button', { name: 'tag-unknown' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'tag-vision' })).toBeInTheDocument() + }) + }) + + describe('functionality', () => { + it('should call onToggleTag when a tag is clicked', () => { + const handleToggle = vi.fn() + render() + + const visionBtn = screen.getByRole('button', { name: 'tag-vision' }) + fireEvent.click(visionBtn) + + expect(handleToggle).toHaveBeenCalledTimes(1) + expect(handleToggle).toHaveBeenCalledWith('vision') + }) + }) +}) diff --git a/src/renderer/src/components/Popups/SelectModelPopup/__tests__/__snapshots__/TagFilterSection.test.tsx.snap b/src/renderer/src/components/Popups/SelectModelPopup/__tests__/__snapshots__/TagFilterSection.test.tsx.snap new file mode 100644 index 0000000000..297987423c --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/__tests__/__snapshots__/TagFilterSection.test.tsx.snap @@ -0,0 +1,74 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TagFilterSection > rendering > should match snapshot 1`] = ` +.c0 { + padding: 8px; + padding-left: 18px; +} + +.c1 { + color: var(--color-text-3); + font-size: 12px; +} + +
+
+ + models.filter.by_tag + + + + + + + + +
+
+`; diff --git a/src/renderer/src/components/Popups/SelectModelPopup/__tests__/filters.test.ts b/src/renderer/src/components/Popups/SelectModelPopup/__tests__/filters.test.ts new file mode 100644 index 0000000000..ebdfce59a3 --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/__tests__/filters.test.ts @@ -0,0 +1,122 @@ +import type { Model } from '@renderer/types' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useModelTagFilter } from '../filters' + +const mocks = vi.hoisted(() => ({ + isVisionModel: vi.fn(), + isEmbeddingModel: vi.fn(), + isReasoningModel: vi.fn(), + isFunctionCallingModel: vi.fn(), + isWebSearchModel: vi.fn(), + isRerankModel: vi.fn(), + isFreeModel: vi.fn() +})) + +vi.mock('@renderer/config/models', () => ({ + isEmbeddingModel: mocks.isEmbeddingModel, + isFunctionCallingModel: mocks.isFunctionCallingModel, + isReasoningModel: mocks.isReasoningModel, + isRerankModel: mocks.isRerankModel, + isVisionModel: mocks.isVisionModel, + isWebSearchModel: mocks.isWebSearchModel +})) + +vi.mock('@renderer/utils/model', () => ({ + isFreeModel: mocks.isFreeModel +})) + +function createModel(overrides: Partial = {}): Model { + return { + id: 'm1', + provider: 'openai', + name: 'Model-1', + group: 'default', + ...overrides + } +} + +describe('useModelTagFilter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should have all tags unselected initially', () => { + const { result } = renderHook(() => useModelTagFilter()) + + expect(result.current.tagSelection).toEqual({ + vision: false, + embedding: false, + reasoning: false, + function_calling: false, + web_search: false, + rerank: false, + free: false + }) + expect(result.current.selectedTags).toEqual([]) + }) + + it('should toggle a tag state', () => { + const { result } = renderHook(() => useModelTagFilter()) + + act(() => result.current.toggleTag('vision')) + expect(result.current.tagSelection.vision).toBe(true) + expect(result.current.selectedTags).toEqual(['vision']) + + act(() => result.current.toggleTag('vision')) + expect(result.current.tagSelection.vision).toBe(false) + expect(result.current.selectedTags).toEqual([]) + }) + + it('should reset all tags to false', () => { + const { result } = renderHook(() => useModelTagFilter()) + + act(() => result.current.toggleTag('vision')) + act(() => result.current.toggleTag('embedding')) + expect(result.current.selectedTags.sort()).toEqual(['embedding', 'vision']) + + act(() => result.current.resetTags()) + expect(result.current.selectedTags).toEqual([]) + expect(Object.values(result.current.tagSelection).every((v) => v === false)).toBe(true) + }) + + it('tagFilter returns true when no tags selected', () => { + const { result } = renderHook(() => useModelTagFilter()) + const model = createModel() + const passed = result.current.tagFilter(model) + expect(passed).toBe(true) + expect(mocks.isVisionModel).not.toHaveBeenCalled() + }) + + it('tagFilter uses single selected tag predicate', () => { + const { result } = renderHook(() => useModelTagFilter()) + const model = createModel() + + mocks.isVisionModel.mockReturnValueOnce(true) + act(() => result.current.toggleTag('vision')) + + const ok = result.current.tagFilter(model) + expect(ok).toBe(true) + expect(mocks.isVisionModel).toHaveBeenCalledTimes(1) + expect(mocks.isVisionModel).toHaveBeenCalledWith(model) + }) + + it('tagFilter requires all selected tags to match (AND logic)', () => { + const { result } = renderHook(() => useModelTagFilter()) + const model = createModel() + + act(() => result.current.toggleTag('vision')) + act(() => result.current.toggleTag('embedding')) + + // 第一次:vision=true, embedding=false => 应为 false + mocks.isVisionModel.mockReturnValueOnce(true) + mocks.isEmbeddingModel.mockReturnValueOnce(false) + expect(result.current.tagFilter(model)).toBe(false) + + // 第二次:vision=true, embedding=true => 应为 true + mocks.isVisionModel.mockReturnValueOnce(true) + mocks.isEmbeddingModel.mockReturnValueOnce(true) + expect(result.current.tagFilter(model)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/Popups/SelectModelPopup/filters.ts b/src/renderer/src/components/Popups/SelectModelPopup/filters.ts new file mode 100644 index 0000000000..d2ee6c7742 --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/filters.ts @@ -0,0 +1,79 @@ +import { + isEmbeddingModel, + isFunctionCallingModel, + isReasoningModel, + isRerankModel, + isVisionModel, + isWebSearchModel +} from '@renderer/config/models' +import { Model, ModelTag, objectEntries } from '@renderer/types' +import { isFreeModel } from '@renderer/utils/model' +import { useCallback, useMemo, useState } from 'react' + +type ModelPredict = (m: Model) => boolean + +const initialTagSelection: Record = { + vision: false, + embedding: false, + reasoning: false, + function_calling: false, + web_search: false, + rerank: false, + free: false +} + +/** + * 标签筛选 hook,仅关注标签过滤逻辑 + */ +export function useModelTagFilter() { + const filterConfig: Record = useMemo( + () => ({ + vision: isVisionModel, + embedding: isEmbeddingModel, + reasoning: isReasoningModel, + function_calling: isFunctionCallingModel, + web_search: isWebSearchModel, + rerank: isRerankModel, + free: isFreeModel + }), + [] + ) + + const [tagSelection, setTagSelection] = useState>(initialTagSelection) + + // 已选中的标签 + const selectedTags = useMemo( + () => + objectEntries(tagSelection) + .filter(([, state]) => state) + .map(([tag]) => tag), + [tagSelection] + ) + + // 切换标签 + const toggleTag = useCallback((tag: ModelTag) => { + setTagSelection((prev) => ({ ...prev, [tag]: !prev[tag] })) + }, []) + + // 重置标签 + const resetTags = useCallback(() => { + setTagSelection(initialTagSelection) + }, []) + + // 根据标签过滤模型 + const tagFilter = useCallback( + (model: Model) => { + if (selectedTags.length === 0) return true + return selectedTags.map((tag) => filterConfig[tag]).every((predict) => predict(model)) + }, + [filterConfig, selectedTags] + ) + + return { + tagSelection, + selectedTags, + tagFilter, + toggleTag, + resetTags + } +} diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index ef616e71ac..1cd7926145 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -1,36 +1,19 @@ import { PushpinOutlined } from '@ant-design/icons' +import { FreeTrialModelTag } from '@renderer/components/FreeTrialModelTag' import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel' -import { - EmbeddingTag, - FreeTag, - ReasoningTag, - RerankerTag, - ToolsCallingTag, - VisionTag, - WebSearchTag -} from '@renderer/components/Tags/Model' import { TopView } from '@renderer/components/TopView' import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' -import { - getModelLogo, - isEmbeddingModel, - isFunctionCallingModel, - isReasoningModel, - isRerankModel, - isVisionModel, - isWebSearchModel -} from '@renderer/config/models' +import { getModelLogo } from '@renderer/config/models' import { usePinnedModels } from '@renderer/hooks/usePinnedModels' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' -import { Model, ModelTag, ModelType, objectEntries, Provider } from '@renderer/types' +import { Model, ModelType, objectEntries, Provider } from '@renderer/types' import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils' -import { getModelTags, isFreeModel } from '@renderer/utils/model' -import { Avatar, Button, Divider, Empty, Flex, Modal, Tooltip } from 'antd' +import { getModelTags } from '@renderer/utils/model' +import { Avatar, Divider, Empty, Modal, Tooltip } from 'antd' import { first, sortBy } from 'lodash' -import { SettingsIcon } from 'lucide-react' +import { Settings2 } from 'lucide-react' import React, { - ReactNode, startTransition, useCallback, useDeferredValue, @@ -43,18 +26,20 @@ import React, { import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import { useModelTagFilter } from './filters' import SelectModelSearchBar from './searchbar' +import TagFilterSection from './TagFilterSection' import { FlatListItem, FlatListModel } from './types' const PAGE_SIZE = 12 const ITEM_HEIGHT = 36 -type ModelPredict = (m: Model) => boolean - interface PopupParams { model?: Model - modelFilter?: (model: Model) => boolean - userFilterDisabled?: boolean + /** Basic model filter */ + filter?: (model: Model) => boolean + /** Show tag filter section */ + showTagFilter?: boolean } interface Props extends PopupParams { @@ -65,7 +50,7 @@ export type FilterType = Exclude | 'free' // const logger = loggerService.withContext('SelectModelPopup') -const PopupContainer: React.FC = ({ model, resolve, modelFilter, userFilterDisabled }) => { +const PopupContainer: React.FC = ({ model, filter: baseFilter, showTagFilter = true, resolve }) => { const { t } = useTranslation() const { providers } = useProviders() const { pinnedModels, togglePinnedModel, loading } = usePinnedModels() @@ -74,11 +59,6 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter, userFilt const [_searchText, setSearchText] = useState('') const searchText = useDeferredValue(_searchText) - const allModels: Model[] = useMemo( - () => providers.flatMap((p) => p.models).filter(modelFilter ?? (() => true)), - [modelFilter, providers] - ) - // 当前选中的模型ID const currentModelId = model ? getModelUniqId(model) : '' @@ -93,95 +73,16 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter, userFilt }) }, []) - // 管理用户筛选状态 - /** 从模型列表获取的需要显示的标签 */ - const availableTags = useMemo( - () => - objectEntries(getModelTags(allModels)) - .filter(([, state]) => state) - .map(([tag]) => tag), - [allModels] - ) + const { tagSelection, selectedTags, tagFilter, toggleTag } = useModelTagFilter() - const filterConfig: Record = useMemo( - () => ({ - vision: isVisionModel, - embedding: isEmbeddingModel, - reasoning: isReasoningModel, - function_calling: isFunctionCallingModel, - web_search: isWebSearchModel, - rerank: isRerankModel, - free: isFreeModel - }), - [] - ) + // 计算要显示的可用标签列表 + const availableTags = useMemo(() => { + const models = providers.flatMap((p) => p.models).filter(baseFilter ?? (() => true)) + return objectEntries(getModelTags(models)) + .filter(([, state]) => state) + .map(([tag]) => tag) + }, [providers, baseFilter]) - /** 当前选择的标签,表示是否启用特定tag的筛选 */ - const [filterTags, setFilterTags] = useState>({ - vision: false, - embedding: false, - reasoning: false, - function_calling: false, - web_search: false, - rerank: false, - free: false - }) - const selectedFilterTags = useMemo( - () => - objectEntries(filterTags) - .filter(([, state]) => state) - .map(([tag]) => tag), - [filterTags] - ) - - const userFilter = useCallback( - (model: Model) => { - return selectedFilterTags - .map((tag) => [tag, filterConfig[tag]] as const) - .reduce((prev, [tag, predict]) => { - return prev && (!filterTags[tag] || predict(model)) - }, true) - }, - [filterConfig, filterTags, selectedFilterTags] - ) - - const onClickTag = useCallback((type: ModelTag) => { - startTransition(() => { - setFilterTags((prev) => ({ ...prev, [type]: !prev[type] })) - }) - }, []) - - // 筛选项列表 - const tagsItems: Record = useMemo( - () => ({ - vision: onClickTag('vision')} />, - embedding: onClickTag('embedding')} />, - reasoning: onClickTag('reasoning')} />, - function_calling: ( - onClickTag('function_calling')} - /> - ), - web_search: onClickTag('web_search')} />, - rerank: onClickTag('rerank')} />, - free: onClickTag('free')} /> - }), - [ - filterTags.embedding, - filterTags.free, - filterTags.function_calling, - filterTags.reasoning, - filterTags.rerank, - filterTags.vision, - filterTags.web_search, - onClickTag - ] - ) - - // 要显示的筛选项 - const displayedTags = useMemo(() => availableTags.map((tag) => tagsItems[tag]), [availableTags, tagsItems]) // 根据输入的文本筛选模型 const searchFilter = useCallback( (provider: Provider) => { @@ -201,6 +102,7 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter, userFilt (model: Model, provider: Provider, isPinned: boolean): FlatListModel => { const modelId = getModelUniqId(model) const groupName = getFancyProviderName(provider) + const isCherryin = provider.id === 'cherryin' return { key: isPinned ? `${modelId}_pinned` : modelId, @@ -209,11 +111,12 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter, userFilt {model.name} {isPinned && | {groupName}} + {isCherryin && } ), tags: ( - + ), icon: ( @@ -234,9 +137,9 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter, userFilt const items: FlatListItem[] = [] const pinnedModelIds = new Set(pinnedModels) const finalModelFilter = (model: Model) => { - const _userFilter = userFilterDisabled || userFilter(model) - const _modelFilter = modelFilter === undefined || modelFilter(model) - return _userFilter && _modelFilter + const _tagFilter = !showTagFilter || tagFilter(model) + const _baseFilter = baseFilter === undefined || baseFilter(model) + return _tagFilter && _baseFilter } // 添加置顶模型分组(仅在无搜索文本时) @@ -276,11 +179,10 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter, userFilt name: getFancyProviderName(p), actions: ( -
+ + {/* floating panel */} +
+ +
+ {filteredItems.map((item) => ( + + ))} +
+
+
+ + ) +} + +export default React.memo(ToC) diff --git a/src/renderer/src/components/RichEditor/command.ts b/src/renderer/src/components/RichEditor/command.ts new file mode 100644 index 0000000000..a460e210d4 --- /dev/null +++ b/src/renderer/src/components/RichEditor/command.ts @@ -0,0 +1,648 @@ +import { autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom' +import { loggerService } from '@logger' +import type { Editor } from '@tiptap/core' +import type { MentionNodeAttrs } from '@tiptap/extension-mention' +import { posToDOMRect, ReactRenderer } from '@tiptap/react' +import type { SuggestionOptions } from '@tiptap/suggestion' +import type { LucideIcon } from 'lucide-react' +import { + Bold, + Calculator, + CheckCircle, + Code, + FileCode, + Heading1, + Heading2, + Heading3, + Image, + Italic, + Link, + List, + ListOrdered, + Minus, + Omega, + Quote, + Redo, + Strikethrough, + Table, + Type, + Underline, + Undo, + X +} from 'lucide-react' + +import CommandListPopover from './CommandListPopover' + +const logger = loggerService.withContext('RichEditor.Command') + +export interface Command { + id: string + title: string + description: string + category: CommandCategory + icon: LucideIcon + keywords: string[] + handler: (editor: Editor) => void + isAvailable?: (editor: Editor) => boolean + // Toolbar support + showInToolbar?: boolean + toolbarGroup?: 'text' | 'formatting' | 'blocks' | 'media' | 'structure' | 'history' + formattingCommand?: string // Maps to FormattingCommand for state checking +} + +export enum CommandCategory { + TEXT = 'text', + LISTS = 'lists', + BLOCKS = 'blocks', + MEDIA = 'media', + STRUCTURE = 'structure', + SPECIAL = 'special' +} + +export interface CommandSuggestion { + query: string + range: any + clientRect?: () => DOMRect | null +} + +// Internal dynamic command registry +const commandRegistry = new Map() + +export function registerCommand(cmd: Command): void { + commandRegistry.set(cmd.id, cmd) +} + +export function unregisterCommand(id: string): void { + commandRegistry.delete(id) +} + +export function getCommand(id: string): Command | undefined { + return commandRegistry.get(id) +} + +export function getAllCommands(): Command[] { + return Array.from(commandRegistry.values()) +} + +export function getToolbarCommands(): Command[] { + return getAllCommands().filter((cmd) => cmd.showInToolbar) +} + +export function getCommandsByGroup(group: string): Command[] { + return getAllCommands().filter((cmd) => cmd.toolbarGroup === group) +} + +// Dynamic toolbar management +export function registerToolbarCommand(cmd: Command): void { + if (!cmd.showInToolbar) { + cmd.showInToolbar = true + } + registerCommand(cmd) +} + +export function unregisterToolbarCommand(id: string): void { + const cmd = getCommand(id) + if (cmd) { + cmd.showInToolbar = false + // Keep command for slash menu, just hide from toolbar + } +} + +export function setCommandAvailability(id: string, isAvailable: (editor: Editor) => boolean): void { + const cmd = getCommand(id) + if (cmd) { + cmd.isAvailable = isAvailable + } +} + +// Convenience functions for common scenarios +export function disableCommandsWhen(commandIds: string[], condition: (editor: Editor) => boolean): void { + commandIds.forEach((id) => { + setCommandAvailability(id, (editor) => !condition(editor)) + }) +} + +export function hideToolbarCommandsWhen(commandIds: string[], condition: () => boolean): void { + if (condition()) { + commandIds.forEach((id) => unregisterToolbarCommand(id)) + } else { + commandIds.forEach((id) => { + const cmd = getCommand(id) + if (cmd) { + cmd.showInToolbar = true + } + }) + } +} + +// Default command definitions +const DEFAULT_COMMANDS: Command[] = [ + { + id: 'bold', + title: 'Bold', + description: 'Make text bold', + category: CommandCategory.TEXT, + icon: Bold, + keywords: ['bold', 'strong', 'b'], + handler: (editor: Editor) => { + editor.chain().focus().toggleBold().run() + }, + showInToolbar: true, + toolbarGroup: 'formatting', + formattingCommand: 'bold' + }, + { + id: 'italic', + title: 'Italic', + description: 'Make text italic', + category: CommandCategory.TEXT, + icon: Italic, + keywords: ['italic', 'emphasis', 'i'], + handler: (editor: Editor) => { + editor.chain().focus().toggleItalic().run() + }, + showInToolbar: true, + toolbarGroup: 'formatting', + formattingCommand: 'italic' + }, + { + id: 'underline', + title: 'Underline', + description: 'Underline text', + category: CommandCategory.TEXT, + icon: Underline, + keywords: ['underline', 'u'], + handler: (editor: Editor) => { + editor.chain().focus().toggleUnderline().run() + }, + showInToolbar: true, + toolbarGroup: 'formatting', + formattingCommand: 'underline' + }, + { + id: 'strike', + title: 'Strikethrough', + description: 'Strike through text', + category: CommandCategory.TEXT, + icon: Strikethrough, + keywords: ['strikethrough', 'strike', 's'], + handler: (editor: Editor) => { + editor.chain().focus().toggleStrike().run() + }, + showInToolbar: true, + toolbarGroup: 'formatting', + formattingCommand: 'strike' + }, + { + id: 'inlineCode', + title: 'Inline Code', + description: 'Add inline code', + category: CommandCategory.SPECIAL, + icon: Code, + keywords: ['code', 'inline', 'monospace'], + handler: (editor: Editor) => { + editor.chain().focus().toggleCode().run() + }, + showInToolbar: true, + toolbarGroup: 'formatting', + formattingCommand: 'code' + }, + { + id: 'paragraph', + title: 'Text', + description: 'Start writing with plain text', + category: CommandCategory.TEXT, + icon: Type, + keywords: ['text', 'paragraph', 'p'], + handler: (editor: Editor) => { + editor.chain().focus().setParagraph().run() + }, + showInToolbar: true, + toolbarGroup: 'text', + formattingCommand: 'paragraph' + }, + { + id: 'heading1', + title: 'Heading 1', + description: 'Big section heading', + category: CommandCategory.TEXT, + icon: Heading1, + keywords: ['heading', 'h1', 'title', 'big'], + handler: (editor: Editor) => { + editor.chain().focus().toggleHeading({ level: 1 }).run() + }, + showInToolbar: true, + toolbarGroup: 'text', + formattingCommand: 'heading1' + }, + { + id: 'heading2', + title: 'Heading 2', + description: 'Medium section heading', + category: CommandCategory.TEXT, + icon: Heading2, + keywords: ['heading', 'h2', 'subtitle', 'medium'], + handler: (editor: Editor) => { + editor.chain().focus().toggleHeading({ level: 2 }).run() + }, + showInToolbar: true, + toolbarGroup: 'text', + formattingCommand: 'heading2' + }, + { + id: 'heading3', + title: 'Heading 3', + description: 'Small section heading', + category: CommandCategory.TEXT, + icon: Heading3, + keywords: ['heading', 'h3', 'small'], + handler: (editor: Editor) => { + editor.chain().focus().toggleHeading({ level: 3 }).run() + }, + showInToolbar: true, + toolbarGroup: 'text', + formattingCommand: 'heading3' + }, + { + id: 'bulletList', + title: 'Bulleted list', + description: 'Create a simple bulleted list', + category: CommandCategory.LISTS, + icon: List, + keywords: ['bullet', 'list', 'ul', 'unordered'], + handler: (editor: Editor) => { + editor.chain().focus().toggleBulletList().run() + }, + showInToolbar: true, + toolbarGroup: 'blocks', + formattingCommand: 'bulletList' + }, + { + id: 'orderedList', + title: 'Numbered list', + description: 'Create a list with numbering', + category: CommandCategory.LISTS, + icon: ListOrdered, + keywords: ['number', 'list', 'ol', 'ordered'], + handler: (editor: Editor) => { + editor.chain().focus().toggleOrderedList().run() + }, + showInToolbar: true, + toolbarGroup: 'blocks', + formattingCommand: 'orderedList' + }, + { + id: 'codeBlock', + title: 'Code', + description: 'Capture a code snippet', + category: CommandCategory.BLOCKS, + icon: FileCode, + keywords: ['code', 'block', 'snippet', 'programming'], + handler: (editor: Editor) => { + editor.chain().focus().toggleCodeBlock().run() + }, + showInToolbar: true, + toolbarGroup: 'blocks', + formattingCommand: 'codeBlock' + }, + { + id: 'blockquote', + title: 'Quote', + description: 'Capture a quote', + category: CommandCategory.BLOCKS, + icon: Quote, + keywords: ['quote', 'blockquote', 'citation'], + handler: (editor: Editor) => { + editor.chain().focus().toggleBlockquote().run() + }, + showInToolbar: true, + toolbarGroup: 'blocks', + formattingCommand: 'blockquote' + }, + { + id: 'divider', + title: 'Divider', + description: 'Add a horizontal line', + category: CommandCategory.STRUCTURE, + icon: Minus, + keywords: ['divider', 'hr', 'line', 'separator'], + handler: (editor: Editor) => { + editor.chain().focus().setHorizontalRule().run() + } + }, + { + id: 'image', + title: 'Image', + description: 'Insert an image', + category: CommandCategory.MEDIA, + icon: Image, + keywords: ['image', 'img', 'picture', 'photo'], + handler: (editor: Editor) => { + editor.chain().focus().insertImagePlaceholder().run() + }, + showInToolbar: true, + toolbarGroup: 'media', + formattingCommand: 'image' + }, + { + id: 'link', + title: 'Link', + description: 'Add a link', + category: CommandCategory.SPECIAL, + icon: Link, + keywords: ['link', 'url', 'href'], + handler: (editor: Editor) => { + editor.chain().focus().setEnhancedLink({ href: '' }).run() + }, + showInToolbar: true, + toolbarGroup: 'media', + formattingCommand: 'link' + }, + { + id: 'table', + title: 'Table', + description: 'Insert a table', + category: CommandCategory.STRUCTURE, + icon: Table, + keywords: ['table', 'grid', 'rows', 'columns'], + handler: (editor: Editor) => { + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() + }, + showInToolbar: true, + toolbarGroup: 'structure', + formattingCommand: 'table' + }, + // Additional commands for slash menu only + { + id: 'taskList', + title: 'Task List', + description: 'Create a checklist', + category: CommandCategory.LISTS, + icon: CheckCircle, + keywords: ['task', 'todo', 'checklist', 'checkbox'], + handler: (editor: Editor) => { + editor.chain().focus().toggleTaskList().run() + }, + showInToolbar: true, + toolbarGroup: 'blocks', + formattingCommand: 'taskList' + }, + { + id: 'hardBreak', + title: 'Line Break', + description: 'Insert a line break', + category: CommandCategory.STRUCTURE, + icon: X, + keywords: ['break', 'br', 'newline'], + handler: (editor: Editor) => { + editor.chain().focus().setHardBreak().run() + } + }, + { + id: 'inlineMath', + title: 'Inline Equation', + description: 'Insert inline equation', + category: CommandCategory.BLOCKS, + icon: Omega, + keywords: ['inline', 'math', 'formula', 'equation', 'latex'], + handler: (editor: Editor) => { + editor.chain().focus().insertMathPlaceholder({ mathType: 'inline' }).run() + }, + showInToolbar: true, + toolbarGroup: 'blocks', + formattingCommand: 'inlineMath' + }, + { + id: 'blockMath', + title: 'Math Formula', + description: 'Insert mathematical formula', + category: CommandCategory.BLOCKS, + icon: Calculator, + keywords: ['math', 'formula', 'equation', 'latex'], + handler: (editor: Editor) => { + editor.chain().focus().insertMathPlaceholder({ mathType: 'block' }).run() + }, + showInToolbar: true, + toolbarGroup: 'blocks', + formattingCommand: 'blockMath' + }, + // History commands + { + id: 'undo', + title: 'Undo', + description: 'Undo last action', + category: CommandCategory.SPECIAL, + icon: Undo, + keywords: ['undo', 'revert'], + handler: (editor: Editor) => { + editor.chain().focus().undo().run() + }, + showInToolbar: true, + toolbarGroup: 'history', + formattingCommand: 'undo' + }, + { + id: 'redo', + title: 'Redo', + description: 'Redo last action', + category: CommandCategory.SPECIAL, + icon: Redo, + keywords: ['redo', 'repeat'], + handler: (editor: Editor) => { + editor.chain().focus().redo().run() + }, + showInToolbar: true, + toolbarGroup: 'history', + formattingCommand: 'redo' + } +] + +export interface CommandFilterOptions { + query?: string + category?: CommandCategory + maxResults?: number +} + +// Filter commands based on search query and category +export function filterCommands(options: CommandFilterOptions = {}): Command[] { + const { query = '', category } = options + + let filtered = getAllCommands() + + // Filter by category if specified + if (category) { + filtered = filtered.filter((cmd) => cmd.category === category) + } + + // Filter by search query + if (query) { + const searchTerm = query.toLowerCase().trim() + filtered = filtered.filter((cmd) => { + const searchableText = [cmd.title, cmd.description, ...cmd.keywords].join(' ').toLowerCase() + + return searchableText.includes(searchTerm) + }) + + // Sort by relevance (exact matches first, then title matches, then keyword matches) + filtered.sort((a, b) => { + const aTitle = a.title.toLowerCase() + const bTitle = b.title.toLowerCase() + const aExactMatch = aTitle === searchTerm + const bExactMatch = bTitle === searchTerm + const aTitleMatch = aTitle.includes(searchTerm) + const bTitleMatch = bTitle.includes(searchTerm) + + if (aExactMatch && !bExactMatch) return -1 + if (bExactMatch && !aExactMatch) return 1 + if (aTitleMatch && !bTitleMatch) return -1 + if (bTitleMatch && !aTitleMatch) return 1 + + return a.title.localeCompare(b.title) + }) + } + + return filtered +} + +const updatePosition = (editor: Editor, element: HTMLElement) => { + const virtualElement = { + getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to) + } + + computePosition(virtualElement, element, { + placement: 'bottom-start', + strategy: 'fixed', + middleware: [ + offset(4), // Add small offset from trigger + flip({ + fallbackPlacements: ['top-start', 'bottom-end', 'top-end', 'bottom-start'], + padding: 8 // Ensure some padding from viewport edges + }), + shift({ + padding: 8 // Prevent overflow on sides + }), + size({ + apply({ availableWidth, availableHeight, elements }) { + // Ensure the popover doesn't exceed viewport bounds + const maxHeight = Math.min(400, availableHeight - 16) // 16px total padding + const maxWidth = Math.min(320, availableWidth - 16) + + Object.assign(elements.floating.style, { + maxHeight: `${maxHeight}px`, + maxWidth: `${maxWidth}px`, + minWidth: '240px' + }) + } + }) + ] + }) + .then(({ x, y, strategy, placement }) => { + Object.assign(element.style, { + position: strategy, + left: `${x}px`, + top: `${y}px`, + width: 'max-content' + }) + + // Add data attribute to track current placement for styling + element.setAttribute('data-placement', placement) + }) + .catch((error) => { + logger.error('Error positioning command list:', error) + }) +} + +// Register default commands into the dynamic registry +DEFAULT_COMMANDS.forEach(registerCommand) + +// TipTap suggestion configuration +export const commandSuggestion: Omit, 'editor'> = { + char: '/', + startOfLine: true, + items: ({ query }: { query: string }) => { + try { + return filterCommands({ query }) + } catch (error) { + logger.error('Error filtering commands:', error as Error) + return [] + } + }, + command: ({ editor, range, props }) => { + editor.chain().focus().deleteRange(range).run() + + // Find the original command by id + if (props.id) { + const command = getCommand(props.id) + if (command) { + command.handler(editor) + } + } + }, + + render: () => { + let component: ReactRenderer + let cleanup: (() => void) | undefined + + return { + onStart: (props) => { + if (!props?.items || !props?.clientRect) { + logger.warn('Invalid props in command suggestion onStart') + return + } + + component = new ReactRenderer(CommandListPopover, { + props, + editor: props.editor + }) + const element = component.element as HTMLElement + // element.style.position = 'absolute' + element.style.zIndex = '1001' + + document.body.appendChild(element) + + // Set up auto-updating position that responds to scroll and resize + const virtualElement = { + getBoundingClientRect: () => + posToDOMRect(props.editor.view, props.editor.state.selection.from, props.editor.state.selection.to) + } + + cleanup = autoUpdate(virtualElement, element, () => { + updatePosition(props.editor, element) + }) + + // Initial position update + updatePosition(props.editor, element) + }, + + onUpdate: (props) => { + if (!props?.items || !props.clientRect) return + + component.updateProps(props) + + // Update position when items change (might affect size) + if (component.element) { + setTimeout(() => { + updatePosition(props.editor, component.element as HTMLElement) + }, 0) + } + }, + + onKeyDown: (props) => { + if (props.event.key === 'Escape') { + if (cleanup) cleanup() + component.destroy() + return true + } + + return component.ref?.onKeyDown(props.event) + }, + + onExit: () => { + if (cleanup) cleanup() + const element = component.element as HTMLElement + element.remove() + component.destroy() + } + } + } +} diff --git a/src/renderer/src/components/RichEditor/components/ActionMenu.tsx b/src/renderer/src/components/RichEditor/components/ActionMenu.tsx new file mode 100644 index 0000000000..5f35799da6 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/ActionMenu.tsx @@ -0,0 +1,84 @@ +import { Menu } from 'antd' +import React, { FC, useCallback, useEffect, useMemo, useRef } from 'react' +import { createPortal } from 'react-dom' + +export interface ActionMenuItem { + key: string + label: React.ReactNode + icon?: React.ReactNode + danger?: boolean + onClick: () => void +} + +export interface ActionMenuProps { + show: boolean + position: { x: number; y: number } + items: ActionMenuItem[] + onClose: () => void + minWidth?: number +} + +export const ActionMenu: FC = ({ show, position, items, onClose, minWidth = 168 }) => { + const ref = useRef(null) + + useEffect(() => { + if (!show) return + const onDocMouseDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + onClose() + } + } + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('mousedown', onDocMouseDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('mousedown', onDocMouseDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [show, onClose]) + + const menuItems = useMemo( + () => + items.map((it) => ({ + key: it.key, + label: it.label, + icon: it.icon, + danger: it.danger + })), + [items] + ) + + const onMenuClick = useCallback( + ({ key }: { key: string }) => { + const found = items.find((i) => i.key === key) + if (found) found.onClick() + onClose() + }, + [items, onClose] + ) + + if (!show) return null + + const node = ( +
+ +
+ ) + + return createPortal(node, document.body) +} diff --git a/src/renderer/src/components/RichEditor/components/ImageUploader.tsx b/src/renderer/src/components/RichEditor/components/ImageUploader.tsx new file mode 100644 index 0000000000..02a9b73885 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/ImageUploader.tsx @@ -0,0 +1,206 @@ +import { InboxOutlined, LinkOutlined, LoadingOutlined, UploadOutlined } from '@ant-design/icons' +import { Button, Flex, Input, message, Modal, Spin, Tabs, Upload } from 'antd' + +const { Dragger } = Upload +import type { RcFile } from 'antd/es/upload' +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface ImageUploaderProps { + /** Callback when image is selected/uploaded */ + onImageSelect: (imageUrl: string) => void + /** Whether the uploader is visible */ + visible: boolean + /** Callback when uploader should be closed */ + onClose: () => void +} + +const TabContent = styled.div` + padding: 24px 0; + display: flex; + flex-direction: column; +` + +const UrlInput = styled(Input)` + .ant-input { + padding: 12px 16px + font-size: 14px + border-radius: 4px + border: 1px solid #dadce0 + transition: all 0.2s ease + background: #ffffff + + &:hover { + border-color: #4285f4 + } + + &:focus { + border-color: #4285f4 + box-shadow: 0 0 0 1px rgba(66, 133, 244, 0.3) + } + } +` + +// Function to convert file to base64 URL +const convertFileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result) + } else { + reject(new Error('Failed to convert file to base64')) + } + } + reader.onerror = () => reject(new Error('Failed to read file')) + reader.readAsDataURL(file) + }) +} + +export const ImageUploader: React.FC = ({ onImageSelect, visible, onClose }) => { + const { t } = useTranslation() + const [urlInput, setUrlInput] = useState('') + const [loading, setLoading] = useState(false) + + const handleFileSelect = async (file: RcFile) => { + try { + setLoading(true) + + // Validate file type + const isImage = file.type.startsWith('image/') + if (!isImage) { + message.error(t('richEditor.imageUploader.invalidType')) + return false + } + + // Validate file size (max 10MB) + const isLt10M = file.size / 1024 / 1024 < 10 + if (!isLt10M) { + message.error(t('richEditor.imageUploader.tooLarge')) + return false + } + + // Convert to base64 and call callback + const base64Url = await convertFileToBase64(file) + onImageSelect(base64Url) + message.success(t('richEditor.imageUploader.uploadSuccess')) + onClose() + } catch (error) { + message.error(t('richEditor.imageUploader.uploadError')) + } finally { + setLoading(false) + } + + return false // Prevent default upload + } + + const handleUrlSubmit = () => { + if (!urlInput.trim()) { + message.error(t('richEditor.imageUploader.urlRequired')) + return + } + + // Basic URL validation + try { + new URL(urlInput.trim()) + onImageSelect(urlInput.trim()) + message.success(t('richEditor.imageUploader.embedSuccess')) + setUrlInput('') + onClose() + } catch { + message.error(t('richEditor.imageUploader.invalidUrl')) + } + } + + const handleCancel = () => { + setUrlInput('') + onClose() + } + + const tabItems = [ + { + key: 'upload', + label: ( +
+ + {t('richEditor.imageUploader.upload')} +
+ ), + children: ( + + {}} // Prevent default upload + disabled={loading}> + {loading ? ( + <> + } /> +

{t('richEditor.imageUploader.uploading')}

+

{t('richEditor.imageUploader.processing')}

+ + ) : ( + <> +

+ +

+

{t('richEditor.imageUploader.uploadText')}

+

{t('richEditor.imageUploader.uploadHint')}

+ + )} +
+
+ ) + }, + { + key: 'url', + label: ( + + + {t('richEditor.imageUploader.embedLink')} + + ), + children: ( + + + setUrlInput(e.target.value)} + onPressEnter={handleUrlSubmit} + prefix={} + style={{ flex: 1 }} + /> + + + + + ) + } + ] + + return ( + + + + ) +} diff --git a/src/renderer/src/components/RichEditor/components/LinkEditor.tsx b/src/renderer/src/components/RichEditor/components/LinkEditor.tsx new file mode 100644 index 0000000000..74a6a149d9 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/LinkEditor.tsx @@ -0,0 +1,166 @@ +import { useTheme } from '@renderer/context/ThemeProvider' +import { Button, Flex, Input } from 'antd' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface LinkEditorProps { + /** Whether the editor is visible */ + visible: boolean + /** Position for the popup */ + position: { x: number; y: number } | null + /** Link attributes */ + link: { href: string; text: string } + /** Callback when the user saves the link */ + onSave: (href: string, text: string) => void + /** Callback when the user removes the link */ + onRemove: () => void + /** Callback when the editor is closed without saving */ + onCancel: () => void + /** Whether to show remove button */ + showRemove?: boolean +} + +/** + * Inline link editor that appears on hover over links + * Provides input fields for editing link URL and title + */ +const LinkEditor: React.FC = ({ + visible, + position, + link, + onSave, + onRemove, + onCancel, + showRemove = true +}) => { + const { t } = useTranslation() + const { theme } = useTheme() + const [href, setHref] = useState(link.href || '') + const [text, setText] = useState(link.text || '') + const containerRef = useRef(null) + const hrefInputRef = useRef(null) + + // Reset values when link changes + useEffect(() => { + if (visible) { + setHref(link.href || '') + setText(link.text || '') + } + }, [visible, link.href, link.text]) + + // Auto-focus href input when dialog opens + useEffect(() => { + if (visible && hrefInputRef.current) { + setTimeout(() => { + hrefInputRef.current?.focus() + }, 100) + } + }, [visible]) + + // Handle clicks outside to close + useEffect(() => { + if (!visible) return + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement + + // Don't close if clicking within the editor or on a link + if (containerRef.current?.contains(target) || target.closest('a[href]') || target.closest('[data-link-editor]')) { + return + } + + onCancel() + } + + setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside) + }, 100) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [visible, onCancel]) + + const handleSave = useCallback(() => { + const trimmedHref = href.trim() + const trimmedText = text.trim() + if (trimmedHref && trimmedText) { + onSave(trimmedHref, trimmedText) + } + }, [href, text, onSave]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + handleSave() + } else if (e.key === 'Escape') { + e.preventDefault() + onCancel() + } + }, + [handleSave, onCancel] + ) + + if (!visible || !position) return null + + // Theme-aware styles + const isDark = theme === 'dark' + const styles: React.CSSProperties = { + position: 'fixed', + left: position.x, + top: position.y + 25, // Position slightly below the link + zIndex: 1000, + background: isDark ? 'var(--color-background-soft, #222222)' : 'white', + border: `1px solid ${isDark ? 'var(--color-border, #ffffff19)' : '#d9d9d9'}`, + borderRadius: 8, + boxShadow: isDark ? '0 4px 12px rgba(0, 0, 0, 0.3)' : '0 4px 12px rgba(0,0,0,0.15)', + padding: 12, + width: 320, + maxWidth: '90vw' + } + + return ( +
+
+ + setText(e.target.value)} + size="small" + /> +
+ +
+ + setHref(e.target.value)} size="small" /> +
+ + +
+ {showRemove && ( + + )} +
+ + + + +
+
+ ) +} + +export default LinkEditor diff --git a/src/renderer/src/components/RichEditor/components/MathInputDialog.tsx b/src/renderer/src/components/RichEditor/components/MathInputDialog.tsx new file mode 100644 index 0000000000..93a864a3a6 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/MathInputDialog.tsx @@ -0,0 +1,161 @@ +import { useTheme } from '@renderer/context/ThemeProvider' +import { Button, Flex, Input } from 'antd' +import React, { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface MathInputDialogProps { + /** Whether the dialog is visible */ + visible: boolean + /** Callback when the user confirms the formula */ + onSubmit: (formula: string) => void + /** Callback when the dialog is closed without submitting */ + onCancel: () => void + /** Initial LaTeX value */ + defaultValue?: string + /** Callback for real-time formula updates */ + onFormulaChange?: (formula: string) => void + /** Position relative to target element */ + position?: { x: number; y: number; top?: number } + /** Scroll container reference to prevent scrolling */ + scrollContainer?: React.RefObject +} + +/** + * Simple inline dialog for entering LaTeX formula. + * Renders a small floating box (similar to the screenshot provided by the user) + * with a multi-line input and a confirm button. + */ +const MathInputDialog: React.FC = ({ + visible, + onSubmit, + onCancel, + defaultValue = '', + onFormulaChange, + position, + scrollContainer +}) => { + const { t } = useTranslation() + const { theme } = useTheme() + const [value, setValue] = useState(defaultValue) + const containerRef = useRef(null) + + useEffect(() => { + if (visible) { + setValue(defaultValue) + } + }, [visible, defaultValue]) + + // Prevent scroll container scrolling when dialog is open + useEffect(() => { + if (visible && scrollContainer?.current) { + const scrollElement = scrollContainer.current + const originalOverflow = scrollElement.style.overflow + const originalScrollbarGutter = scrollElement.style.scrollbarGutter + + scrollElement.style.overflow = 'hidden' + scrollElement.style.scrollbarGutter = 'stable' + + return () => { + if (scrollElement) { + scrollElement.style.overflow = originalOverflow + scrollElement.style.scrollbarGutter = originalScrollbarGutter + } + } + } + return + }, [visible, scrollContainer]) + + useEffect(() => { + if (visible && containerRef.current) { + const textarea = containerRef.current.querySelector('textarea') as HTMLTextAreaElement | null + if (textarea) { + textarea.focus() + // Position cursor at the end of the text + const length = textarea.value.length + textarea.setSelectionRange(length, length) + } + } + }, [visible]) + + if (!visible) return null + + const handleSubmit = () => { + const trimmed = value.trim() + if (trimmed) { + onSubmit(trimmed) + } + } + + const handleKeyDown: React.KeyboardEventHandler = (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + handleSubmit() + } + } + + const isDark = theme === 'dark' + + const getPositionStyles = (): React.CSSProperties => { + if (position) { + const dialogHeight = 200 + const spaceBelow = window.innerHeight - position.y + const spaceAbove = position.y + + const showAbove = spaceBelow < dialogHeight + 20 && spaceAbove > dialogHeight + 20 + + return { + position: 'fixed', + // When showing above, use the element's top position for accurate placement + top: showAbove ? 'auto' : position.y + 10, + bottom: showAbove ? window.innerHeight - (position.top || position.y) + 10 : 'auto', + left: position.x, + transform: 'translateX(-50%)', + zIndex: 1000 + } + } + + return { + position: 'fixed', + top: '50%', + left: '50%', + zIndex: 1000 + } + } + + const styles: React.CSSProperties = { + ...getPositionStyles(), + background: isDark ? 'var(--color-background-soft, #222222)' : 'white', + border: `1px solid ${isDark ? 'var(--color-border, #ffffff19)' : '#d9d9d9'}`, + borderRadius: 8, + boxShadow: isDark ? '0 4px 12px rgba(0, 0, 0, 0.3)' : '0 4px 12px rgba(0,0,0,0.15)', + padding: 16, + width: 360, + maxWidth: '90vw' + } + + return ( +
+ { + const newValue = e.target.value + setValue(newValue) + onFormulaChange?.(newValue) + }} + onKeyDown={handleKeyDown} + style={{ marginBottom: 12, fontFamily: 'monospace' }} + /> + + + + +
+ ) +} + +export default MathInputDialog diff --git a/src/renderer/src/components/RichEditor/components/PlusButton.tsx b/src/renderer/src/components/RichEditor/components/PlusButton.tsx new file mode 100644 index 0000000000..b1ac0b9030 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/PlusButton.tsx @@ -0,0 +1,79 @@ +import type { Plugin } from '@tiptap/pm/state' +import type { Editor } from '@tiptap/react' +import React from 'react' +import { type ReactNode, useEffect, useRef, useState } from 'react' + +import { defaultComputePositionConfig } from '../extensions/plus-button' +import { PlusButtonPlugin, plusButtonPluginDefaultKey, PlusButtonPluginOptions } from '../plugins/plusButtonPlugin' + +type Optional = Pick, K> & Omit + +export type PlusButtonProps = Omit, 'element'> & { + className?: string + onNodeChange?: (data: { node: Node | null; editor: Editor; pos: number }) => void + children: ReactNode +} + +export const PlusButton: React.FC = (props: PlusButtonProps) => { + const { + className = 'plus-button', + children, + editor, + pluginKey = plusButtonPluginDefaultKey, + onNodeChange, + onElementClick, + computePositionConfig = defaultComputePositionConfig + } = props + const [element, setElement] = useState(null) + const plugin = useRef(null) + useEffect(() => { + let initPlugin: { + plugin: Plugin + unbind: () => void + } | null = null + + if (!element) { + return () => { + plugin.current = null + } + } + + if (editor.isDestroyed) { + return () => { + plugin.current = null + } + } + + if (!plugin.current) { + initPlugin = PlusButtonPlugin({ + editor, + element, + pluginKey, + computePositionConfig: { + ...defaultComputePositionConfig, + ...computePositionConfig + }, + onElementClick, + onNodeChange + }) + plugin.current = initPlugin.plugin + + editor.registerPlugin(plugin.current) + } + return () => { + editor.unregisterPlugin(pluginKey) + plugin.current = null + if (initPlugin) { + initPlugin.unbind() + initPlugin = null + } + } + }, [computePositionConfig, editor, element, onElementClick, onNodeChange, pluginKey]) + return ( +
+ {children} +
+ ) +} + +export default PlusButton diff --git a/src/renderer/src/components/RichEditor/components/TableActionMenu.tsx b/src/renderer/src/components/RichEditor/components/TableActionMenu.tsx new file mode 100644 index 0000000000..bc0282aac0 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/TableActionMenu.tsx @@ -0,0 +1,111 @@ +import { FC, useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +export interface TableAction { + label: string + action: () => void + icon?: string +} + +export interface TableActionMenuProps { + show: boolean + position?: { x: number; y: number } + actions: TableAction[] + onClose: () => void +} + +export const TableActionMenu: FC = ({ show, position, actions, onClose }) => { + const menuRef = useRef(null) + const [menuPosition, setMenuPosition] = useState(position || { x: 0, y: 0 }) + + useEffect(() => { + if (show && position) { + setMenuPosition(position) + } + }, [show, position]) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose() + } + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + + if (show) { + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleEscape) + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleEscape) + } + }, [show, onClose]) + + const handleActionClick = useCallback( + (action: TableAction) => { + action.action() + onClose() + }, + [onClose] + ) + + if (!show) return null + + const menu = ( +
+ {actions.map((action, index) => ( + + ))} +
+ ) + + return createPortal(menu, document.body) +} diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/DragContextMenu.tsx b/src/renderer/src/components/RichEditor/components/dragContextMenu/DragContextMenu.tsx new file mode 100644 index 0000000000..6a17d4cdf5 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/DragContextMenu.tsx @@ -0,0 +1,240 @@ +import { loggerService } from '@logger' +import React, { useCallback, useMemo, useRef } from 'react' +import { createPortal } from 'react-dom' + +import { useMenuActionVisibility } from './hooks/useMenuActionVisibility' +import { + EmptyState, + MenuContainer, + MenuDivider, + MenuGroup, + MenuGroupTitle, + MenuItem, + MenuItemIcon, + MenuItemLabel, + MenuItemShortcut +} from './styles' +import type { ActionGroup, DragContextMenuProps, MenuAction } from './types' + +const logger = loggerService.withContext('DragContextMenu') + +/** + * 操作组显示名称映射 + */ +const GROUP_LABELS: Record = { + transform: 'Transform', + format: 'Format', + block: 'Actions', + insert: 'Insert', + ai: 'AI' +} + +/** + * 操作组显示顺序 + */ +const GROUP_ORDER: ActionGroup[] = [ + 'transform' as ActionGroup, + 'format' as ActionGroup, + 'insert' as ActionGroup, + 'block' as ActionGroup, + 'ai' as ActionGroup +] + +/** + * 拖拽上下文菜单主组件 + */ +const DragContextMenu: React.FC = ({ + editor, + node, + position, + visible, + menuPosition, + onClose, + customActions = [], + disabledActions = [] +}) => { + const menuRef = useRef(null) + + // 获取菜单操作可见性 + const { visibleActions } = useMenuActionVisibility({ + editor, + node, + position + }) + + /** + * 合并自定义操作 + */ + const allActions = useMemo(() => { + const actions = [...visibleActions, ...customActions] + // 过滤被禁用的操作 + return actions.filter((action) => !disabledActions.includes(action.id)) + }, [visibleActions, customActions, disabledActions]) + + /** + * 按组分类的最终操作列表 + */ + const finalActionsByGroup = useMemo(() => { + const grouped: Record = { + transform: [], + format: [], + block: [], + insert: [], + ai: [] + } + + allActions.forEach((action) => { + if (grouped[action.group]) { + grouped[action.group].push(action) + } + }) + + return grouped + }, [allActions]) + + /** + * 处理菜单项点击 + */ + const handleMenuItemClick = useCallback( + async (action: MenuAction) => { + try { + logger.debug('Menu item clicked', { + actionId: action.id, + nodeType: node.type.name, + position + }) + + // 执行操作 + action.execute(editor, node, position) + + // 关闭菜单 + onClose() + + logger.debug('Menu action executed successfully', { actionId: action.id }) + } catch (error) { + logger.error('Failed to execute menu action', error as Error, { + actionId: action.id, + nodeType: node.type.name + }) + + // 即使失败也关闭菜单 + onClose() + } + }, + [editor, node, position, onClose] + ) + + /** + * 处理键盘导航 + */ + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + switch (event.key) { + case 'Escape': + event.preventDefault() + onClose() + break + case 'ArrowDown': + event.preventDefault() + // TODO: 实现键盘导航 + break + case 'ArrowUp': + event.preventDefault() + // TODO: 实现键盘导航 + break + case 'Enter': + event.preventDefault() + // TODO: 执行选中的操作 + break + } + }, + [onClose] + ) + + /** + * 渲染菜单组 + */ + const renderMenuGroup = useCallback( + (group: ActionGroup, actions: MenuAction[]) => { + if (actions.length === 0) return null + + return ( + + {GROUP_LABELS[group]} + {actions.map((action) => ( + handleMenuItemClick(action)} + disabled={!action.isEnabled(editor, node, position)} + title={action.shortcut ? `${action.label} (${action.shortcut})` : action.label}> + {action.icon && {action.icon}} + {action.label} + {action.shortcut && {action.shortcut}} + + ))} + + ) + }, + [editor, node, position, handleMenuItemClick] + ) + + // 如果菜单不可见,不渲染 + if (!visible) return null + + // 如果没有可用操作,显示空状态 + if (allActions.length === 0) { + const emptyMenu = ( + + No actions available for this block + + ) + + return createPortal(emptyMenu, document.body) + } + + // 渲染完整菜单 + const menu = ( + + {GROUP_ORDER.map((group, index) => { + const actions = finalActionsByGroup[group] + const groupElement = renderMenuGroup(group, actions) + + if (!groupElement) return null + + return ( + + {groupElement} + {/* 在组之间添加分隔线,除了最后一个组 */} + {index < GROUP_ORDER.length - 1 && + actions.length > 0 && + GROUP_ORDER.slice(index + 1).some((g) => finalActionsByGroup[g].length > 0) && } + + ) + })} + + ) + + return createPortal(menu, document.body) +} + +DragContextMenu.displayName = 'DragContextMenu' + +export default DragContextMenu diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/block.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/block.ts new file mode 100644 index 0000000000..85d3219d50 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/block.ts @@ -0,0 +1,113 @@ +import { loggerService } from '@logger' + +import type { ActionGroup, MenuAction } from '../types' + +const logger = loggerService.withContext('BlockActions') + +/** + * 块级操作集合 + */ +export const blockActions: MenuAction[] = [ + { + id: 'block-copy', + label: 'Copy to clipboard', + group: 'block' as ActionGroup, + isEnabled: () => true, // 总是可用 + execute: async (editor, node, pos) => { + try { + logger.debug('Copying block', { nodeType: node.type.name, pos }) + + // 获取节点的文本内容 + const text = node.textContent + + // 获取节点的 HTML 内容 + const htmlContent = editor.getHTML() + + // 尝试使用现代剪贴板 API + if (navigator.clipboard && window.ClipboardItem) { + const clipboardItem = new ClipboardItem({ + 'text/plain': new Blob([text], { type: 'text/plain' }), + 'text/html': new Blob([htmlContent], { type: 'text/html' }) + }) + + await navigator.clipboard.write([clipboardItem]) + logger.debug('Block copied to clipboard (modern API)') + } else if (navigator.clipboard) { + // 后备方案:只复制文本 + await navigator.clipboard.writeText(text) + logger.debug('Block text copied to clipboard') + } else { + // 最后的后备方案:使用传统的复制方法 + const textArea = document.createElement('textarea') + textArea.value = text + document.body.appendChild(textArea) + textArea.select() + document.execCommand('copy') + document.body.removeChild(textArea) + logger.debug('Block copied using legacy method') + } + } catch (error) { + logger.error('Failed to copy block', error as Error) + throw error + } + } + }, + + { + id: 'block-duplicate', + label: 'Duplicate node', + group: 'block' as ActionGroup, + isEnabled: () => true, + execute: (editor, node, pos) => { + try { + logger.debug('Duplicating block', { nodeType: node.type.name, pos }) + + // 计算插入位置(当前块之后) + const insertPos = pos + node.nodeSize + + // 获取节点的 JSON 表示 + const nodeJson = node.toJSON() + + // 在当前块后插入相同的节点 + editor.chain().focus().insertContentAt(insertPos, nodeJson).run() + + logger.debug('Block duplicated successfully') + } catch (error) { + logger.error('Failed to duplicate block', error as Error) + throw error + } + } + }, + + { + id: 'block-delete', + label: 'Delete', + group: 'block' as ActionGroup, + danger: true, + isEnabled: (editor, node) => { + // 检查是否是文档中唯一的块,如果是则不允许删除 + const doc = editor.state.doc + if (doc.childCount <= 1 && node.type.name === 'paragraph' && !node.textContent.trim()) { + return false // 不允许删除唯一的空段落 + } + return true + }, + execute: (editor, node, pos) => { + try { + logger.debug('Deleting block', { nodeType: node.type.name, pos }) + + // 计算删除范围 + const from = pos + const to = pos + node.nodeSize + + // 删除节点 + editor.chain().focus().deleteRange({ from, to }).run() + + logger.debug('Block deleted successfully') + } catch (error) { + logger.error('Failed to delete block', error as Error) + throw error + } + } + } +] diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/formatting.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/formatting.ts new file mode 100644 index 0000000000..13083131e8 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/formatting.ts @@ -0,0 +1,55 @@ +import { loggerService } from '@logger' + +import type { ActionGroup, MenuAction } from '../types' + +const logger = loggerService.withContext('FormattingActions') + +/** + * 格式化操作集合 + */ +export const formattingActions: MenuAction[] = [ + { + id: 'format-color', + label: 'Color', + group: 'format' as ActionGroup, + isEnabled: () => true, // 颜色选择总是可用 + execute: (_editor, node, pos) => { + try { + logger.debug('Color picker action - placeholder', { nodeType: node.type.name, pos }) + // TODO: 实现颜色选择器功能 + // 这里先提供一个占位实现 + } catch (error) { + logger.error('Failed to open color picker', error as Error) + throw error + } + } + }, + + { + id: 'format-reset', + label: 'Clear Formatting', + group: 'format' as ActionGroup, + isEnabled: (editor) => { + return editor.can().unsetAllMarks() + }, + execute: (editor, node, pos) => { + try { + logger.debug('Clearing formatting', { nodeType: node.type.name, pos }) + + // 选择整个节点内容 + const from = pos + 1 // 节点内容开始位置 + const to = pos + node.nodeSize - 1 // 节点内容结束位置 + + // 清除所有格式标记 + editor.chain().focus().setTextSelection({ from, to }).unsetAllMarks().run() + + logger.debug('Formatting cleared successfully') + } catch (error) { + logger.error('Failed to clear formatting', error as Error) + throw error + } + } + } + + // 注意:更多格式化操作可以在后续版本中添加 +] diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/index.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/index.ts new file mode 100644 index 0000000000..4ebba7722f --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/index.ts @@ -0,0 +1,53 @@ +/** + * 菜单操作集合 + * + * 导出所有可用的菜单操作,按类型分组 + */ + +// 操作定义 +export * from './block' +export * from './formatting' +export * from './insert' +export * from './transform' + +// 操作注册表 +import type { MenuAction } from '../types' +import { blockActions } from './block' +import { formattingActions } from './formatting' +import { insertActions } from './insert' +import { transformActions } from './transform' + +/** + * 所有可用操作的集合 + */ +export const allActions: MenuAction[] = [...transformActions, ...formattingActions, ...blockActions, ...insertActions] + +/** + * 根据 ID 获取操作 + */ +export function getActionById(id: string): MenuAction | undefined { + return allActions.find((action) => action.id === id) +} + +/** + * 获取默认启用的操作 ID 列表 + */ +export const defaultEnabledActions = [ + // Transform + 'transform-heading-1', + 'transform-heading-2', + 'transform-heading-3', + 'transform-paragraph', + 'transform-bullet-list', + 'transform-ordered-list', + 'transform-blockquote', + 'transform-code-block', + + // Block operations + 'block-duplicate', + 'block-copy', + 'block-delete', + + // Insert + 'insert-paragraph-after' +] diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/insert.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/insert.ts new file mode 100644 index 0000000000..38b1b78d1d --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/insert.ts @@ -0,0 +1,81 @@ +import { loggerService } from '@logger' +import { FileText, Plus } from 'lucide-react' +import React from 'react' + +import type { ActionGroup, MenuAction } from '../types' + +const logger = loggerService.withContext('InsertActions') + +/** + * 插入操作集合 + */ +export const insertActions: MenuAction[] = [ + { + id: 'insert-paragraph-after', + label: 'Add Paragraph Below', + icon: React.createElement(Plus, { size: 16 }), + group: 'insert' as ActionGroup, + shortcut: 'Enter', + isEnabled: () => true, + execute: (editor, node, pos) => { + try { + logger.debug('Inserting paragraph after block', { nodeType: node.type.name, pos }) + + // 计算插入位置(当前块之后) + const insertPos = pos + node.nodeSize + + // 插入新段落 + editor + .chain() + .focus() + .insertContentAt(insertPos, '

') + .focus(insertPos + 1) + .run() + + // 延迟触发命令菜单 - 这样用户可以通过 "/" 快速插入其他类型的块 + setTimeout(() => { + try { + editor.chain().insertContent('/').run() + logger.debug('Command menu triggered with "/"') + } catch (error) { + logger.warn('Failed to trigger command menu', error as Error) + } + }, 50) + + logger.debug('Paragraph inserted successfully') + } catch (error) { + logger.error('Failed to insert paragraph', error as Error) + throw error + } + } + }, + + { + id: 'insert-paragraph-before', + label: 'Add Paragraph Above', + icon: React.createElement(FileText, { size: 16 }), + group: 'insert' as ActionGroup, + isEnabled: () => true, + execute: (editor, node, pos) => { + try { + logger.debug('Inserting paragraph before block', { nodeType: node.type.name, pos }) + + // 插入位置就是当前块的开始位置 + const insertPos = pos + + // 插入新段落 + editor + .chain() + .focus() + .insertContentAt(insertPos, '

') + .focus(insertPos + 1) + .run() + + logger.debug('Paragraph inserted before block successfully') + } catch (error) { + logger.error('Failed to insert paragraph before block', error as Error) + throw error + } + } + } +] diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/transform.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/transform.ts new file mode 100644 index 0000000000..9ec34640ca --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/transform.ts @@ -0,0 +1,146 @@ +import { loggerService } from '@logger' + +import type { ActionGroup, MenuAction } from '../types' + +const logger = loggerService.withContext('TransformActions') + +/** + * 节点转换操作集合 + */ +export const transformActions: MenuAction[] = [ + { + id: 'transform-heading-1', + label: 'Heading 1', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().setHeading({ level: 1 }) + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to H1', { nodeType: node.type.name, pos }) + editor.chain().focus().setHeading({ level: 1 }).run() + } catch (error) { + logger.error('Failed to transform to H1', error as Error) + } + } + }, + + { + id: 'transform-heading-2', + label: 'Heading 2', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().setHeading({ level: 2 }) + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to H2', { nodeType: node.type.name, pos }) + editor.chain().focus().setHeading({ level: 2 }).run() + } catch (error) { + logger.error('Failed to transform to H2', error as Error) + } + } + }, + + { + id: 'transform-heading-3', + label: 'Heading 3', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().setHeading({ level: 3 }) + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to H3', { nodeType: node.type.name, pos }) + editor.chain().focus().setHeading({ level: 3 }).run() + } catch (error) { + logger.error('Failed to transform to H3', error as Error) + } + } + }, + + { + id: 'transform-paragraph', + label: 'Text', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return node.type.name === 'heading' && editor.can().setParagraph() + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to paragraph', { nodeType: node.type.name, pos }) + editor.chain().focus().setParagraph().run() + } catch (error) { + logger.error('Failed to transform to paragraph', error as Error) + } + } + }, + + { + id: 'transform-bullet-list', + label: 'Bulleted list', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().toggleBulletList() + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to bullet list', { nodeType: node.type.name, pos }) + editor.chain().focus().toggleBulletList().run() + } catch (error) { + logger.error('Failed to transform to bullet list', error as Error) + } + } + }, + + { + id: 'transform-ordered-list', + label: 'Numbered list', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().toggleOrderedList() + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to ordered list', { nodeType: node.type.name, pos }) + editor.chain().focus().toggleOrderedList().run() + } catch (error) { + logger.error('Failed to transform to ordered list', error as Error) + } + } + }, + + { + id: 'transform-blockquote', + label: 'Quote', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().toggleBlockquote() + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to blockquote', { nodeType: node.type.name, pos }) + editor.chain().focus().toggleBlockquote().run() + } catch (error) { + logger.error('Failed to transform to blockquote', error as Error) + } + } + }, + + { + id: 'transform-code-block', + label: 'Code', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().toggleCodeBlock() + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to code block', { nodeType: node.type.name, pos }) + editor.chain().focus().toggleCodeBlock().run() + } catch (error) { + logger.error('Failed to transform to code block', error as Error) + } + } + } +] diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useDragContextMenu.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useDragContextMenu.ts new file mode 100644 index 0000000000..b2bc68ed71 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useDragContextMenu.ts @@ -0,0 +1,279 @@ +import { loggerService } from '@logger' +import type { Editor } from '@tiptap/core' +import type { Node } from '@tiptap/pm/model' +import React, { useCallback, useRef, useState } from 'react' + +import type { EventHandlers, MenuAction, MenuActionResult, PositionOptions, UseDragContextMenuReturn } from '../types' + +const logger = loggerService.withContext('useDragContextMenu') + +interface UseDragContextMenuOptions { + /** 编辑器实例 */ + editor: Editor + /** 事件处理器 */ + eventHandlers?: EventHandlers + /** 位置计算选项 */ + positionOptions?: PositionOptions +} + +/** + * 拖拽上下文菜单核心逻辑 Hook + */ +export function useDragContextMenu({ + editor, + eventHandlers, + positionOptions +}: UseDragContextMenuOptions): UseDragContextMenuReturn { + // 菜单状态 + const [isMenuVisible, setIsMenuVisible] = useState(false) + const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }) + const [currentNode, setCurrentNode] = useState<{ node: Node; position: number } | null>(null) + + // 引用 + const menuRef = useRef(null) + const timeoutRef = useRef(undefined) + + /** + * 计算菜单位置 + */ + const calculateMenuPosition = useCallback( + (clientPos: { x: number; y: number }) => { + const { offset = { x: 10, y: 0 }, boundary, autoAdjust = true } = positionOptions || {} + + let x = clientPos.x + offset.x + let y = clientPos.y + offset.y + + if (autoAdjust) { + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const menuWidth = 280 // 预估菜单宽度 + const menuHeight = 400 // 预估菜单最大高度 + + // 水平位置调整 + if (x + menuWidth > viewportWidth) { + x = clientPos.x - menuWidth - offset.x + } + + // 垂直位置调整 + if (y + menuHeight > viewportHeight) { + y = Math.max(10, viewportHeight - menuHeight - 10) + } + + // 边界约束 + if (boundary) { + const rect = boundary.getBoundingClientRect() + x = Math.max(rect.left, Math.min(x, rect.right - menuWidth)) + y = Math.max(rect.top, Math.min(y, rect.bottom - menuHeight)) + } + } + + return { x, y } + }, + [positionOptions] + ) + + /** + * 显示菜单 + */ + const showMenu = useCallback( + (node: Node, position: number, clientPos: { x: number; y: number }) => { + try { + logger.debug('Showing context menu', { + nodeType: node.type.name, + position, + clientPos + }) + + // 清除之前的延时隐藏 + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = undefined + } + + const menuPos = calculateMenuPosition(clientPos) + + setCurrentNode({ node, position }) + setMenuPosition(menuPos) + setIsMenuVisible(true) + + // 触发事件 + eventHandlers?.onMenuShow?.(node, position) + } catch (error) { + logger.error('Failed to show menu', error as Error) + eventHandlers?.onError?.(error as Error, 'showMenu') + } + }, + [calculateMenuPosition, eventHandlers] + ) + + /** + * 隐藏菜单 + */ + const hideMenu = useCallback(() => { + try { + logger.debug('Hiding context menu') + + setIsMenuVisible(false) + setCurrentNode(null) + + // 延时清理位置,以便动画完成 + timeoutRef.current = setTimeout(() => { + setMenuPosition({ x: 0, y: 0 }) + }, 200) + + // 触发事件 + eventHandlers?.onMenuHide?.() + } catch (error) { + logger.error('Failed to hide menu', error as Error) + eventHandlers?.onError?.(error as Error, 'hideMenu') + } + }, [eventHandlers]) + + /** + * 执行菜单操作 + */ + const executeAction = useCallback( + async (action: MenuAction): Promise => { + if (!currentNode) { + const error = new Error('No current node available') + logger.error('Cannot execute action without current node', error) + return { success: false, error: error.message } + } + + try { + logger.debug('Executing menu action', { + actionId: action.id, + nodeType: currentNode.node.type.name, + position: currentNode.position + }) + + // 检查操作是否可用 + if (!action.isEnabled(editor, currentNode.node, currentNode.position)) { + const error = 'Action is not enabled for current context' + logger.warn('Action not enabled', { actionId: action.id }) + return { success: false, error } + } + + // 执行操作 + action.execute(editor, currentNode.node, currentNode.position) + + const result: MenuActionResult = { + success: true, + shouldCloseMenu: true // 默认执行后关闭菜单 + } + + // 触发事件 + eventHandlers?.onActionExecute?.(action, result) + + // 自动关闭菜单 + if (result.shouldCloseMenu !== false) { + hideMenu() + } + + logger.debug('Action executed successfully', { actionId: action.id }) + return result + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to execute action', error as Error, { + actionId: action.id, + nodeType: currentNode.node.type.name + }) + + const result: MenuActionResult = { + success: false, + error: errorMessage + } + + eventHandlers?.onActionExecute?.(action, result) + eventHandlers?.onError?.(error as Error, `executeAction:${action.id}`) + + return result + } + }, + [currentNode, editor, eventHandlers, hideMenu] + ) + + /** + * 监听编辑器变化,自动隐藏菜单 + */ + const handleEditorUpdate = useCallback(() => { + if (isMenuVisible) { + // 检查当前节点是否仍然有效 + if (currentNode) { + try { + const doc = editor.state.doc + const pos = currentNode.position + + // 检查位置是否仍然有效 + if (pos >= 0 && pos < doc.content.size) { + const resolvedPos = doc.resolve(pos) + const nodeAtPos = resolvedPos.nodeAfter || resolvedPos.parent + + // 如果节点类型或内容发生变化,隐藏菜单 + if (nodeAtPos?.type.name !== currentNode.node.type.name) { + hideMenu() + } + } else { + // 位置无效,隐藏菜单 + hideMenu() + } + } catch (error) { + logger.warn('Invalid node position, hiding menu', error as Error) + hideMenu() + } + } + } + }, [isMenuVisible, currentNode, editor, hideMenu]) + + // 监听编辑器更新 + React.useEffect(() => { + if (!editor) return + + editor.on('update', handleEditorUpdate) + editor.on('blur', hideMenu) + + return () => { + editor.off('update', handleEditorUpdate) + editor.off('blur', hideMenu) + + // 清理定时器 + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } + }, [editor, handleEditorUpdate, hideMenu]) + + // 监听全局点击事件,点击菜单外部时隐藏 + React.useEffect(() => { + if (!isMenuVisible) return + + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as HTMLElement)) { + hideMenu() + } + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + hideMenu() + } + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleEscape) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleEscape) + } + }, [isMenuVisible, hideMenu]) + + return { + isMenuVisible, + menuPosition, + currentNode, + showMenu, + hideMenu, + executeAction + } +} diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useMenuActionVisibility.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useMenuActionVisibility.ts new file mode 100644 index 0000000000..332c41ebcb --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useMenuActionVisibility.ts @@ -0,0 +1,110 @@ +import { loggerService } from '@logger' +import { useCallback, useMemo } from 'react' + +import type { MenuAction, MenuVisibilityOptions, UseMenuActionVisibilityReturn } from '../types' +import { ActionGroup } from '../types' + +const logger = loggerService.withContext('useMenuActionVisibility') + +/** + * 菜单操作可见性管理 Hook + */ +export function useMenuActionVisibility({ + editor, + node, + position, + customRules +}: MenuVisibilityOptions): UseMenuActionVisibilityReturn { + /** + * 计算可见的操作列表 + */ + const visibleActions = useMemo(() => { + if (!editor || !node) { + return [] + } + + try { + // 获取所有已注册的操作 + const allActions = getRegisteredActions() + + // 过滤可用的操作 + const filtered = allActions.filter((action) => { + try { + // 基础可用性检查 + if (!action.isEnabled(editor, node, position)) { + return false + } + + // 自定义规则检查 + if (customRules) { + const customResult = customRules.every((rule) => rule(editor, node, position)) + if (!customResult) { + return false + } + } + + return true + } catch (error) { + logger.warn('Error checking action visibility', error as Error, { actionId: action.id }) + return false + } + }) + + logger.debug('Filtered visible actions', { + total: allActions.length, + visible: filtered.length, + nodeType: node.type.name, + position + }) + + return filtered + } catch (error) { + logger.error('Failed to calculate visible actions', error as Error) + return [] + } + }, [editor, node, position, customRules]) + + /** + * 按组分类的操作 + */ + const actionsByGroup = useMemo(() => { + const grouped: Record = { + [ActionGroup.TRANSFORM]: [], + [ActionGroup.FORMAT]: [], + [ActionGroup.BLOCK]: [], + [ActionGroup.INSERT]: [], + [ActionGroup.AI]: [] + } + + visibleActions.forEach((action) => { + if (grouped[action.group]) { + grouped[action.group].push(action) + } + }) + + return grouped + }, [visibleActions]) + + /** + * 刷新可见性 - 强制重新计算 + */ + const refreshVisibility = useCallback(() => { + // 这个函数主要用于外部强制刷新,实际的刷新通过依赖项自动处理 + logger.debug('Visibility refresh requested') + }, []) + + return { + visibleActions, + actionsByGroup, + refreshVisibility + } +} + +import { allActions } from '../actions' + +/** + * 获取已注册的操作 + */ +function getRegisteredActions(): MenuAction[] { + return allActions +} diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/index.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/index.ts new file mode 100644 index 0000000000..4dd9e042d6 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/index.ts @@ -0,0 +1,54 @@ +/** + * Drag Context Menu - 拖拽上下文菜单 + * + * 提供类似 Notion 的块级操作体验,包括: + * - 拖拽手柄 + * - 上下文菜单 + * - 节点转换操作 + * - 格式化和块操作 + */ + +// 主要组件 +export { default as DragContextMenu } from './DragContextMenu' +// DragContextMenuWrapper 已被 TipTap 扩展替代 + +// Hooks +export { useDragContextMenu } from './hooks/useDragContextMenu' +export { useMenuActionVisibility } from './hooks/useMenuActionVisibility' + +// 操作定义 +export * from './actions' +export { allActions, defaultEnabledActions, getActionById } from './actions' + +// 类型定义 +export type * from './types' + +// 样式组件 +export * from './styles' + +/** + * 默认配置 + */ +export const defaultDragContextMenuConfig = { + enabled: true, + defaultActions: [ + 'transform-heading-1', + 'transform-heading-2', + 'transform-heading-3', + 'transform-paragraph', + 'transform-bullet-list', + 'transform-ordered-list', + 'transform-blockquote', + 'transform-code-block', + 'block-duplicate', + 'block-copy', + 'block-delete', + 'insert-paragraph-after' + ], + groupOrder: ['transform', 'format', 'insert', 'block', 'ai'], + menuStyles: { + maxWidth: 320, + maxHeight: 400, + showShortcuts: true + } +} diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/styles.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/styles.ts new file mode 100644 index 0000000000..6f6bebba62 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/styles.ts @@ -0,0 +1,280 @@ +import styled, { css, keyframes } from 'styled-components' + +/** + * 拖拽上下文菜单样式组件 + */ + +// 动画定义 +const fadeInUp = keyframes` + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +` + +const fadeOut = keyframes` + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(10px); + } +` + +/** + * 菜单容器 + */ +export const MenuContainer = styled.div<{ $visible: boolean }>` + position: fixed; + z-index: 2000; + background: var(--color-bg-base); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: + 0 6px 16px rgba(0, 0, 0, 0.12), + 0 3px 6px rgba(0, 0, 0, 0.08); + overflow: hidden; + min-width: 280px; + max-width: 320px; + max-height: 400px; + + ${(props) => + props.$visible + ? css` + animation: ${fadeInUp} 0.15s ease-out; + ` + : css` + animation: ${fadeOut} 0.15s ease-out; + pointer-events: none; + `} + + /* 响应式调整 */ + @media (max-width: 480px) { + min-width: 240px; + max-width: 280px; + } +` + +/** + * 菜单组标题 + */ +export const MenuGroupTitle = styled.div` + padding: 8px 16px 4px; + font-size: 12px; + font-weight: 500; + color: var(--color-text-3); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: none; + + &:not(:first-child) { + margin-top: 8px; + padding-top: 12px; + border-top: 1px solid var(--color-border-secondary); + } +` + +/** + * 菜单项容器 + */ +export const MenuGroup = styled.div` + padding: 4px 0; + + &:not(:last-child) { + border-bottom: 1px solid var(--color-border-secondary); + } +` + +/** + * 菜单项 + */ +export const MenuItem = styled.button<{ $danger?: boolean }>` + width: 100%; + display: flex; + align-items: center; + padding: 8px 16px; + border: none; + background: transparent; + color: var(--color-text); + font-size: 14px; + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease; + gap: 12px; + + ${(props) => + props.$danger && + css` + color: var(--color-error); + + &:hover { + background: var(--color-error-bg); + color: var(--color-error); + } + `} + + &:hover { + background: var(--color-hover); + } + + &:focus { + outline: none; + background: var(--color-primary-bg); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + + &:hover { + background: transparent; + } + } +` + +/** + * 菜单项图标 + */ +export const MenuItemIcon = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; +` + +/** + * 菜单项标签 + */ +export const MenuItemLabel = styled.span` + flex: 1; + font-weight: 400; +` + +/** + * 菜单项快捷键 + */ +export const MenuItemShortcut = styled.span` + font-size: 12px; + color: var(--color-text-3); + font-family: var(--font-mono); + margin-left: auto; +` + +/** + * 拖拽手柄容器样式 + */ +export const DragHandleContainer = styled.div<{ $visible: boolean }>` + display: flex; + align-items: center; + gap: 0.25rem; + opacity: ${(props) => (props.$visible ? 1 : 0)}; + transition: opacity 0.15s ease; + position: absolute; + left: -60px; + top: 50%; + transform: translateY(-50%); + z-index: 10; + padding: 2px; +` + +/** + * 手柄按钮基础样式 + */ +const handleButtonBase = css` + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 0.25rem; + border: none; + background: var(--color-background); + color: var(--color-text-3); + cursor: pointer; + transition: background 0.15s ease; + padding: 0; + + &:hover { + background: var(--color-hover); + } + + &:focus { + outline: none; + background: var(--color-primary-bg); + } +` + +/** + * 加号按钮 + */ +export const PlusButton = styled.button` + ${handleButtonBase} +` + +/** + * 拖拽手柄 + */ +export const DragHandleButton = styled.div` + ${handleButtonBase} + cursor: grab; + + &:active { + cursor: grabbing; + } + + &[draggable='true'] { + user-select: none; + } +` + +/** + * 加载状态指示器 + */ +export const LoadingIndicator = styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + color: var(--color-text-3); + font-size: 14px; +` + +/** + * 错误状态显示 + */ +export const ErrorMessage = styled.div` + padding: 12px 16px; + color: var(--color-error); + background: var(--color-error-bg); + border-radius: 4px; + margin: 8px; + font-size: 14px; + text-align: center; +` + +/** + * 空状态显示 + */ +export const EmptyState = styled.div` + padding: 24px 16px; + text-align: center; + color: var(--color-text-3); + font-size: 14px; +` + +/** + * 分隔线 + */ +export const MenuDivider = styled.hr` + border: none; + border-top: 1px solid var(--color-border-secondary); + margin: 4px 0; +` diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/types.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/types.ts new file mode 100644 index 0000000000..32932c7646 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/types.ts @@ -0,0 +1,224 @@ +import type { Editor } from '@tiptap/core' +import type { Node } from '@tiptap/pm/model' +import type { ReactNode } from 'react' + +/** + * 操作组类型 - 用于菜单项分组 + */ +export enum ActionGroup { + TRANSFORM = 'transform', // 节点转换操作 + FORMAT = 'format', // 格式化操作 + BLOCK = 'block', // 块级操作 + INSERT = 'insert', // 插入操作 + AI = 'ai' // AI 相关操作 (预留) +} + +/** + * 菜单操作项接口 + */ +export interface MenuAction { + /** 操作唯一标识 */ + id: string + /** 显示标签 */ + label: string + /** 图标 */ + icon?: ReactNode + /** 操作组 */ + group: ActionGroup + /** 快捷键描述 */ + shortcut?: string + /** 是否为危险操作 */ + danger?: boolean + /** 是否可用 */ + isEnabled: (editor: Editor, node: Node, pos: number) => boolean + /** 执行操作 */ + execute: (editor: Editor, node: Node, pos: number) => void + /** 自定义类名 */ + className?: string +} + +/** + * 节点转换选项 + */ +export interface NodeTransformOptions { + /** 目标节点类型 */ + nodeType: string + /** 节点属性 */ + attrs?: Record + /** 是否保留内容 */ + preserveContent?: boolean +} + +/** + * 拖拽上下文菜单属性 + */ +export interface DragContextMenuProps { + /** 编辑器实例 */ + editor: Editor + /** 当前节点 */ + node: Node + /** 节点在文档中的位置 */ + position: number + /** 菜单显示状态 */ + visible: boolean + /** 菜单位置 */ + menuPosition: { x: number; y: number } + /** 关闭回调 */ + onClose: () => void + /** 自定义操作 */ + customActions?: MenuAction[] + /** 禁用的操作 ID 列表 */ + disabledActions?: string[] +} + +/** + * 拖拽手柄属性 + */ +export interface DragHandleProps { + /** 编辑器实例 */ + editor: Editor + /** 当前节点 */ + node: Node + /** 节点位置 */ + position: number + /** 是否显示 */ + visible: boolean + /** 点击回调 */ + onClick: () => void + /** 拖拽开始回调 */ + onDragStart?: (e: DragEvent) => void + /** 自定义类名 */ + className?: string +} + +/** + * 菜单可见性配置 + */ +export interface MenuVisibilityOptions { + /** 当前编辑器实例 */ + editor: Editor + /** 当前节点 */ + node: Node + /** 节点位置 */ + position: number + /** 自定义可见性规则 */ + customRules?: Array<(editor: Editor, node: Node, pos: number) => boolean> +} + +/** + * 菜单操作结果 + */ +export interface MenuActionResult { + /** 是否成功执行 */ + success: boolean + /** 错误信息 */ + error?: string + /** 是否需要关闭菜单 */ + shouldCloseMenu?: boolean +} + +/** + * 颜色选择器选项 + */ +export interface ColorOption { + /** 颜色值 */ + color: string + /** 显示名称 */ + name: string + /** 是否为默认颜色 */ + isDefault?: boolean +} + +/** + * 节点转换映射 + */ +export interface NodeTransformMap { + [key: string]: { + /** 显示名称 */ + label: string + /** 图标 */ + icon: ReactNode + /** 转换配置 */ + transform: NodeTransformOptions + /** 是否可用的检查函数 */ + isAvailable?: (editor: Editor, currentNode: Node) => boolean + } +} + +/** + * 拖拽上下文菜单配置 + */ +export interface DragContextMenuConfig { + /** 是否启用 */ + enabled: boolean + /** 默认操作列表 */ + defaultActions: string[] + /** 自定义操作 */ + customActions?: MenuAction[] + /** 操作组排序 */ + groupOrder?: ActionGroup[] + /** 颜色选择器配置 */ + colorOptions?: ColorOption[] + /** 节点转换映射 */ + transformMap?: NodeTransformMap + /** 菜单样式配置 */ + menuStyles?: { + maxWidth?: number + maxHeight?: number + showShortcuts?: boolean + } +} + +/** + * 钩子返回值 - useDragContextMenu + */ +export interface UseDragContextMenuReturn { + /** 菜单是否可见 */ + isMenuVisible: boolean + /** 菜单位置 */ + menuPosition: { x: number; y: number } + /** 当前节点信息 */ + currentNode: { node: Node; position: number } | null + /** 显示菜单 */ + showMenu: (node: Node, position: number, clientPos: { x: number; y: number }) => void + /** 隐藏菜单 */ + hideMenu: () => void + /** 执行操作 */ + executeAction: (action: MenuAction) => Promise +} + +/** + * 钩子返回值 - useMenuActionVisibility + */ +export interface UseMenuActionVisibilityReturn { + /** 可见的操作列表 */ + visibleActions: MenuAction[] + /** 按组分类的操作 */ + actionsByGroup: Record + /** 刷新可见性 */ + refreshVisibility: () => void +} + +/** + * 事件处理器类型 + */ +export interface EventHandlers { + onMenuShow?: (node: Node, position: number) => void + onMenuHide?: () => void + onActionExecute?: (action: MenuAction, result: MenuActionResult) => void + onError?: (error: Error, context: string) => void +} + +/** + * 位置计算选项 + */ +export interface PositionOptions { + /** 偏移量 */ + offset?: { x: number; y: number } + /** 边界约束 */ + boundary?: HTMLElement + /** 对齐方式 */ + align?: 'start' | 'center' | 'end' + /** 自动调整位置 */ + autoAdjust?: boolean +} diff --git a/src/renderer/src/components/RichEditor/components/placeholder/ImagePlaceholderNodeView.tsx b/src/renderer/src/components/RichEditor/components/placeholder/ImagePlaceholderNodeView.tsx new file mode 100644 index 0000000000..a838782ece --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/placeholder/ImagePlaceholderNodeView.tsx @@ -0,0 +1,47 @@ +import { Editor } from '@tiptap/core' +import { NodeViewWrapper } from '@tiptap/react' +import { Image as ImageIcon } from 'lucide-react' +import React, { useCallback } from 'react' +import { useTranslation } from 'react-i18next' + +import PlaceholderBlock from './PlaceholderBlock' + +interface ImagePlaceholderNodeViewProps { + node: any + updateAttributes: (attributes: Record) => void + deleteNode: () => void + editor: Editor +} + +const ImagePlaceholderNodeView: React.FC = ({ deleteNode, editor }) => { + const { t } = useTranslation() + + const handleClick = useCallback(() => { + const event = new CustomEvent('openImageUploader', { + detail: { + onImageSelect: (imageUrl: string) => { + if (imageUrl.trim()) { + deleteNode() + editor.chain().focus().setImage({ src: imageUrl }).run() + } else { + deleteNode() + } + }, + onCancel: () => deleteNode() + } + }) + window.dispatchEvent(event) + }, [editor, deleteNode]) + + return ( + + } + message={t('richEditor.image.placeholder')} + onClick={handleClick} + /> + + ) +} + +export default ImagePlaceholderNodeView diff --git a/src/renderer/src/components/RichEditor/components/placeholder/MathPlaceholderNodeView.tsx b/src/renderer/src/components/RichEditor/components/placeholder/MathPlaceholderNodeView.tsx new file mode 100644 index 0000000000..95c1b0f98e --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/placeholder/MathPlaceholderNodeView.tsx @@ -0,0 +1,74 @@ +import { type NodeViewProps, NodeViewWrapper } from '@tiptap/react' +import { Calculator } from 'lucide-react' +import React, { useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' + +import PlaceholderBlock from './PlaceholderBlock' + +const MathPlaceholderNodeView: React.FC = ({ node, deleteNode, editor }) => { + const { t } = useTranslation() + const wrapperRef = useRef(null) + + const handleClick = useCallback(() => { + let hasCreatedMath = false + const mathType = node.attrs.mathType || 'block' + + let position: { x: number; y: number; top: number } | undefined + if (wrapperRef.current) { + const rect = wrapperRef.current.getBoundingClientRect() + position = { + x: rect.left + rect.width / 2, + y: rect.bottom, + top: rect.top + } + } + + const event = new CustomEvent('openMathDialog', { + detail: { + defaultValue: '', + position, + onSubmit: (latex: string) => { + // onFormulaChange has already handled the creation/update + // onSubmit just needs to close the dialog + // Only delete if input is empty + if (!latex.trim()) { + deleteNode() + } + }, + onCancel: () => deleteNode(), + onFormulaChange: (formula: string) => { + if (formula.trim()) { + if (!hasCreatedMath) { + hasCreatedMath = true + deleteNode() + if (mathType === 'block') { + editor.chain().insertBlockMath({ latex: formula }).run() + } else { + editor.chain().insertInlineMath({ latex: formula }).run() + } + } else { + if (mathType === 'block') { + editor.chain().updateBlockMath({ latex: formula }).run() + } else { + editor.chain().updateInlineMath({ latex: formula }).run() + } + } + } + } + } + }) + window.dispatchEvent(event) + }, [node.attrs.mathType, deleteNode, editor]) + + return ( + + } + message={t('richEditor.math.placeholder')} + onClick={handleClick} + /> + + ) +} + +export default MathPlaceholderNodeView diff --git a/src/renderer/src/components/RichEditor/components/placeholder/PlaceholderBlock.tsx b/src/renderer/src/components/RichEditor/components/placeholder/PlaceholderBlock.tsx new file mode 100644 index 0000000000..c33cdb78cd --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/placeholder/PlaceholderBlock.tsx @@ -0,0 +1,62 @@ +import { useTheme } from '@renderer/context/ThemeProvider' +import React from 'react' + +interface PlaceholderBlockProps { + /** Icon element to display */ + icon: React.ReactNode + /** Localised message */ + message: string + /** Click handler */ + onClick: () => void +} + +/** + * Reusable placeholder block for TipTap NodeViews (math / image etc.) + * Handles dark-mode colours and simple hover feedback. + */ +const PlaceholderBlock: React.FC = ({ icon, message, onClick }) => { + const { theme } = useTheme() + const isDark = theme === 'dark' + + const colors = { + border: isDark ? 'var(--color-border, #ffffff19)' : '#d0d7de', + background: isDark ? 'var(--color-background-soft, #222222)' : 'var(--color-canvas-subtle, #f6f8fa)', + hoverBorder: isDark ? 'var(--color-primary, #2f81f7)' : '#0969da', + hoverBackground: isDark ? 'rgba(56, 139, 253, 0.15)' : 'var(--color-accent-subtle, #ddf4ff)' + } + + return ( +
{ + const target = e.currentTarget as HTMLElement + target.style.borderColor = colors.hoverBorder + target.style.backgroundColor = colors.hoverBackground + }} + onMouseLeave={(e) => { + const target = e.currentTarget as HTMLElement + target.style.borderColor = colors.border + target.style.backgroundColor = colors.background + }}> + {icon} + {message} +
+ ) +} + +export default PlaceholderBlock diff --git a/src/renderer/src/components/RichEditor/extensions/code-block-shiki/CodeBlockNodeView.tsx b/src/renderer/src/components/RichEditor/extensions/code-block-shiki/CodeBlockNodeView.tsx new file mode 100644 index 0000000000..75ccf6befb --- /dev/null +++ b/src/renderer/src/components/RichEditor/extensions/code-block-shiki/CodeBlockNodeView.tsx @@ -0,0 +1,87 @@ +import { CopyOutlined } from '@ant-design/icons' +import { DEFAULT_LANGUAGES, getHighlighter, getShiki } from '@renderer/utils/shiki' +import { NodeViewContent, NodeViewWrapper, type ReactNodeViewProps, ReactNodeViewRenderer } from '@tiptap/react' +import { Button, Select, Tooltip } from 'antd' +import { FC, useCallback, useEffect, useState } from 'react' + +const CodeBlockNodeView: FC = (props) => { + const { node, updateAttributes } = props + const [languageOptions, setLanguageOptions] = useState(DEFAULT_LANGUAGES) + + // Detect language from node attrs or fallback + const language = (node.attrs.language as string) || 'text' + + // Build language options with 'text' always available + useEffect(() => { + const loadLanguageOptions = async () => { + try { + const shiki = await getShiki() + const highlighter = await getHighlighter() + + // Get bundled languages from shiki + const bundledLanguages = Object.keys(shiki.bundledLanguages) + + // Combine with loaded languages + const loadedLanguages = highlighter.getLoadedLanguages() + + const allLanguages = Array.from(new Set(['text', ...bundledLanguages, ...loadedLanguages])) + + setLanguageOptions(allLanguages) + } catch { + setLanguageOptions(DEFAULT_LANGUAGES) + } + } + + loadLanguageOptions() + }, []) + + // Handle language change + const handleLanguageChange = useCallback( + (value: string) => { + updateAttributes({ language: value }) + }, + [updateAttributes] + ) + + // Handle copy code block content + const handleCopy = useCallback(async () => { + const codeText = props.node.textContent || '' + try { + await navigator.clipboard.writeText(codeText) + } catch { + // Clipboard may fail (e.g. non-secure context) + } + }, [props.node.textContent]) + + return ( + +
+ + + + {t('paintings.image.size')} @@ -529,12 +534,12 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => { setPainting(addPainting('paintings', getNewPainting()))} + onNewPainting={() => setPainting(addPainting('siliconflow_paintings', getNewPainting()))} /> diff --git a/src/renderer/src/pages/paintings/TokenFluxPage.tsx b/src/renderer/src/pages/paintings/TokenFluxPage.tsx index 100550e6c7..0860b62ee9 100644 --- a/src/renderer/src/pages/paintings/TokenFluxPage.tsx +++ b/src/renderer/src/pages/paintings/TokenFluxPage.tsx @@ -32,6 +32,7 @@ import Artboard from './components/Artboard' import { DynamicFormRender } from './components/DynamicFormRender' import PaintingsList from './components/PaintingsList' import { DEFAULT_TOKENFLUX_PAINTING, type TokenFluxModel } from './config/tokenFluxConfig' +import { checkProviderEnabled } from './utils' import TokenFluxService from './utils/TokenFluxService' const logger = loggerService.withContext('TokenFluxPage') @@ -48,8 +49,8 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => { const { t, i18n } = useTranslation() const providers = useAllProviders() - const { addPainting, removePainting, updatePainting, persistentData } = usePaintings() - const tokenFluxPaintings = useMemo(() => persistentData.tokenFluxPaintings || [], [persistentData.tokenFluxPaintings]) + const { addPainting, removePainting, updatePainting, tokenflux_paintings } = usePaintings() + const tokenFluxPaintings = tokenflux_paintings const [painting, setPainting] = useState( tokenFluxPaintings[0] || { ...DEFAULT_TOKENFLUX_PAINTING, id: uuid() } ) @@ -105,7 +106,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => { (updates: Partial) => { setPainting((prevPainting) => { const updatedPainting = { ...prevPainting, ...updates } - updatePainting('tokenFluxPaintings', updatedPainting) + updatePainting('tokenflux_paintings', updatedPainting) return updatedPainting }) }, @@ -137,6 +138,8 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => { } const onGenerate = async () => { + await checkProviderEnabled(tokenfluxProvider, t) + if (painting.files.length > 0) { const confirmed = await window.modal.confirm({ content: t('paintings.regenerate.confirm'), @@ -149,22 +152,6 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => { const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || '' - if (!tokenfluxProvider.enabled) { - window.modal.error({ - content: t('error.provider_disabled'), - centered: true - }) - return - } - - if (!tokenfluxProvider.apiKey) { - window.modal.error({ - content: t('error.no_api_key'), - centered: true - }) - return - } - if (!selectedModel || !prompt) { window.modal.error({ content: t('paintings.text_desc_required'), @@ -236,8 +223,8 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => { } const handleAddPainting = () => { - const newPainting = addPainting('tokenFluxPaintings', getNewPainting()) - updatePainting('tokenFluxPaintings', newPainting) + const newPainting = addPainting('tokenflux_paintings', getNewPainting()) + updatePainting('tokenflux_paintings', newPainting) setPainting(newPainting as TokenFluxPainting) return newPainting } @@ -253,7 +240,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => { } } - removePainting('tokenFluxPaintings', paintingToDelete) + removePainting('tokenflux_paintings', paintingToDelete) } const translate = async () => { @@ -338,7 +325,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => { useEffect(() => { if (tokenFluxPaintings.length === 0) { const newPainting = getNewPainting() - addPainting('tokenFluxPaintings', newPainting) + addPainting('tokenflux_paintings', newPainting) setPainting(newPainting) } }, [tokenFluxPaintings, addPainting, getNewPainting]) @@ -573,7 +560,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => { = ({ Options }) => { + const { zhipu_paintings, addPainting, removePainting, updatePainting } = usePaintings() + const [painting, setPainting] = useState(zhipu_paintings?.[0] || DEFAULT_PAINTING) + const { t } = useTranslation() + const providers = useAllProviders() + + // 确保painting使用智谱的cogview系列模型 + useEffect(() => { + if (painting && !painting.model?.startsWith('cogview')) { + const updatedPainting = { ...painting, model: 'cogview-3-flash' } + setPainting(updatedPainting) + updatePainting('zhipu_paintings', updatedPainting) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [painting?.id]) // 只在painting的id改变时执行,避免无限循环 + + const providerOptions = Options.map((option) => { + const provider = providers.find((p) => p.id === option) + if (provider) { + return { + label: getProviderLabel(provider.id), + value: provider.id + } + } else { + return { + label: 'Unknown Provider', + value: undefined + } + } + }) + + const zhipuProvider = providers.find((p) => p.id === 'zhipu')! + + const [currentImageIndex, setCurrentImageIndex] = useState(0) + const [isLoading, setIsLoading] = useState(false) + const [abortController, setAbortController] = useState(null) + const dispatch = useAppDispatch() + const { generating } = useRuntime() + const navigate = useNavigate() + const location = useLocation() + + // 自定义尺寸相关状态 + const [isCustomSize, setIsCustomSize] = useState(false) + const [customWidth, setCustomWidth] = useState() + const [customHeight, setCustomHeight] = useState() + + const updatePaintingState = (updates: Partial) => { + const updatedPainting = { ...painting, ...updates } + setPainting(updatedPainting) + updatePainting('zhipu_paintings', updatedPainting) + } + + const getNewPainting = (params?: Partial) => { + return { + ...DEFAULT_PAINTING, + id: uuid(), + ...params + } + } + + const onGenerate = async () => { + await checkProviderEnabled(zhipuProvider, t) + + if (isLoading) return + + if (!painting.prompt.trim()) { + window.modal.error({ + content: t('paintings.prompt_required'), + centered: true + }) + return + } + + // 检查是否需要重新生成(如果已有图片) + if (painting.files.length > 0) { + const confirmed = await window.modal.confirm({ + content: t('paintings.regenerate.confirm'), + centered: true + }) + if (!confirmed) return + await FileManager.deleteFiles(painting.files) + } + + setIsLoading(true) + dispatch(setGenerating(true)) + const controller = new AbortController() + setAbortController(controller) + + try { + // 使用AiProvider调用智谱AI绘图API + const aiProvider = new AiProvider(zhipuProvider) + + // 准备API请求参数 + let actualImageSize = painting.imageSize + + // 如果是自定义尺寸,使用实际的宽高值 + if (painting.imageSize === 'custom') { + if (!customWidth || !customHeight) { + window.modal.error({ + content: '请设置自定义尺寸的宽度和高度', + centered: true + }) + return + } + // 验证自定义尺寸是否符合智谱AI的要求 + if (customWidth < 512 || customWidth > 2048 || customHeight < 512 || customHeight > 2048) { + window.modal.error({ + content: '自定义尺寸必须在512px-2048px之间', + centered: true + }) + return + } + + if (customWidth % 16 !== 0 || customHeight % 16 !== 0) { + window.modal.error({ + content: '自定义尺寸必须能被16整除', + centered: true + }) + return + } + + const totalPixels = customWidth * customHeight + if (totalPixels > 2097152) { + // 2^21 = 2097152 + window.modal.error({ + content: '自定义尺寸的总像素数不能超过2,097,152', + centered: true + }) + return + } + + actualImageSize = `${customWidth}x${customHeight}` + } + + const request = { + model: painting.model, + prompt: painting.prompt, + negativePrompt: painting.negativePrompt, + imageSize: actualImageSize, + batchSize: painting.numImages, + quality: painting.quality, + signal: controller.signal + } + + // 调用智谱AI绘图API + const imageUrls = await aiProvider.generateImage(request) + + // 下载图片到本地文件 + if (imageUrls.length > 0) { + const downloadedFiles = await Promise.all( + imageUrls.map(async (url) => { + try { + if (!url || url.trim() === '') { + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + return null + } + return await window.api.file.download(url) + } catch (error) { + if ( + error instanceof Error && + (error.message.includes('Failed to parse URL') || error.message.includes('Invalid URL')) + ) { + window.message.warning({ + content: t('message.empty_url'), + key: 'empty-url-warning' + }) + } + return null + } + }) + ) + + const validFiles = downloadedFiles.filter((file): file is any => file !== null) + + await FileManager.addFiles(validFiles) + + // 处理响应结果 + const newPainting = { + ...painting, + urls: imageUrls, + files: validFiles + } + + updatePaintingState(newPainting) + } + } catch (error) { + if (error instanceof Error && error.name !== 'AbortError') { + window.modal.error({ + content: getErrorMessage(error), + centered: true + }) + } + } finally { + setIsLoading(false) + dispatch(setGenerating(false)) + setAbortController(null) + } + } + + const onCancel = () => { + if (abortController) { + abortController.abort() + } + } + + const nextImage = () => { + setCurrentImageIndex((prev) => (prev + 1) % painting.files.length) + } + + const prevImage = () => { + setCurrentImageIndex((prev) => (prev - 1 + painting.files.length) % painting.files.length) + } + + const onDeletePainting = async (paintingToDelete: any) => { + if (paintingToDelete.id === painting.id) { + if (isLoading) return + + const currentIndex = zhipu_paintings.findIndex((p) => p.id === paintingToDelete.id) + + if (currentIndex > 0) { + setPainting(zhipu_paintings[currentIndex - 1]) + } else if (zhipu_paintings.length > 1) { + setPainting(zhipu_paintings[1]) + } + } + + await removePainting('zhipu_paintings', paintingToDelete) + + if (!zhipu_paintings || zhipu_paintings.length === 1) { + const newPainting = getNewPainting() + const addedPainting = addPainting('zhipu_paintings', newPainting) + setPainting(addedPainting) + } + } + + const onSelectPainting = (newPainting: any) => { + if (generating) return + setPainting(newPainting) + setCurrentImageIndex(0) + } + + const handleProviderChange = (providerId: string) => { + const routeName = location.pathname.split('/').pop() + if (providerId !== routeName) { + navigate('../' + providerId, { replace: true }) + } + } + + const onSelectModel = (modelId: string) => { + updatePaintingState({ model: modelId }) + } + + const onSelectQuality = (quality: string) => { + updatePaintingState({ quality }) + } + + const onSelectImageSize = (size: string) => { + if (size === 'custom') { + setIsCustomSize(true) + updatePaintingState({ imageSize: 'custom' }) + } else { + setIsCustomSize(false) + updatePaintingState({ imageSize: size }) + } + } + + const onCustomSizeChange = (value: number | undefined, dimension: 'width' | 'height') => { + if (dimension === 'width') { + setCustomWidth(value) + updatePaintingState({ customWidth: value }) + } else { + setCustomHeight(value) + updatePaintingState({ customHeight: value }) + } + } + + const createNewPainting = () => { + if (generating) return + const newPainting = getNewPainting() + const addedPainting = addPainting('zhipu_paintings', newPainting) + setPainting(addedPainting) + } + + // 移除modelOptions的定义,直接在Select中使用 + + useEffect(() => { + if (!zhipu_paintings || zhipu_paintings.length === 0) { + const newPainting = getNewPainting() + addPainting('zhipu_paintings', newPainting) + } + }, [zhipu_paintings, addPainting]) + + // 同步自定义尺寸状态 + useEffect(() => { + if (painting.imageSize === 'custom') { + setIsCustomSize(true) + // 恢复自定义尺寸的宽高值 + if (painting.customWidth) { + setCustomWidth(painting.customWidth) + } + if (painting.customHeight) { + setCustomHeight(painting.customHeight) + } + } else { + setIsCustomSize(false) + } + }, [painting.imageSize, painting.customWidth, painting.customHeight]) + + return ( + + + + {t('title.paintings')} + + {isMac && ( + +