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 @@
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
+
+[](https://www.npmjs.com/package/@tiptap/extension-table)
+[](https://npmcharts.com/compare/tiptap?minimal=true)
+[](https://www.npmjs.com/package/@tiptap/extension-table)
+[](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')
+ }
+ ]
+ }}>
+
+ } className="nodrag" />
+
+