diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 96c2e73aad..f886d01f8b 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -98,8 +98,11 @@ jobs: 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' @@ -112,9 +115,12 @@ 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' @@ -123,8 +129,11 @@ jobs: 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa3aa91a19..41b915953c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,6 +86,7 @@ jobs: 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 }} @@ -104,6 +105,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 }} @@ -116,6 +118,7 @@ jobs: 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/.prettierignore b/.prettierignore index 4ff98b4519..5f6cea6dad 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,4 @@ tsconfig.*.json CHANGELOG*.md agents.json src/renderer/src/integration/nutstore/sso/lib +src/main/integration/cherryin/index.js 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/electron-builder.yml b/electron-builder.yml index ce50a8ec36..04ed410d6d 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -55,6 +55,9 @@ files: - '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds - '!node_modules/selection-hook/node_modules' # we don't need what in the node_modules dir - '!node_modules/selection-hook/src' # we don't need source files + - '!node_modules/tesseract.js-core/{tesseract-core.js,tesseract-core.wasm,tesseract-core.wasm.js}' # we don't need source files + - '!node_modules/tesseract.js-core/{tesseract-core-lstm.js,tesseract-core-lstm.wasm,tesseract-core-lstm.wasm.js}' # we don't need source files + - '!node_modules/tesseract.js-core/{tesseract-core-simd-lstm.js,tesseract-core-simd-lstm.wasm,tesseract-core-simd-lstm.wasm.js}' # we don't need source files - '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}' # filter .node build files asarUnpack: - resources/** @@ -119,11 +122,24 @@ afterSign: scripts/notarize.js artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - 输入框快捷菜单增加清除按钮 - 侧边栏增加代码工具入口,代码工具增加环境变量设置 - 小程序增加多语言显示 - 优化 MCP 服务器列表 - 新增 Web 搜索图标 - 优化 SVG 预览,优化 HTML 内容样式 - 修复知识库文档预处理失败问题 - 稳定性改进和错误修复 + ✨ 重要更新: + - 新增笔记模块,支持富文本编辑和管理 + - 内置 GLM-4.5-Flash 免费模型(由智谱开放平台提供) + - 内置 Qwen3-8B 免费模型(由硅基流动提供) + - 新增 Nano Banana(Gemini 2.5 Flash Image)模型支持 + - 新增系统 OCR 功能 (macOS & Windows) + - 新增图片 OCR 识别和翻译功能 + - 模型切换支持通过标签筛选 + - 翻译功能增强:历史搜索和收藏 + + 🔧 性能优化: + - 优化历史页面搜索性能 + - 优化拖拽列表组件交互 + - 升级 Electron 到 37.4.0 + + 🐛 修复问题: + - 修复知识库加密 PDF 文档处理 + - 修复导航栏在左侧时笔记侧边栏按钮缺失 + - 修复多个模型兼容性问题 + - 修复 MCP 相关问题 + - 其他稳定性改进 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 075107154c..25719dc11f 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -94,7 +94,8 @@ export default defineConfig({ '@logger': resolve('src/renderer/src/services/LoggerService'), '@data': resolve('src/renderer/src/data'), '@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'), - '@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web') + '@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web'), + '@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 37565b76f5..696e1f2eb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.5.7-rc.2", + "version": "1.5.8-rc.2", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -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" ] } }, @@ -73,6 +74,7 @@ "dependencies": { "@libsql/client": "0.14.0", "@libsql/win32-x64-msvc": "^0.4.7", + "@napi-rs/system-ocr": "^1.0.2", "@strongtz/win32-arm64-msvc": "^0.4.7", "graceful-fs": "^4.2.11", "jsdom": "26.1.0", @@ -106,6 +108,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", @@ -120,7 +123,7 @@ "@eslint-react/eslint-plugin": "^1.36.1", "@eslint/js": "^9.22.0", "@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch", - "@hello-pangea/dnd": "^16.6.0", + "@hello-pangea/dnd": "^18.0.1", "@kangfenmao/keyv-storage": "^0.1.0", "@langchain/community": "^0.3.36", "@langchain/ollama": "^0.2.1", @@ -136,18 +139,35 @@ "@opentelemetry/sdk-trace-web": "^2.0.0", "@playwright/test": "^1.52.0", "@reduxjs/toolkit": "^2.2.5", - "@shikijs/markdown-it": "^3.9.1", + "@shikijs/markdown-it": "^3.12.0", "@swc/plugin-styled-components": "^7.1.5", - "@tanstack/react-query": "^5.27.0", + "@tanstack/react-query": "^5.85.5", "@tanstack/react-virtual": "^3.13.12", "@testing-library/dom": "^10.4.0", "@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/diff": "^7", "@types/fs-extra": "^11", + "@types/he": "^1", "@types/lodash": "^4.17.5", "@types/markdown-it": "^14", "@types/md5": "^2.3.5", @@ -158,6 +178,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", @@ -176,6 +197,7 @@ "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", @@ -183,12 +205,13 @@ "dayjs": "^1.11.11", "dexie": "^4.0.8", "dexie-react-hooks": "^1.1.7", - "diff": "^7.0.0", + "diff": "^8.0.2", "docx": "^9.0.2", + "dompurify": "^3.2.6", "dotenv-cli": "^7.4.2", "drizzle-kit": "^0.31.4", "drizzle-orm": "^0.44.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", @@ -208,6 +231,7 @@ "franc-min": "^6.2.0", "fs-extra": "^11.2.0", "google-auth-library": "^9.15.1", + "he": "^1.2.0", "html-to-image": "^1.11.13", "husky": "^9.1.7", "i18next": "^23.11.5", @@ -215,14 +239,14 @@ "isbinaryfile": "5.0.4", "jaison": "^2.0.2", "jest-styled-components": "^7.2.0", - "linguist-languages": "^8.0.0", + "linguist-languages": "^8.1.0", "lint-staged": "^15.5.0", "lodash": "^4.17.21", "lru-cache": "^11.1.0", "lucide-react": "^0.525.0", "macos-release": "^3.4.0", "markdown-it": "^14.1.0", - "mermaid": "^11.9.0", + "mermaid": "^11.10.1", "mime": "^4.0.4", "motion": "^12.10.5", "notion-helper": "^1.3.22", @@ -262,14 +286,16 @@ "remove-markdown": "^0.6.2", "rollup-plugin-visualizer": "^5.12.0", "sass": "^1.88.0", - "shiki": "^3.9.1", + "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", @@ -280,6 +306,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 992a372384..334b08e1c3 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', @@ -311,5 +326,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/languages.ts b/packages/shared/config/languages.ts index 95b8cab587..42a733bc4a 100644 --- a/packages/shared/config/languages.ts +++ b/packages/shared/config/languages.ts @@ -2020,6 +2020,10 @@ export const languages: Record = { extensions: ['.nginx', '.nginxconf', '.vhost'], aliases: ['nginx configuration file'] }, + Nickel: { + type: 'programming', + extensions: ['.ncl'] + }, Nim: { type: 'programming', extensions: ['.nim', '.nim.cfg', '.nimble', '.nimrod', '.nims'] @@ -3061,7 +3065,7 @@ export const languages: Record = { }, SWIG: { type: 'programming', - extensions: ['.i'] + extensions: ['.i', '.swg', '.swig'] }, SystemVerilog: { type: 'programming', 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..fd3c361788 100644 --- a/scripts/after-pack.js +++ b/scripts/after-pack.js @@ -17,6 +17,14 @@ exports.default = async function (context) { ) keepPackageNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64']) + + keepPackageNodeFiles( + node_modules_path, + '@img', + arch === Arch.arm64 + ? ['sharp-darwin-arm64', 'sharp-libvips-darwin-arm64'] + : ['sharp-darwin-x64', 'sharp-libvips-darwin-x64'] + ) } if (platform === 'linux') { @@ -24,8 +32,13 @@ exports.default = async function (context) { 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) + keepPackageNodeFiles( + node_modules_path, + '@img', + arch === Arch.arm64 + ? ['sharp-libvips-linux-arm64', 'sharp-linux-arm64'] + : ['sharp-libvips-linux-x64', 'sharp-linux-x64'] + ) } if (platform === 'windows') { @@ -39,7 +52,13 @@ exports.default = async function (context) { keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc']) } - removeMacOnlyPackages(node_modules_path) + keepPackageNodeFiles( + node_modules_path, + '@img', + arch === Arch.arm64 + ? ['sharp-win32-arm64', 'sharp-libvips-win32-arm64'] + : ['sharp-win32-x64', 'sharp-libvips-win32-x64'] + ) } if (platform === 'windows') { @@ -48,22 +67,6 @@ exports.default = async function (context) { } } -/** - * 删除 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 diff --git a/scripts/build-npm.js b/scripts/build-npm.js index 7718410bbb..159e77453e 100644 --- a/scripts/build-npm.js +++ b/scripts/build-npm.js @@ -7,6 +7,24 @@ async function downloadNpm(platform) { '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') + + // sharp for macOS + downloadNpmPackage( + '@img/sharp-darwin-arm64', + 'https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz' + ) + downloadNpmPackage( + '@img/sharp-darwin-x64', + 'https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz' + ) + downloadNpmPackage( + '@img/sharp-libvips-darwin-arm64', + 'https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz' + ) + downloadNpmPackage( + '@img/sharp-libvips-darwin-x64', + 'https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz' + ) } if (!platform || platform === 'linux') { @@ -26,6 +44,23 @@ async function downloadNpm(platform) { '@libsql/linux-x64-musl', 'https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.4.7.tgz' ) + + downloadNpmPackage( + '@img/sharp-libvips-linux-arm64', + 'https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz' + ) + downloadNpmPackage( + '@img/sharp-libvips-linux-x64', + 'https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz' + ) + downloadNpmPackage( + '@img/sharp-libvips-linuxmusl-arm64', + 'https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz' + ) + downloadNpmPackage( + '@img/sharp-libvips-linuxmusl-x64', + 'https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz' + ) } if (!platform || platform === 'windows') { @@ -37,6 +72,15 @@ async function downloadNpm(platform) { '@strongtz/win32-arm64-msvc', 'https://registry.npmjs.org/@strongtz/win32-arm64-msvc/-/win32-arm64-msvc-0.4.7.tgz' ) + + downloadNpmPackage( + '@img/sharp-win32-arm64', + 'https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz' + ) + downloadNpmPackage( + '@img/sharp-win32-x64', + 'https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz' + ) } } 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 f83b99d947..3f303dca71 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -5,6 +5,7 @@ import path from 'node:path' import { PreferenceService } from '@data/PreferenceService' 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' @@ -58,7 +59,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' @@ -78,11 +87,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(), @@ -197,6 +213,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) }) @@ -430,16 +450,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)) @@ -447,6 +476,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) => { @@ -535,19 +569,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] }) @@ -716,6 +754,9 @@ 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)) + // Preference handlers PreferenceService.registerIpcHandler() } diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts index 6cc8a41b05..256b4dcbd6 100644 --- a/src/main/services/CodeToolsService.ts +++ b/src/main/services/CodeToolsService.ts @@ -421,7 +421,7 @@ end tell` const envPrefix = buildEnvPrefix(false) const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand - const linuxTerminals = ['gnome-terminal', 'konsole', 'xterm', 'x-terminal-emulator'] + const linuxTerminals = ['gnome-terminal', 'konsole', 'deepin-terminal', 'xterm', 'x-terminal-emulator'] let foundTerminal = 'xterm' // Default to xterm for (const terminal of linuxTerminals) { @@ -448,6 +448,9 @@ end tell` } else if (foundTerminal === 'konsole') { terminalCommand = 'konsole' terminalArgs = ['--workdir', directory, '-e', 'bash', '-c', `clear && ${command}; exec bash`] + } else if (foundTerminal === 'deepin-terminal') { + terminalCommand = 'deepin-terminal' + terminalArgs = ['-w', directory, '-e', 'bash', '-c', `clear && ${command}; exec bash`] } else { // Default to xterm terminalCommand = 'xterm' 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 78267991d3..91a8ef828e 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -426,7 +426,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..3140bc21c0 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, diff --git a/src/main/services/ocr/OcrService.ts b/src/main/services/ocr/OcrService.ts index 6ac8c311e3..0d7383a24a 100644 --- a/src/main/services/ocr/OcrService.ts +++ b/src/main/services/ocr/OcrService.ts @@ -1,7 +1,8 @@ import { loggerService } from '@logger' import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types' -import { tesseractService } from './tesseract/TesseractService' +import { systemOcrService } from './builtin/SystemOcrService' +import { tesseractService } from './builtin/TesseractService' const logger = loggerService.withContext('OcrService') @@ -24,7 +25,7 @@ export class OcrService { if (!handler) { throw new Error(`Provider ${provider.id} is not registered`) } - return handler(file) + return handler(file, provider.config) } } @@ -32,3 +33,4 @@ 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)) diff --git a/src/main/services/ocr/builtin/OcrBaseService.ts b/src/main/services/ocr/builtin/OcrBaseService.ts new file mode 100644 index 0000000000..9c36e79c3a --- /dev/null +++ b/src/main/services/ocr/builtin/OcrBaseService.ts @@ -0,0 +1,5 @@ +import { OcrHandler } from '@types' + +export abstract class OcrBaseService { + abstract ocr: OcrHandler +} diff --git a/src/main/services/ocr/builtin/SystemOcrService.ts b/src/main/services/ocr/builtin/SystemOcrService.ts new file mode 100644 index 0000000000..cda52bfec6 --- /dev/null +++ b/src/main/services/ocr/builtin/SystemOcrService.ts @@ -0,0 +1,39 @@ +import { isMac, isWin } from '@main/constant' +import { loadOcrImage } from '@main/utils/ocr' +import { OcrAccuracy, recognize } from '@napi-rs/system-ocr' +import { + ImageFileMetadata, + isImageFileMetadata as isImageFileMetadata, + OcrResult, + OcrSystemConfig, + SupportedOcrFile +} from '@types' + +import { OcrBaseService } from './OcrBaseService' + +// const logger = loggerService.withContext('SystemOcrService') +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 { + const buffer = await loadOcrImage(file) + const langs = isWin ? options?.langs : undefined + const result = await recognize(buffer, OcrAccuracy.Accurate, langs) + return { text: result.text } + } + + public ocr = async (file: SupportedOcrFile, options?: OcrSystemConfig): Promise => { + if (isImageFileMetadata(file)) { + return this.ocrImage(file, options) + } else { + throw new Error('Unsupported file type, currently only image files are supported') + } + } +} + +export const systemOcrService = new SystemOcrService() diff --git a/src/main/services/ocr/builtin/TesseractService.ts b/src/main/services/ocr/builtin/TesseractService.ts new file mode 100644 index 0000000000..9fd7bbcf01 --- /dev/null +++ b/src/main/services/ocr/builtin/TesseractService.ts @@ -0,0 +1,115 @@ +import { loggerService } from '@logger' +import { getIpCountry } from '@main/utils/ipService' +import { loadOcrImage } from '@main/utils/ocr' +import { MB } from '@shared/config/constant' +import { ImageFileMetadata, isImageFileMetadata, OcrResult, OcrTesseractConfig, SupportedOcrFile } from '@types' +import { app } from 'electron' +import fs from 'fs' +import { isEqual } from 'lodash' +import path from 'path' +import Tesseract, { createWorker, LanguageCode } from 'tesseract.js' + +import { OcrBaseService } from './OcrBaseService' + +const logger = loggerService.withContext('TesseractService') + +// config +const MB_SIZE_THRESHOLD = 50 +const defaultLangs = ['chi_sim', 'chi_tra', 'eng'] satisfies LanguageCode[] +enum TesseractLangsDownloadUrl { + CN = 'https://gitcode.com/beyondkmp/tessdata-best/releases/download/1.0.0/' +} + +export class TesseractService extends OcrBaseService { + private worker: Tesseract.Worker | null = null + private previousLangs: OcrTesseractConfig['langs'] + + constructor() { + super() + this.previousLangs = {} + } + + async getWorker(options?: OcrTesseractConfig): Promise { + let langsArray: LanguageCode[] + if (options?.langs) { + // TODO: use type safe objectKeys + langsArray = Object.keys(options.langs) as LanguageCode[] + if (langsArray.length === 0) { + logger.warn('Empty langs option. Fallback to defaultLangs.') + langsArray = defaultLangs + } + } else { + langsArray = defaultLangs + } + logger.debug('langsArray', langsArray) + if (!this.worker || !isEqual(this.previousLangs, langsArray)) { + if (this.worker) { + await this.dispose() + } + logger.debug('use langsArray to create worker', langsArray) + const langPath = await this._getLangPath() + const cachePath = await this._getCacheDir() + const promise = new Promise((resolve, reject) => { + createWorker(langsArray, undefined, { + langPath, + cachePath, + logger: (m) => logger.debug('From worker', m), + errorHandler: (e) => { + logger.error('Worker Error', e) + reject(e) + } + }) + .then(resolve) + .catch(reject) + }) + this.worker = await promise + } + return this.worker + } + + private async imageOcr(file: ImageFileMetadata, options?: OcrTesseractConfig): Promise { + const worker = await this.getWorker(options) + const stat = await fs.promises.stat(file.path) + if (stat.size > MB_SIZE_THRESHOLD * MB) { + throw new Error(`This image is too large (max ${MB_SIZE_THRESHOLD}MB)`) + } + const buffer = await loadOcrImage(file) + const result = await worker.recognize(buffer) + return { text: result.data.text } + } + + public ocr = async (file: SupportedOcrFile, options?: OcrTesseractConfig): Promise => { + if (!isImageFileMetadata(file)) { + throw new Error('Only image files are supported currently') + } + return this.imageOcr(file, options) + } + + private async _getLangPath(): Promise { + const country = await getIpCountry() + return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : '' + } + + private async _getCacheDir(): Promise { + const cacheDir = path.join(app.getPath('userData'), 'tesseract') + // use access to check if the directory exists + if ( + !(await fs.promises + .access(cacheDir, fs.constants.F_OK) + .then(() => true) + .catch(() => false)) + ) { + await fs.promises.mkdir(cacheDir, { recursive: true }) + } + return cacheDir + } + + async dispose(): Promise { + if (this.worker) { + await this.worker.terminate() + this.worker = null + } + } +} + +export const tesseractService = new TesseractService() diff --git a/src/main/services/ocr/tesseract/TesseractService.ts b/src/main/services/ocr/tesseract/TesseractService.ts deleted file mode 100644 index d2ba6d2ed8..0000000000 --- a/src/main/services/ocr/tesseract/TesseractService.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { loggerService } from '@logger' -import { getIpCountry } from '@main/utils/ipService' -import { loadOcrImage } from '@main/utils/ocr' -import { MB } from '@shared/config/constant' -import { ImageFileMetadata, isImageFile, OcrResult, SupportedOcrFile } from '@types' -import { app } from 'electron' -import fs from 'fs' -import path from 'path' -import Tesseract, { createWorker, LanguageCode } from 'tesseract.js' - -const logger = loggerService.withContext('TesseractService') - -// config -const MB_SIZE_THRESHOLD = 50 -const tesseractLangs = ['chi_sim', 'chi_tra', 'eng'] satisfies LanguageCode[] -enum TesseractLangsDownloadUrl { - CN = 'https://gitcode.com/beyondkmp/tessdata/releases/download/4.1.0/', - GLOBAL = 'https://github.com/tesseract-ocr/tessdata/raw/main/' -} - -export class TesseractService { - private worker: Tesseract.Worker | null = null - - async getWorker(): Promise { - if (!this.worker) { - // for now, only support limited languages - this.worker = await createWorker(tesseractLangs, undefined, { - langPath: await this._getLangPath(), - cachePath: await this._getCacheDir(), - gzip: false, - logger: (m) => logger.debug('From worker', m) - }) - } - return this.worker - } - - async imageOcr(file: ImageFileMetadata): Promise { - const worker = await this.getWorker() - const stat = await fs.promises.stat(file.path) - if (stat.size > MB_SIZE_THRESHOLD * MB) { - throw new Error(`This image is too large (max ${MB_SIZE_THRESHOLD}MB)`) - } - const buffer = await loadOcrImage(file) - const result = await worker.recognize(buffer) - return { text: result.data.text } - } - - async ocr(file: SupportedOcrFile): Promise { - if (!isImageFile(file)) { - throw new Error('Only image files are supported currently') - } - return this.imageOcr(file) - } - - private async _getLangPath(): Promise { - const country = await getIpCountry() - return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : TesseractLangsDownloadUrl.GLOBAL - } - - private async _getCacheDir(): Promise { - const cacheDir = path.join(app.getPath('userData'), 'tesseract') - // use access to check if the directory exists - if ( - !(await fs.promises - .access(cacheDir, fs.constants.F_OK) - .then(() => true) - .catch(() => false)) - ) { - await fs.promises.mkdir(cacheDir, { recursive: true }) - } - return cacheDir - } - - async dispose(): Promise { - if (this.worker) { - await this.worker.terminate() - this.worker = null - } - } -} - -export const tesseractService = new TesseractService() 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/main/utils/ocr.ts b/src/main/utils/ocr.ts index ca63e82f07..446fbe63d6 100644 --- a/src/main/utils/ocr.ts +++ b/src/main/utils/ocr.ts @@ -2,11 +2,12 @@ import { ImageFileMetadata } from '@types' import { readFile } from 'fs/promises' import sharp from 'sharp' -const preprocessImage = async (buffer: Buffer) => { - return await sharp(buffer) +const preprocessImage = async (buffer: Buffer): Promise => { + return sharp(buffer) .grayscale() // 转为灰度 .normalize() .sharpen() + .png({ quality: 100 }) .toBuffer() } @@ -23,5 +24,5 @@ const preprocessImage = async (buffer: Buffer) => { */ export const loadOcrImage = async (file: ImageFileMetadata): Promise => { const buffer = await readFile(file.path) - return await preprocessImage(buffer) + return preprocessImage(buffer) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 65511574f9..2821702440 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,8 +4,8 @@ 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 { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/types' -import type { SelectionActionItem } from '@shared/data/types' +import type { PreferenceDefaultScopeType, PreferenceKeyType, SelectionActionItem } from '@shared/data/types' +import type { FileChangeEvent } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' import { AddMemoryOptions, @@ -80,6 +80,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 +142,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 +176,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), @@ -416,6 +433,10 @@ const api = { 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) + }, preference: { get: (key: K): Promise => ipcRenderer.invoke(IpcChannel.Preference_Get, key), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 27df3fda29..3395f55067 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3,6 +3,7 @@ import '@renderer/databases' import { preferenceService } from '@data/PreferenceService' 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' @@ -18,26 +19,38 @@ const logger = loggerService.withContext('App.tsx') preferenceService.loadAll() +// 创建 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/__tests__/index.clientCompatibilityTypes.test.ts b/src/renderer/src/aiCore/__tests__/index.clientCompatibilityTypes.test.ts index b69d761307..12571875db 100644 --- a/src/renderer/src/aiCore/__tests__/index.clientCompatibilityTypes.test.ts +++ b/src/renderer/src/aiCore/__tests__/index.clientCompatibilityTypes.test.ts @@ -1,9 +1,9 @@ -import { AihubmixAPIClient } from '@renderer/aiCore/clients/AihubmixAPIClient' +import { AihubmixAPIClient } from '@renderer/aiCore/clients/aihubmix/AihubmixAPIClient' import { AnthropicAPIClient } from '@renderer/aiCore/clients/anthropic/AnthropicAPIClient' import { ApiClientFactory } from '@renderer/aiCore/clients/ApiClientFactory' import { GeminiAPIClient } from '@renderer/aiCore/clients/gemini/GeminiAPIClient' import { VertexAPIClient } from '@renderer/aiCore/clients/gemini/VertexAPIClient' -import { NewAPIClient } from '@renderer/aiCore/clients/NewAPIClient' +import { NewAPIClient } from '@renderer/aiCore/clients/newapi/NewAPIClient' import { OpenAIAPIClient } from '@renderer/aiCore/clients/openai/OpenAIApiClient' import { OpenAIResponseAPIClient } from '@renderer/aiCore/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: [], @@ -32,7 +33,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/clients/ApiClientFactory.ts b/src/renderer/src/aiCore/clients/ApiClientFactory.ts index e708ab8c42..7c5575aa08 100644 --- a/src/renderer/src/aiCore/clients/ApiClientFactory.ts +++ b/src/renderer/src/aiCore/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 @@ -78,8 +92,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/clients/__tests__/ApiClientFactory.test.ts b/src/renderer/src/aiCore/clients/__tests__/ApiClientFactory.test.ts index 5ec3bf6404..4d58c78772 100644 --- a/src/renderer/src/aiCore/clients/__tests__/ApiClientFactory.test.ts +++ b/src/renderer/src/aiCore/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/clients/AihubmixAPIClient.ts b/src/renderer/src/aiCore/clients/aihubmix/AihubmixAPIClient.ts similarity index 88% rename from src/renderer/src/aiCore/clients/AihubmixAPIClient.ts rename to src/renderer/src/aiCore/clients/aihubmix/AihubmixAPIClient.ts index f27674174d..1149c04b35 100644 --- a/src/renderer/src/aiCore/clients/AihubmixAPIClient.ts +++ b/src/renderer/src/aiCore/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/clients/cherryin/CherryinAPIClient.ts b/src/renderer/src/aiCore/clients/cherryin/CherryinAPIClient.ts new file mode 100644 index 0000000000..bf3ed7d718 --- /dev/null +++ b/src/renderer/src/aiCore/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/clients/index.ts b/src/renderer/src/aiCore/clients/index.ts index ec7f9d9d7e..f364dbcee6 100644 --- a/src/renderer/src/aiCore/clients/index.ts +++ b/src/renderer/src/aiCore/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/clients/NewAPIClient.ts b/src/renderer/src/aiCore/clients/newapi/NewAPIClient.ts similarity index 89% rename from src/renderer/src/aiCore/clients/NewAPIClient.ts rename to src/renderer/src/aiCore/clients/newapi/NewAPIClient.ts index e87d54ae3e..58b349a2be 100644 --- a/src/renderer/src/aiCore/clients/NewAPIClient.ts +++ b/src/renderer/src/aiCore/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/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index 228398fea7..92e49cec53 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -46,6 +46,7 @@ import { EFFORT_RATIO, FileTypes, isSystemProvider, + isTranslateAssistant, MCPCallToolResponse, MCPTool, MCPToolResponse, @@ -54,7 +55,6 @@ import { Provider, SystemProviderIds, ToolCallResponse, - TranslateAssistant, WebSearchSource } from '@renderer/types' import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk' @@ -122,13 +122,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 +137,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 +147,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 +207,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 +225,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 + } } } } @@ -569,13 +575,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient< const extra_body: Record = {} if (isQwenMTModel(model)) { - const targetLanguage = (assistant as TranslateAssistant).targetLanguage - extra_body.translation_options = { - source_lang: 'auto', - target_lang: mapLanguageToQwenMTModel(targetLanguage!) - } - if (!extra_body.translation_options.target_lang) { - throw new Error(t('translate.error.not_supported', { language: targetLanguage?.value })) + if (isTranslateAssistant(assistant)) { + const targetLanguage = assistant.targetLanguage + const translationOptions = { + source_lang: 'auto', + target_lang: mapLanguageToQwenMTModel(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')) } } @@ -628,12 +639,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 } } } diff --git a/src/renderer/src/aiCore/clients/zhipu/ZhipuAPIClient.ts b/src/renderer/src/aiCore/clients/zhipu/ZhipuAPIClient.ts new file mode 100644 index 0000000000..c1d7b8f562 --- /dev/null +++ b/src/renderer/src/aiCore/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/index.ts b/src/renderer/src/aiCore/index.ts index 99eb6c940b..2b48137b24 100644 --- a/src/renderer/src/aiCore/index.ts +++ b/src/renderer/src/aiCore/index.ts @@ -9,9 +9,9 @@ import type { GenerateImageParams, Model, Provider } from '@renderer/types' import type { RequestOptions, SdkModel } from '@renderer/types/sdk' import { isEnabledToolUse } 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/middleware/common/ErrorHandlerMiddleware.ts b/src/renderer/src/aiCore/middleware/common/ErrorHandlerMiddleware.ts index 26d9342ebc..d80c9d2f83 100644 --- a/src/renderer/src/aiCore/middleware/common/ErrorHandlerMiddleware.ts +++ b/src/renderer/src/aiCore/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/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..f52d57a7c2 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -367,4 +367,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..3890f013ec --- /dev/null +++ b/src/renderer/src/assets/styles/richtext.scss @@ -0,0 +1,494 @@ +.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; + } + } + + /* 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; + } +} + +// 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/HtmlArtifactsCard.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx index acb4a9c4f1..13d13c55a9 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsCard.tsx @@ -2,7 +2,7 @@ import { CodeOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { useTheme } from '@renderer/context/ThemeProvider' import { ThemeMode } from '@renderer/types' -import { extractTitle } from '@renderer/utils/formats' +import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats' import { Button } from 'antd' import { Code, DownloadIcon, Globe, LinkIcon, Sparkles } from 'lucide-react' import { FC, useState } from 'react' @@ -28,7 +28,7 @@ const getTerminalStyles = (theme: ThemeMode) => ({ const HtmlArtifactsCard: FC = ({ html, onSave, isStreaming = false }) => { const { t } = useTranslation() - const title = extractTitle(html) || 'HTML Artifacts' + const title = extractHtmlTitle(html) || 'HTML Artifacts' const [isPopupOpen, setIsPopupOpen] = useState(false) const { theme } = useTheme() @@ -48,7 +48,7 @@ const HtmlArtifactsCard: FC = ({ html, onSave, isStreaming = false }) => } const handleDownload = async () => { - const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html` + const fileName = `${getFileNameFromHtmlTitle(title) || 'html-artifact'}.html` await window.api.file.save(fileName, htmlContent) window.message.success({ content: t('message.download.success'), key: 'download' }) } diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index 216e247701..d072e88c4b 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -1,9 +1,13 @@ import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor' +import { CopyIcon, FilePngIcon } from '@renderer/components/Icons' import { isLinux, isMac, isWin } from '@renderer/config/constant' +import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import { classNames } from '@renderer/utils' -import { Button, Modal, Splitter, Tooltip, Typography } from 'antd' -import { Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' +import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats' +import { captureScrollableIframeAsBlob, captureScrollableIframeAsDataURL } from '@renderer/utils/image' +import { Button, Dropdown, Modal, Splitter, Tooltip, Typography } from 'antd' +import { Camera, Check, Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -21,7 +25,9 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht const { t } = useTranslation() const [viewMode, setViewMode] = useState('split') const [isFullscreen, setIsFullscreen] = useState(false) + const [saved, setSaved] = useTemporaryValue(false, 2000) const codeEditorRef = useRef(null) + const previewFrameRef = useRef(null) // Prevent body scroll when fullscreen useEffect(() => { @@ -38,8 +44,32 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht const handleSave = () => { codeEditorRef.current?.save?.() + setSaved(true) } + const handleCapture = useCallback( + async (to: 'file' | 'clipboard') => { + const title = extractHtmlTitle(html) + const fileName = getFileNameFromHtmlTitle(title) || 'html-artifact' + + if (to === 'file') { + const dataUrl = await captureScrollableIframeAsDataURL(previewFrameRef) + if (dataUrl) { + window.api.file.saveImage(fileName, dataUrl) + } + } + if (to === 'clipboard') { + await captureScrollableIframeAsBlob(previewFrameRef, async (blob) => { + if (blob) { + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) + window.message.success(t('message.copy.success')) + } + }) + } + }, + [html, t] + ) + const renderHeader = () => ( setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}> @@ -47,7 +77,7 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht - + e.stopPropagation()}> = ({ open, title, ht - + e.stopPropagation()}> + , + onClick: () => handleCapture('file') + }, + { + label: t('html_artifacts.capture.to_clipboard'), + key: 'capture_to_clipboard', + icon: , + onClick: () => handleCapture('clipboard') + } + ] + }}> + + + + + + + + + + +`; 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: ( - + + + + ) + } + ] + + 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 +526,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 && ( + +