Merge branch 'main' of github.com:CherryHQ/cherry-studio into wip/data-refactor
15
.github/workflows/nightly-build.yml
vendored
@ -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
|
||||
|
||||
3
.github/workflows/release.yml
vendored
@ -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 }}
|
||||
|
||||
@ -7,3 +7,4 @@ tsconfig.*.json
|
||||
CHANGELOG*.md
|
||||
agents.json
|
||||
src/renderer/src/integration/nutstore/sso/lib
|
||||
src/main/integration/cherryin/index.js
|
||||
|
||||
48
.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch
vendored
Normal file
@ -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;
|
||||
@ -57,7 +57,7 @@
|
||||
|
||||
<div align="center">
|
||||
<a href="https://hellogithub.com/repository/1605492e1e2a4df3be07abfa4578dd37" target="_blank" style="text-decoration: none"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=1605492e1e2a4df3be07abfa4578dd37" alt="Featured|HelloGitHub" width="220" height="55" /></a>
|
||||
<a href="https://trendshift.io/repositories/11772" target="_blank" style="text-decoration: none"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" width="220" height="55" /></a>
|
||||
<a href="https://trendshift.io/repositories/14318" target="_blank" style="text-decoration: none"><img src="https://trendshift.io/api/badge/repositories/14318" alt="CherryHQ%2Fcherry-studio | Trendshift" width="220" height="55" /></a>
|
||||
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry-studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry Studio - AI Chatbots, AI Desktop Client | Product Hunt" width="220" height="55" /></a>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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 相关问题
|
||||
- 其他稳定性改进
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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'
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
50
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"
|
||||
},
|
||||
|
||||
1457
packages/extension-table-plus/CHANGELOG.md
Executable file
18
packages/extension-table-plus/README.md
Executable file
@ -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).
|
||||
93
packages/extension-table-plus/package.json
Executable file
@ -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"
|
||||
}
|
||||
1
packages/extension-table-plus/src/cell/index.ts
Executable file
@ -0,0 +1 @@
|
||||
export * from './table-cell.js'
|
||||
150
packages/extension-table-plus/src/cell/table-cell.ts
Executable file
@ -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<string, any>
|
||||
/**
|
||||
* 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<TableCellOptions>({
|
||||
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)
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
1
packages/extension-table-plus/src/header/index.ts
Executable file
@ -0,0 +1 @@
|
||||
export * from './table-header.js'
|
||||
60
packages/extension-table-plus/src/header/table-header.ts
Executable file
@ -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<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension allows you to create table headers.
|
||||
* @see https://www.tiptap.dev/api/nodes/table-header
|
||||
*/
|
||||
export const TableHeader = Node.create<TableHeaderOptions>({
|
||||
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]
|
||||
}
|
||||
})
|
||||
6
packages/extension-table-plus/src/index.ts
Executable file
@ -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'
|
||||
64
packages/extension-table-plus/src/kit/index.ts
Executable file
@ -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<TableOptions> | false
|
||||
/**
|
||||
* If set to false, the table extension will not be registered
|
||||
* @example tableCell: false
|
||||
*/
|
||||
tableCell: Partial<TableCellOptions> | false
|
||||
/**
|
||||
* If set to false, the table extension will not be registered
|
||||
* @example tableHeader: false
|
||||
*/
|
||||
tableHeader: Partial<TableHeaderOptions> | false
|
||||
/**
|
||||
* If set to false, the table extension will not be registered
|
||||
* @example tableRow: false
|
||||
*/
|
||||
tableRow: Partial<TableRowOptions> | 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<TableKitOptions>({
|
||||
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
|
||||
}
|
||||
})
|
||||
1
packages/extension-table-plus/src/row/index.ts
Executable file
@ -0,0 +1 @@
|
||||
export * from './table-row.js'
|
||||
38
packages/extension-table-plus/src/row/table-row.ts
Executable file
@ -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<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* This extension allows you to create table rows.
|
||||
* @see https://www.tiptap.dev/api/nodes/table-row
|
||||
*/
|
||||
export const TableRow = Node.create<TableRowOptions>({
|
||||
name: 'tableRow',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
}
|
||||
},
|
||||
|
||||
content: '(tableCell | tableHeader)*',
|
||||
|
||||
tableRole: 'row',
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'tr' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['tr', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
|
||||
}
|
||||
})
|
||||
558
packages/extension-table-plus/src/table/TableView.ts
Executable file
@ -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, // <colgroup> has the same prototype as <col>
|
||||
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)
|
||||
}
|
||||
}
|
||||
3
packages/extension-table-plus/src/table/index.ts
Executable file
@ -0,0 +1,3 @@
|
||||
export * from './table.js'
|
||||
export * from './utilities/createColGroup.js'
|
||||
export * from './utilities/createTable.js'
|
||||
486
packages/extension-table-plus/src/table/table.ts
Executable file
@ -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<string, any>
|
||||
|
||||
/**
|
||||
* 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<ReturnType> {
|
||||
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<TableOptions>({
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
9
packages/extension-table-plus/src/table/utilities/colStyle.ts
Executable file
@ -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`]
|
||||
}
|
||||
12
packages/extension-table-plus/src/table/utilities/createCell.ts
Executable file
@ -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>
|
||||
): ProsemirrorNode | null | undefined {
|
||||
if (cellContent) {
|
||||
return cellType.createChecked(null, cellContent)
|
||||
}
|
||||
|
||||
return cellType.createAndFill()
|
||||
}
|
||||
68
packages/extension-table-plus/src/table/utilities/createColGroup.ts
Executable file
@ -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<string, never>
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
40
packages/extension-table-plus/src/table/utilities/createTable.ts
Executable file
@ -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>
|
||||
): 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
21
packages/extension-table-plus/src/table/utilities/getTableNodeTypes.ts
Executable file
@ -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
|
||||
}
|
||||
5
packages/extension-table-plus/src/table/utilities/isCellSelection.ts
Executable file
@ -0,0 +1,5 @@
|
||||
import { CellSelection } from '@tiptap/pm/tables'
|
||||
|
||||
export function isCellSelection(value: unknown): value is CellSelection {
|
||||
return value instanceof CellSelection
|
||||
}
|
||||
@ -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<typeof TableMap.get>
|
||||
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 }
|
||||
}
|
||||
19
packages/extension-table-plus/src/types.ts
Executable file
@ -0,0 +1,19 @@
|
||||
import type { ParentConfig } from '@tiptap/core'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface NodeConfig<Options, Storage> {
|
||||
/**
|
||||
* 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<NodeConfig<Options>>['tableRole']
|
||||
}) => string)
|
||||
}
|
||||
}
|
||||
20
packages/extension-table-plus/tsdown.config.ts
Executable file
@ -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: [/^[^./]/]
|
||||
}))
|
||||
)
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -2020,6 +2020,10 @@ export const languages: Record<string, LanguageData> = {
|
||||
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<string, LanguageData> = {
|
||||
},
|
||||
SWIG: {
|
||||
type: 'programming',
|
||||
extensions: ['.i']
|
||||
extensions: ['.i', '.swg', '.swig']
|
||||
},
|
||||
SystemVerilog: {
|
||||
type: 'programming',
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
1
src/main/integration/cherryin/index.js
Normal file
@ -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};
|
||||
@ -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<typeof ocrService.ocr>) => ocrService.ocr(...args))
|
||||
|
||||
// CherryIN
|
||||
ipcMain.handle(IpcChannel.Cherryin_GetSignature, (_, params) => generateSignature(params))
|
||||
|
||||
// Preference handlers
|
||||
PreferenceService.registerIpcHandler()
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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<FileWatcherConfig> = {
|
||||
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<FileWatcherConfig> = 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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<string> => {
|
||||
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<string> => {
|
||||
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<string> => {
|
||||
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<FileMetadata> => {
|
||||
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<void> {
|
||||
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<void> => {
|
||||
await fs.promises.rm(this.storageDir, { recursive: true })
|
||||
await this.initStorageDir()
|
||||
this.initStorageDir()
|
||||
}
|
||||
|
||||
public clearTemp = async (): Promise<void> => {
|
||||
@ -432,6 +730,7 @@ class FileStorage {
|
||||
|
||||
/**
|
||||
* 通过相对路径打开文件,跨设备时使用
|
||||
* @param _
|
||||
* @param file
|
||||
*/
|
||||
public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<void> => {
|
||||
@ -443,6 +742,79 @@ class FileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
public getDirectoryStructure = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<NotesTreeNode[]> => {
|
||||
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<boolean> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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))
|
||||
|
||||
5
src/main/services/ocr/builtin/OcrBaseService.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { OcrHandler } from '@types'
|
||||
|
||||
export abstract class OcrBaseService {
|
||||
abstract ocr: OcrHandler
|
||||
}
|
||||
39
src/main/services/ocr/builtin/SystemOcrService.ts
Normal file
@ -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<OcrResult> {
|
||||
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<OcrResult> => {
|
||||
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()
|
||||
115
src/main/services/ocr/builtin/TesseractService.ts
Normal file
@ -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<Tesseract.Worker> {
|
||||
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<Tesseract.Worker>((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<OcrResult> {
|
||||
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<OcrResult> => {
|
||||
if (!isImageFileMetadata(file)) {
|
||||
throw new Error('Only image files are supported currently')
|
||||
}
|
||||
return this.imageOcr(file, options)
|
||||
}
|
||||
|
||||
private async _getLangPath(): Promise<string> {
|
||||
const country = await getIpCountry()
|
||||
return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : ''
|
||||
}
|
||||
|
||||
private async _getCacheDir(): Promise<string> {
|
||||
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<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.terminate()
|
||||
this.worker = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const tesseractService = new TesseractService()
|
||||
@ -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<Tesseract.Worker> {
|
||||
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<OcrResult> {
|
||||
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<OcrResult> {
|
||||
if (!isImageFile(file)) {
|
||||
throw new Error('Only image files are supported currently')
|
||||
}
|
||||
return this.imageOcr(file)
|
||||
}
|
||||
|
||||
private async _getLangPath(): Promise<string> {
|
||||
const country = await getIpCountry()
|
||||
return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : TesseractLangsDownloadUrl.GLOBAL
|
||||
}
|
||||
|
||||
private async _getCacheDir(): Promise<string> {
|
||||
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<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.terminate()
|
||||
this.worker = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const tesseractService = new TesseractService()
|
||||
@ -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<st
|
||||
logger.error(`File ${filePath} failed to decode with all possible encodings, trying UTF-8 encoding`)
|
||||
return iconv.decode(data, 'UTF-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归扫描目录,获取符合条件的文件和目录结构
|
||||
* @param dirPath 当前要扫描的路径
|
||||
* @param depth 当前深度
|
||||
* @param basePath
|
||||
* @returns 文件元数据数组
|
||||
*/
|
||||
export async function scanDir(dirPath: string, depth = 0, basePath?: string): Promise<NotesTreeNode[]> {
|
||||
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
|
||||
}
|
||||
|
||||
@ -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<Buffer> => {
|
||||
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<Buffer> => {
|
||||
const buffer = await readFile(file.path)
|
||||
return await preprocessImage(buffer)
|
||||
return preprocessImage(buffer)
|
||||
}
|
||||
|
||||
@ -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<void> => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value),
|
||||
isFullScreen: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_IsFullScreen),
|
||||
mac: {
|
||||
isProcessTrusted: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
|
||||
requestProcessTrust: (): Promise<boolean> => 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<FileMetadata | null> => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
|
||||
/**
|
||||
* 创建一个空的临时文件
|
||||
* @param fileName 文件名
|
||||
* @returns 临时文件路径
|
||||
*/
|
||||
createTempFile: (fileName: string): Promise<string> => 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<boolean> => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath)
|
||||
isTextFile: (filePath: string): Promise<boolean> => 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<OcrResult> =>
|
||||
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
|
||||
},
|
||||
cherryin: {
|
||||
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
|
||||
ipcRenderer.invoke(IpcChannel.Cherryin_GetSignature, params)
|
||||
},
|
||||
preference: {
|
||||
get: <K extends PreferenceKeyType>(key: K): Promise<PreferenceDefaultScopeType[K]> =>
|
||||
ipcRenderer.invoke(IpcChannel.Preference_Get, key),
|
||||
|
||||
@ -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 (
|
||||
<Provider store={store}>
|
||||
<StyleSheetManager>
|
||||
<ThemeProvider>
|
||||
<AntdProvider>
|
||||
<NotificationProvider>
|
||||
<CodeStyleProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<Router />
|
||||
</TopViewContainer>
|
||||
</PersistGate>
|
||||
</CodeStyleProvider>
|
||||
</NotificationProvider>
|
||||
</AntdProvider>
|
||||
</ThemeProvider>
|
||||
</StyleSheetManager>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StyleSheetManager>
|
||||
<ThemeProvider>
|
||||
<AntdProvider>
|
||||
<NotificationProvider>
|
||||
<CodeStyleProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<Router />
|
||||
</TopViewContainer>
|
||||
</PersistGate>
|
||||
</CodeStyleProvider>
|
||||
</NotificationProvider>
|
||||
</AntdProvider>
|
||||
</ThemeProvider>
|
||||
</StyleSheetManager>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 = () => {
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/notes" element={<NotesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<MinAppsPage />} />
|
||||
<Route path="/code" element={<CodeToolsPage />} />
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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)
|
||||
// }
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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
|
||||
@ -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<OpenAISdkRawOutput> {
|
||||
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<OpenAI.Models.Model[]> {
|
||||
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
|
||||
}))
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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<string, any> = {}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
100
src/renderer/src/aiCore/clients/zhipu/ZhipuAPIClient.ts
Normal file
@ -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<string[]> {
|
||||
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<OpenAI.Models.Model[]> {
|
||||
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
|
||||
}))
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 19 KiB |
BIN
src/renderer/src/assets/images/providers/cherryin.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 19 KiB |
BIN
src/renderer/src/assets/images/search/zhipu.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
59
src/renderer/src/assets/styles/CommandListPopover.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -367,4 +367,9 @@ mjx-container {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-announced {
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
494
src/renderer/src/assets/styles/richtext.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<Props> = ({ 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<Props> = ({ 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' })
|
||||
}
|
||||
|
||||
@ -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<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
const { t } = useTranslation()
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [saved, setSaved] = useTemporaryValue(false, 2000)
|
||||
const codeEditorRef = useRef<CodeEditorHandles>(null)
|
||||
const previewFrameRef = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
// Prevent body scroll when fullscreen
|
||||
useEffect(() => {
|
||||
@ -38,8 +44,32 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ 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 = () => (
|
||||
<ModalHeader onDoubleClick={() => setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}>
|
||||
<HeaderLeft $isFullscreen={isFullscreen}>
|
||||
@ -47,7 +77,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
</HeaderLeft>
|
||||
|
||||
<HeaderCenter>
|
||||
<ViewControls>
|
||||
<ViewControls onDoubleClick={(e) => e.stopPropagation()}>
|
||||
<ViewButton
|
||||
size="small"
|
||||
type={viewMode === 'split' ? 'primary' : 'default'}
|
||||
@ -72,7 +102,29 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
</ViewControls>
|
||||
</HeaderCenter>
|
||||
|
||||
<HeaderRight $isFullscreen={isFullscreen}>
|
||||
<HeaderRight $isFullscreen={isFullscreen} onDoubleClick={(e) => e.stopPropagation()}>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: t('html_artifacts.capture.to_file'),
|
||||
key: 'capture_to_file',
|
||||
icon: <FilePngIcon size={14} className="lucide-custom" />,
|
||||
onClick: () => handleCapture('file')
|
||||
},
|
||||
{
|
||||
label: t('html_artifacts.capture.to_clipboard'),
|
||||
key: 'capture_to_clipboard',
|
||||
icon: <CopyIcon size={14} className="lucide-custom" />,
|
||||
onClick: () => handleCapture('clipboard')
|
||||
}
|
||||
]
|
||||
}}>
|
||||
<Tooltip title={t('html_artifacts.capture.label')} mouseLeaveDelay={0}>
|
||||
<Button type="text" icon={<Camera size={16} />} className="nodrag" />
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
<Button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
type="text"
|
||||
@ -93,9 +145,10 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
language="html"
|
||||
editable={true}
|
||||
onSave={onSave}
|
||||
style={{ height: '100%' }}
|
||||
expanded
|
||||
unwrapped={false}
|
||||
height="100%"
|
||||
expanded={false}
|
||||
wrapped
|
||||
style={{ minHeight: 0 }}
|
||||
options={{
|
||||
stream: true, // FIXME: 避免多余空行
|
||||
lineNumbers: true,
|
||||
@ -104,10 +157,16 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
/>
|
||||
<ToolbarWrapper>
|
||||
<Tooltip title={t('code_block.edit.save.label')} mouseLeaveDelay={0}>
|
||||
<Button
|
||||
<ToolbarButton
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<SaveIcon size={16} className="custom-lucide" />}
|
||||
icon={
|
||||
saved ? (
|
||||
<Check size={16} color="var(--color-status-success)" />
|
||||
) : (
|
||||
<SaveIcon size={16} className="custom-lucide" />
|
||||
)
|
||||
}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
</Tooltip>
|
||||
@ -119,6 +178,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
<PreviewSection>
|
||||
{html.trim() ? (
|
||||
<PreviewFrame
|
||||
ref={previewFrameRef}
|
||||
key={html} // Force recreate iframe when preview content changes
|
||||
srcDoc={html}
|
||||
title="HTML Preview"
|
||||
@ -329,12 +389,8 @@ const CodeSection = styled.div`
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.monaco-editor,
|
||||
.cm-editor,
|
||||
.cm-scroller {
|
||||
height: 100% !important;
|
||||
}
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
`
|
||||
|
||||
const PreviewSection = styled.div`
|
||||
@ -373,4 +429,12 @@ const ToolbarWrapper = styled.div`
|
||||
z-index: 1;
|
||||
`
|
||||
|
||||
const ToolbarButton = styled(Button)`
|
||||
border: none;
|
||||
box-shadow:
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
`
|
||||
|
||||
export default HtmlArtifactsPopup
|
||||
|
||||
@ -19,7 +19,7 @@ import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { pyodideService } from '@renderer/services/PyodideService'
|
||||
import { getExtensionByLanguage } from '@renderer/utils/code-language'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { extractHtmlTitle } from '@renderer/utils/formats'
|
||||
import dayjs from 'dayjs'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -100,7 +100,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
}, [hasSpecialView, viewMode])
|
||||
|
||||
const [expandOverride, setExpandOverride] = useState(!codeCollapsible)
|
||||
const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable)
|
||||
const [wrapOverride, setWrapOverride] = useState(codeWrappable)
|
||||
|
||||
// 重置用户操作
|
||||
useEffect(() => {
|
||||
@ -109,11 +109,11 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
|
||||
// 重置用户操作
|
||||
useEffect(() => {
|
||||
setUnwrapOverride(!codeWrappable)
|
||||
setWrapOverride(codeWrappable)
|
||||
}, [codeWrappable])
|
||||
|
||||
const shouldExpand = useMemo(() => !codeCollapsible || expandOverride, [codeCollapsible, expandOverride])
|
||||
const shouldUnwrap = useMemo(() => !codeWrappable || unwrapOverride, [codeWrappable, unwrapOverride])
|
||||
const shouldWrap = useMemo(() => codeWrappable && wrapOverride, [codeWrappable, wrapOverride])
|
||||
|
||||
const [sourceScrollHeight, setSourceScrollHeight] = useState(0)
|
||||
const expandable = useMemo(() => {
|
||||
@ -136,7 +136,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
|
||||
// 尝试提取 HTML 标题
|
||||
if (language === 'html' && children.includes('</html>')) {
|
||||
fileName = extractTitle(children) || ''
|
||||
fileName = extractHtmlTitle(children) || ''
|
||||
}
|
||||
|
||||
// 默认使用日期格式命名
|
||||
@ -225,9 +225,9 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
// 源代码视图的自动换行按钮
|
||||
useWrapTool({
|
||||
enabled: !isInSpecialView,
|
||||
unwrapped: shouldUnwrap,
|
||||
wrapped: shouldWrap,
|
||||
wrappable: codeWrappable,
|
||||
toggle: useCallback(() => setUnwrapOverride((prev) => !prev), []),
|
||||
toggle: useCallback(() => setWrapOverride((prev) => !prev), []),
|
||||
setTools
|
||||
})
|
||||
|
||||
@ -249,21 +249,22 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
language={language}
|
||||
onSave={onSave}
|
||||
onHeightChange={handleHeightChange}
|
||||
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
|
||||
options={{ stream: true }}
|
||||
expanded={shouldExpand}
|
||||
unwrapped={shouldUnwrap}
|
||||
wrapped={shouldWrap}
|
||||
/>
|
||||
) : (
|
||||
<CodeViewer
|
||||
className="source-view"
|
||||
language={language}
|
||||
expanded={shouldExpand}
|
||||
unwrapped={shouldUnwrap}
|
||||
wrapped={shouldWrap}
|
||||
onHeightChange={handleHeightChange}>
|
||||
{children}
|
||||
</CodeViewer>
|
||||
),
|
||||
[children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldUnwrap]
|
||||
[children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldWrap]
|
||||
)
|
||||
|
||||
// 特殊视图组件映射
|
||||
@ -344,7 +345,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
|
||||
}
|
||||
`
|
||||
|
||||
const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
|
||||
const CodeHeader = styled.div<{ $isInSpecialView?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
@ -370,7 +371,11 @@ const SplitViewWrapper = styled.div<{ $isSpecialView: boolean; $isSplitView: boo
|
||||
&:not(:has(+ [class*='Container'])) {
|
||||
// 特殊视图的 header 会隐藏,所以全都使用圆角
|
||||
border-radius: ${(props) => (props.$isSpecialView ? '8px' : '0 0 8px 8px')};
|
||||
overflow: hidden;
|
||||
// FIXME: 滚动条边缘会溢出,可以考虑增加 padding,但是要保证代码主题颜色铺满容器。
|
||||
// overflow: hidden;
|
||||
.code-viewer {
|
||||
border-radius: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
// 在 split 模式下添加中间分隔线
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getNormalizedExtension } from '../utils'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getExtensionByLanguage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/utils/code-language', () => ({
|
||||
getExtensionByLanguage: mocks.getExtensionByLanguage
|
||||
}))
|
||||
|
||||
describe('getNormalizedExtension', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return custom mapping for custom language', async () => {
|
||||
mocks.getExtensionByLanguage.mockReturnValue(undefined)
|
||||
await expect(getNormalizedExtension('svg')).resolves.toBe('xml')
|
||||
await expect(getNormalizedExtension('SVG')).resolves.toBe('xml')
|
||||
})
|
||||
|
||||
it('should prefer custom mapping when both custom and linguist exist', async () => {
|
||||
mocks.getExtensionByLanguage.mockReturnValue('.svg')
|
||||
await expect(getNormalizedExtension('svg')).resolves.toBe('xml')
|
||||
})
|
||||
|
||||
it('should return linguist mapping when available (strip leading dot)', async () => {
|
||||
mocks.getExtensionByLanguage.mockReturnValue('.ts')
|
||||
await expect(getNormalizedExtension('TypeScript')).resolves.toBe('ts')
|
||||
})
|
||||
|
||||
it('should return extension when input already looks like extension (leading dot)', async () => {
|
||||
mocks.getExtensionByLanguage.mockReturnValue(undefined)
|
||||
await expect(getNormalizedExtension('.json')).resolves.toBe('json')
|
||||
})
|
||||
|
||||
it('should return language as-is when no rules matched', async () => {
|
||||
mocks.getExtensionByLanguage.mockReturnValue(undefined)
|
||||
await expect(getNormalizedExtension('unknownLanguage')).resolves.toBe('unknownLanguage')
|
||||
})
|
||||
})
|
||||
@ -8,7 +8,9 @@ import { getNormalizedExtension } from './utils'
|
||||
|
||||
const logger = loggerService.withContext('CodeEditorHooks')
|
||||
|
||||
// 语言对应的 linter 加载器
|
||||
/** 语言对应的 linter 加载器
|
||||
* key: 语言文件扩展名(不包含 `.`)
|
||||
*/
|
||||
const linterLoaders: Record<string, () => Promise<any>> = {
|
||||
json: async () => {
|
||||
const jsonParseLinter = await import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter)
|
||||
@ -64,13 +66,15 @@ async function loadLanguageExtension(language: string): Promise<Extension | null
|
||||
* 加载 linter 扩展
|
||||
*/
|
||||
async function loadLinterExtension(language: string): Promise<Extension | null> {
|
||||
const loader = linterLoaders[language]
|
||||
const fileExt = await getNormalizedExtension(language)
|
||||
|
||||
const loader = linterLoaders[fileExt]
|
||||
if (!loader) return null
|
||||
|
||||
try {
|
||||
return await loader()
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to load linter for ${language}`, error as Error)
|
||||
logger.debug(`Failed to load linter for ${language} (${fileExt})`, error as Error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror'
|
||||
@ -15,39 +14,87 @@ export interface CodeEditorHandles {
|
||||
save?: () => void
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
export interface CodeEditorProps {
|
||||
ref?: React.RefObject<CodeEditorHandles | null>
|
||||
/** Value used in controlled mode, e.g., code blocks. */
|
||||
value: string
|
||||
/** Placeholder when the editor content is empty. */
|
||||
placeholder?: string | HTMLElement
|
||||
/**
|
||||
* Code language string.
|
||||
* - Case-insensitive.
|
||||
* - Supports common names: javascript, json, python, etc.
|
||||
* - Supports aliases: c#/csharp, objective-c++/obj-c++/objc++, etc.
|
||||
* - Supports file extensions: .cpp/cpp, .js/js, .py/py, etc.
|
||||
*/
|
||||
language: string
|
||||
/** Fired when ref.save() is called or the save shortcut is triggered. */
|
||||
onSave?: (newContent: string) => void
|
||||
/** Fired when the editor content changes. */
|
||||
onChange?: (newContent: string) => void
|
||||
/** Fired when the editor loses focus. */
|
||||
onBlur?: (newContent: string) => void
|
||||
/** Fired when the editor height changes. */
|
||||
onHeightChange?: (scrollHeight: number) => void
|
||||
/**
|
||||
* Fixed editor height, not exceeding maxHeight.
|
||||
* Only works when expanded is false.
|
||||
*/
|
||||
height?: string
|
||||
minHeight?: string
|
||||
/**
|
||||
* Maximum editor height.
|
||||
* Only works when expanded is false.
|
||||
*/
|
||||
maxHeight?: string
|
||||
/** Minimum editor height. */
|
||||
minHeight?: string
|
||||
/** Font size that overrides the app setting. */
|
||||
fontSize?: string
|
||||
/** 用于覆写编辑器的某些设置 */
|
||||
/** Editor options that extend BasicSetupOptions. */
|
||||
options?: {
|
||||
stream?: boolean // 用于流式响应场景,默认 false
|
||||
/**
|
||||
* Whether to enable special treatment for stream response.
|
||||
* @default false
|
||||
*/
|
||||
stream?: boolean
|
||||
/**
|
||||
* Whether to enable linting.
|
||||
* @default false
|
||||
*/
|
||||
lint?: boolean
|
||||
/**
|
||||
* Whether to enable keymap.
|
||||
* @default false
|
||||
*/
|
||||
keymap?: boolean
|
||||
} & BasicSetupOptions
|
||||
/** 用于追加 extensions */
|
||||
/** Additional extensions for CodeMirror. */
|
||||
extensions?: Extension[]
|
||||
/** 用于覆写编辑器的样式,会直接传给 CodeMirror 的 style 属性 */
|
||||
/** Style overrides for the editor, passed directly to CodeMirror's style property. */
|
||||
style?: React.CSSProperties
|
||||
/** CSS class name appended to the default `code-editor` class. */
|
||||
className?: string
|
||||
/**
|
||||
* Whether the editor is editable.
|
||||
* @default true
|
||||
*/
|
||||
editable?: boolean
|
||||
/**
|
||||
* Whether the editor is expanded.
|
||||
* If true, the height and maxHeight props are ignored.
|
||||
* @default true
|
||||
*/
|
||||
expanded?: boolean
|
||||
unwrapped?: boolean
|
||||
/**
|
||||
* Whether the code lines are wrapped.
|
||||
* @default true
|
||||
*/
|
||||
wrapped?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 源代码编辑器,基于 CodeMirror,封装了 ReactCodeMirror。
|
||||
*
|
||||
* 目前必须和 CodeToolbar 配合使用。
|
||||
* A code editor component based on CodeMirror.
|
||||
* This is a wrapper of ReactCodeMirror.
|
||||
*/
|
||||
const CodeEditor = ({
|
||||
ref,
|
||||
@ -59,8 +106,8 @@ const CodeEditor = ({
|
||||
onBlur,
|
||||
onHeightChange,
|
||||
height,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
minHeight,
|
||||
fontSize,
|
||||
options,
|
||||
extensions,
|
||||
@ -68,7 +115,7 @@ const CodeEditor = ({
|
||||
className,
|
||||
editable = true,
|
||||
expanded = true,
|
||||
unwrapped = false
|
||||
wrapped = true
|
||||
}: CodeEditorProps) => {
|
||||
const { fontSize: _fontSize, codeShowLineNumbers: _lineNumbers, codeEditor } = useSettings()
|
||||
const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap])
|
||||
@ -121,12 +168,12 @@ const CodeEditor = ({
|
||||
return [
|
||||
...(extensions ?? []),
|
||||
...langExtensions,
|
||||
...(unwrapped ? [] : [EditorView.lineWrapping]),
|
||||
...(wrapped ? [EditorView.lineWrapping] : []),
|
||||
saveKeymapExtension,
|
||||
blurExtension,
|
||||
heightListenerExtension
|
||||
].flat()
|
||||
}, [extensions, langExtensions, unwrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
|
||||
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: handleSave
|
||||
@ -138,9 +185,9 @@ const CodeEditor = ({
|
||||
value={initialContent.current}
|
||||
placeholder={placeholder}
|
||||
width="100%"
|
||||
height={height}
|
||||
height={expanded ? undefined : height}
|
||||
maxHeight={expanded ? undefined : maxHeight}
|
||||
minHeight={minHeight}
|
||||
maxHeight={expanded ? 'none' : (maxHeight ?? `${MAX_COLLAPSED_CODE_HEIGHT}px`)}
|
||||
editable={editable}
|
||||
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
|
||||
theme={activeCmTheme}
|
||||
|
||||
@ -13,6 +13,7 @@ const _customLanguageExtensions: Record<string, string> = {
|
||||
* 获取语言的扩展名,用于 @uiw/codemirror-extensions-langs
|
||||
* - 先搜索自定义扩展名
|
||||
* - 再搜索 github linguist 扩展名
|
||||
* - 最后假定名称已经是扩展名
|
||||
* @param language 语言名称
|
||||
* @returns 扩展名(不包含 `.`)
|
||||
*/
|
||||
@ -29,6 +30,11 @@ export async function getNormalizedExtension(language: string) {
|
||||
return linguistExt.slice(1)
|
||||
}
|
||||
|
||||
// 如果语言名称像扩展名
|
||||
if (language.startsWith('.') && language.length > 1) {
|
||||
return language.slice(1)
|
||||
}
|
||||
|
||||
// 回退到语言名称
|
||||
return language
|
||||
}
|
||||
|
||||
@ -50,7 +50,7 @@ describe('useWrapTool', () => {
|
||||
const createMockProps = (overrides: Partial<Parameters<typeof useWrapTool>[0]> = {}) => {
|
||||
const defaultProps = {
|
||||
enabled: true,
|
||||
unwrapped: false,
|
||||
wrapped: true,
|
||||
wrappable: true,
|
||||
toggle: vi.fn(),
|
||||
setTools: vi.fn()
|
||||
@ -90,8 +90,8 @@ describe('useWrapTool', () => {
|
||||
expect(mockRegisterTool).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should re-register tool when unwrapped changes', () => {
|
||||
const props = createMockProps({ unwrapped: false })
|
||||
it('should re-register tool when wrapped changes', () => {
|
||||
const props = createMockProps({ wrapped: true })
|
||||
const { rerender } = renderHook((hookProps) => useWrapTool(hookProps), {
|
||||
initialProps: props
|
||||
})
|
||||
@ -100,8 +100,8 @@ describe('useWrapTool', () => {
|
||||
const firstCall = mockRegisterTool.mock.calls[0][0]
|
||||
expect(firstCall.tooltip).toBe('code_block.wrap.off')
|
||||
|
||||
// Change unwrapped to true and rerender
|
||||
const newProps = { ...props, unwrapped: true }
|
||||
// Change wrapped to false and rerender
|
||||
const newProps = { ...props, wrapped: false }
|
||||
rerender(newProps)
|
||||
|
||||
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
|
||||
|
||||
@ -5,13 +5,13 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface UseWrapToolProps {
|
||||
enabled?: boolean
|
||||
unwrapped?: boolean
|
||||
wrapped?: boolean
|
||||
wrappable?: boolean
|
||||
toggle: () => void
|
||||
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
|
||||
}
|
||||
|
||||
export const useWrapTool = ({ enabled, unwrapped, wrappable, toggle, setTools }: UseWrapToolProps) => {
|
||||
export const useWrapTool = ({ enabled, wrapped, wrappable, toggle, setTools }: UseWrapToolProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { registerTool, removeTool } = useToolManager(setTools)
|
||||
|
||||
@ -23,13 +23,13 @@ export const useWrapTool = ({ enabled, unwrapped, wrappable, toggle, setTools }:
|
||||
if (enabled) {
|
||||
registerTool({
|
||||
...TOOL_SPECS.wrap,
|
||||
icon: unwrapped ? <WrapIcon className="tool-icon" /> : <UnWrapIcon className="tool-icon" />,
|
||||
tooltip: unwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
|
||||
icon: wrapped ? <UnWrapIcon className="tool-icon" /> : <WrapIcon className="tool-icon" />,
|
||||
tooltip: wrapped ? t('code_block.wrap.off') : t('code_block.wrap.on'),
|
||||
visible: () => wrappable ?? false,
|
||||
onClick: handleToggle
|
||||
})
|
||||
}
|
||||
|
||||
return () => removeTool(TOOL_SPECS.wrap.id)
|
||||
}, [enabled, handleToggle, registerTool, removeTool, t, unwrapped, wrappable])
|
||||
}, [enabled, handleToggle, registerTool, removeTool, t, wrapped, wrappable])
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ interface CodeViewerProps {
|
||||
language: string
|
||||
children: string
|
||||
expanded?: boolean
|
||||
unwrapped?: boolean
|
||||
wrapped?: boolean
|
||||
onHeightChange?: (scrollHeight: number) => void
|
||||
className?: string
|
||||
}
|
||||
@ -25,7 +25,7 @@ interface CodeViewerProps {
|
||||
* - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应
|
||||
* - 并发安全
|
||||
*/
|
||||
const CodeViewer = ({ children, language, expanded, unwrapped, onHeightChange, className }: CodeViewerProps) => {
|
||||
const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, className }: CodeViewerProps) => {
|
||||
const { codeShowLineNumbers, fontSize } = useSettings()
|
||||
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
|
||||
const shikiThemeRef = useRef<HTMLDivElement>(null)
|
||||
@ -108,7 +108,7 @@ const CodeViewer = ({ children, language, expanded, unwrapped, onHeightChange, c
|
||||
<ScrollContainer
|
||||
ref={scrollerRef}
|
||||
className="shiki-scroller"
|
||||
$wrap={!unwrapped}
|
||||
$wrap={wrapped}
|
||||
$expanded={expanded}
|
||||
$lineHeight={estimateSize()}
|
||||
style={
|
||||
@ -257,6 +257,7 @@ const ScrollContainer = styled.div<{
|
||||
.line-content {
|
||||
flex: 1;
|
||||
padding-right: 1em;
|
||||
white-space: pre;
|
||||
* {
|
||||
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
|
||||
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
|
||||
|
||||
@ -18,6 +18,15 @@ interface Props {
|
||||
filter: NodeFilter
|
||||
includeUser?: boolean
|
||||
onIncludeUserChange?: (value: boolean) => void
|
||||
/**
|
||||
* 是否显示“包含用户问题”切换按钮(默认为 true)。
|
||||
* 在富文本编辑器场景通常不需要该按钮。
|
||||
*/
|
||||
showUserToggle?: boolean
|
||||
/**
|
||||
* 搜索条定位方式
|
||||
*/
|
||||
positionMode?: 'fixed' | 'absolute' | 'sticky'
|
||||
}
|
||||
|
||||
enum SearchCompletedState {
|
||||
@ -125,7 +134,10 @@ const findRangesInTarget = (
|
||||
|
||||
// eslint-disable-next-line @eslint-react/no-forward-ref
|
||||
export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
({ searchTarget, filter, includeUser = false, onIncludeUserChange }, ref) => {
|
||||
(
|
||||
{ searchTarget, filter, includeUser = false, onIncludeUserChange, showUserToggle = true, positionMode = 'fixed' },
|
||||
ref
|
||||
) => {
|
||||
const target: HTMLElement | null = (() => {
|
||||
if (searchTarget instanceof HTMLElement) {
|
||||
return searchTarget
|
||||
@ -335,9 +347,12 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
}
|
||||
|
||||
return (
|
||||
<Container ref={containerRef} style={enableContentSearch ? {} : { display: 'none' }}>
|
||||
<Container
|
||||
ref={containerRef}
|
||||
style={enableContentSearch ? {} : { display: 'none' }}
|
||||
$overlayPosition={positionMode === 'absolute' ? 'absolute' : 'static'}>
|
||||
<NarrowLayout style={{ width: '100%' }}>
|
||||
<SearchBarContainer>
|
||||
<SearchBarContainer $position={positionMode}>
|
||||
<InputWrapper>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
@ -347,11 +362,13 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
style={{ lineHeight: '20px' }}
|
||||
/>
|
||||
<ToolBar>
|
||||
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
|
||||
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
{showUserToggle && (
|
||||
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
|
||||
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
|
||||
<CaseSensitive
|
||||
@ -400,17 +417,21 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
|
||||
ContentSearch.displayName = 'ContentSearch'
|
||||
|
||||
const Container = styled.div`
|
||||
const Container = styled.div<{ $overlayPosition: 'static' | 'absolute' }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
z-index: 2;
|
||||
position: ${({ $overlayPosition }) => $overlayPosition};
|
||||
top: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')};
|
||||
left: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')};
|
||||
right: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')};
|
||||
z-index: 999;
|
||||
`
|
||||
|
||||
const SearchBarContainer = styled.div`
|
||||
const SearchBarContainer = styled.div<{ $position: 'fixed' | 'absolute' | 'sticky' }>`
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
position: fixed;
|
||||
position: ${({ $position }) => $position};
|
||||
top: 15px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
|
||||
81
src/renderer/src/components/FreeTrialModelTag.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { getProviderLabel } from '@renderer/i18n/label'
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import { Model } from '@renderer/types'
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
import { FC, MouseEvent } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import IndicatorLight from './IndicatorLight'
|
||||
import SelectModelPopup from './Popups/SelectModelPopup'
|
||||
import CustomTag from './Tags/CustomTag'
|
||||
|
||||
interface Props {
|
||||
model: Model
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
export const FreeTrialModelTag: FC<Props> = ({ model, showLabel = true }) => {
|
||||
if (model.provider !== 'cherryin') {
|
||||
return null
|
||||
}
|
||||
|
||||
let providerId
|
||||
|
||||
if (model.id === 'glm-4.5-flash') {
|
||||
providerId = 'zhipu'
|
||||
}
|
||||
|
||||
if (model.id === 'Qwen/Qwen3-8B') {
|
||||
providerId = 'silicon'
|
||||
}
|
||||
|
||||
const onSelectProvider = () => {
|
||||
NavigationService.navigate!(`/settings/provider?id=${providerId}`)
|
||||
}
|
||||
|
||||
const onNavigateProvider = (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
SelectModelPopup.hide()
|
||||
NavigationService.navigate!(`/settings/provider?id=${providerId}`)
|
||||
}
|
||||
|
||||
if (!showLabel) {
|
||||
return (
|
||||
<Container>
|
||||
<CustomTag
|
||||
color="var(--color-link)"
|
||||
size={11}
|
||||
onClick={onNavigateProvider}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{getProviderLabel(providerId)}
|
||||
<ArrowUpRight size={12} />
|
||||
</CustomTag>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<IndicatorLight size={6} color="var(--color-primary)" animation={false} shadow={false} />
|
||||
<PoweredBy>Powered by </PoweredBy>
|
||||
<LinkText onClick={onSelectProvider}>{getProviderLabel(providerId)}</LinkText>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const PoweredBy = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
`
|
||||
|
||||
const LinkText = styled.a`
|
||||
font-size: 12px;
|
||||
color: var(--color-link);
|
||||
`
|
||||
@ -1,7 +1,7 @@
|
||||
import { CSSProperties, SVGProps } from 'react'
|
||||
|
||||
interface BaseFileIconProps extends SVGProps<SVGSVGElement> {
|
||||
size?: string
|
||||
size?: string | number
|
||||
text?: string
|
||||
}
|
||||
|
||||
|
||||
@ -239,6 +239,18 @@ export function BochaLogo(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ZhipuLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M 11.699219 1.800781 C 12.300781 1.984375 12.832031 2.203125 13.375 2.515625 C 13.523438 2.597656 13.671875 2.679688 13.820312 2.765625 C 14.058594 2.902344 14.058594 2.902344 14.296875 3.039062 C 14.632812 3.226562 14.96875 3.417969 15.304688 3.605469 C 15.476562 3.707031 15.652344 3.804688 15.824219 3.902344 C 16.671875 4.382812 17.523438 4.859375 18.375 5.335938 C 18.804688 5.574219 19.234375 5.816406 19.660156 6.054688 C 20.257812 6.386719 20.855469 6.71875 21.449219 7.050781 C 21.460938 8.449219 21.46875 9.851562 21.472656 11.25 C 21.476562 11.902344 21.480469 12.550781 21.484375 13.203125 C 21.488281 13.828125 21.492188 14.457031 21.492188 15.082031 C 21.492188 15.324219 21.496094 15.5625 21.496094 15.800781 C 21.5 16.136719 21.5 16.472656 21.5 16.808594 C 21.503906 17.09375 21.503906 17.09375 21.503906 17.386719 C 21.449219 17.851562 21.449219 17.851562 21.242188 18.125 C 20.917969 18.359375 20.585938 18.558594 20.238281 18.757812 C 20.085938 18.84375 19.929688 18.933594 19.769531 19.027344 C 19.605469 19.121094 19.4375 19.214844 19.265625 19.3125 C 19.003906 19.460938 18.742188 19.613281 18.480469 19.761719 C 18.203125 19.917969 17.929688 20.074219 17.65625 20.234375 C 16.929688 20.648438 16.203125 21.066406 15.476562 21.484375 C 15.140625 21.675781 14.804688 21.867188 14.46875 22.0625 C 14.238281 22.195312 14.238281 22.195312 14.003906 22.328125 C 13.863281 22.410156 13.71875 22.492188 13.574219 22.574219 C 13.265625 22.761719 12.984375 22.957031 12.695312 23.171875 C 12.179688 23.46875 11.972656 23.390625 11.398438 23.25 C 10.996094 23.058594 10.996094 23.058594 10.585938 22.816406 C 10.429688 22.726562 10.277344 22.636719 10.117188 22.542969 C 9.871094 22.398438 9.871094 22.398438 9.617188 22.246094 C 9.445312 22.144531 9.273438 22.042969 9.101562 21.945312 C 8.746094 21.734375 8.386719 21.523438 8.03125 21.316406 C 7.359375 20.917969 6.683594 20.523438 6.007812 20.128906 C 5.703125 19.953125 5.402344 19.773438 5.101562 19.597656 C 4.34375 19.15625 3.578125 18.722656 2.800781 18.308594 C 2.550781 18.148438 2.550781 18.148438 2.398438 17.851562 C 2.378906 17.519531 2.367188 17.183594 2.363281 16.851562 C 2.359375 16.75 2.355469 16.648438 2.355469 16.542969 C 2.335938 15.597656 2.324219 14.652344 2.316406 13.707031 C 2.3125 13.070312 2.304688 12.4375 2.289062 11.800781 C 2.277344 11.1875 2.269531 10.574219 2.265625 9.960938 C 2.265625 9.726562 2.257812 9.492188 2.253906 9.257812 C 2.203125 7.4375 2.203125 7.4375 2.675781 6.871094 C 3.023438 6.632812 3.363281 6.464844 3.75 6.300781 C 3.914062 6.203125 4.078125 6.109375 4.246094 6.007812 C 4.402344 5.925781 4.554688 5.839844 4.710938 5.753906 C 4.839844 5.683594 4.839844 5.683594 4.972656 5.613281 C 5.152344 5.515625 5.332031 5.417969 5.515625 5.316406 C 5.992188 5.058594 6.472656 4.792969 6.949219 4.53125 C 7.046875 4.480469 7.144531 4.425781 7.242188 4.371094 C 8.195312 3.847656 9.140625 3.320312 10.085938 2.785156 C 10.234375 2.703125 10.378906 2.621094 10.53125 2.535156 C 10.664062 2.460938 10.796875 2.382812 10.933594 2.308594 C 11.109375 2.207031 11.109375 2.207031 11.285156 2.109375 C 11.542969 1.96875 11.542969 1.96875 11.699219 1.800781 Z M 11.851562 4.5 C 11.820312 4.652344 11.789062 4.800781 11.753906 4.957031 C 11.207031 7.453125 10.085938 9.570312 7.941406 11.066406 C 6.816406 11.78125 5.628906 12.113281 4.351562 12.449219 C 4.351562 12.5 4.351562 12.550781 4.351562 12.601562 C 4.582031 12.648438 4.582031 12.648438 4.824219 12.699219 C 6.101562 12.984375 7.183594 13.300781 8.25 14.101562 C 8.363281 14.183594 8.476562 14.265625 8.59375 14.351562 C 10.558594 15.933594 11.488281 18.261719 11.851562 20.699219 C 11.898438 20.699219 11.949219 20.699219 12 20.699219 C 12.03125 20.554688 12.058594 20.410156 12.089844 20.257812 C 12.6875 17.503906 13.816406 15.222656 16.242188 13.65625 C 17.253906 13.054688 18.351562 12.8125 19.5 12.601562 C 19.035156 12.289062 18.695312 12.191406 18.160156 12.046875 C 15.792969 11.332031 14.222656 9.945312 13.050781 7.800781 C 12.527344 6.726562 12.175781 5.679688 12 4.5 C 11.949219 4.5 11.902344 4.5 11.851562 4.5 Z M 11.851562 4.5 "
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export function PoeLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import {
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FileImageOutlined,
|
||||
RotateLeftOutlined,
|
||||
RotateRightOutlined,
|
||||
SwapOutlined,
|
||||
@ -13,11 +12,14 @@ import { loggerService } from '@logger'
|
||||
import { download } from '@renderer/utils/download'
|
||||
import { Dropdown, Image as AntImage, ImageProps as AntImageProps, Space } from 'antd'
|
||||
import { Base64 } from 'js-base64'
|
||||
import { DownloadIcon, ImageIcon } from 'lucide-react'
|
||||
import mime from 'mime'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { CopyIcon } from './Icons'
|
||||
|
||||
interface ImageViewerProps extends AntImageProps {
|
||||
src: string
|
||||
}
|
||||
@ -33,7 +35,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
|
||||
if (src.startsWith('data:')) {
|
||||
// 处理 base64 格式的图片
|
||||
const match = src.match(/^data:(image\/\w+);base64,(.+)$/)
|
||||
if (!match) throw new Error('无效的 base64 图片格式')
|
||||
if (!match) throw new Error('Invalid base64 image format')
|
||||
const mimeType = match[1]
|
||||
const byteArray = Base64.toUint8Array(match[2])
|
||||
const blob = new Blob([byteArray], { type: mimeType })
|
||||
@ -62,17 +64,17 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
|
||||
|
||||
window.message.success(t('message.copy.success'))
|
||||
} catch (error) {
|
||||
logger.error('复制图片失败:', error as Error)
|
||||
logger.error('Failed to copy image:', error as Error)
|
||||
window.message.error(t('message.copy.failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const getContextMenuItems = (src: string) => {
|
||||
const getContextMenuItems = (src: string, size: number = 14) => {
|
||||
return [
|
||||
{
|
||||
key: 'copy-url',
|
||||
label: t('common.copy'),
|
||||
icon: <CopyOutlined />,
|
||||
icon: <CopyIcon size={size} />,
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(src)
|
||||
window.message.success(t('message.copy.success'))
|
||||
@ -81,13 +83,13 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
|
||||
{
|
||||
key: 'download',
|
||||
label: t('common.download'),
|
||||
icon: <DownloadOutlined />,
|
||||
icon: <DownloadIcon size={size} />,
|
||||
onClick: () => download(src)
|
||||
},
|
||||
{
|
||||
key: 'copy-image',
|
||||
label: t('preview.copy.image'),
|
||||
icon: <FileImageOutlined />,
|
||||
icon: <ImageIcon size={size} />,
|
||||
onClick: () => handleCopyImage(src)
|
||||
}
|
||||
]
|
||||
@ -98,6 +100,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
|
||||
<AntImage
|
||||
src={src}
|
||||
style={style}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
preview={{
|
||||
mask: typeof props.preview === 'object' ? props.preview.mask : false,
|
||||
|
||||
20
src/renderer/src/components/InfoPopover.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Popover, PopoverProps } from 'antd'
|
||||
import { Info } from 'lucide-react'
|
||||
|
||||
type InheritedPopoverProps = Omit<PopoverProps, 'children'>
|
||||
|
||||
interface InfoPopoverProps extends InheritedPopoverProps {
|
||||
iconColor?: string
|
||||
iconSize?: string | number
|
||||
iconStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
const InfoPopover = ({ iconColor = 'var(--color-text-3)', iconSize = 14, iconStyle, ...rest }: InfoPopoverProps) => {
|
||||
return (
|
||||
<Popover {...rest}>
|
||||
<Info size={iconSize} color={iconColor} style={{ ...iconStyle }} role="img" aria-label="Information" />
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default InfoPopover
|
||||
@ -15,7 +15,7 @@ type Props = {
|
||||
|
||||
const ModelSelectButton = ({ model, onSelectModel, modelFilter, noTooltip, tooltipProps }: Props) => {
|
||||
const onClick = useCallback(async () => {
|
||||
const selectedModel = await SelectModelPopup.show({ model, modelFilter })
|
||||
const selectedModel = await SelectModelPopup.show({ model, filter: modelFilter })
|
||||
if (selectedModel) {
|
||||
onSelectModel?.(selectedModel)
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { getProgressLabel } from '@renderer/i18n/label'
|
||||
import { getBackupProgressLabel } from '@renderer/i18n/label'
|
||||
import { backup } from '@renderer/services/BackupService'
|
||||
import store from '@renderer/store'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
@ -61,7 +61,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
progress: Math.floor(progressData.progress)
|
||||
})
|
||||
}
|
||||
return getProgressLabel(progressData.stage)
|
||||
return getBackupProgressLabel(progressData.stage)
|
||||
}
|
||||
|
||||
BackupPopup.hide = onCancel
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { getProgressLabel } from '@renderer/i18n/label'
|
||||
import { getRestoreProgressLabel } from '@renderer/i18n/label'
|
||||
import { restore } from '@renderer/services/BackupService'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Modal, Progress } from 'antd'
|
||||
@ -49,11 +49,11 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
if (!progressData) return ''
|
||||
|
||||
if (progressData.stage === 'copying_files') {
|
||||
return t('backup.progress.copying_files', {
|
||||
return t('restore.progress.copying_files', {
|
||||
progress: Math.floor(progressData.progress)
|
||||
})
|
||||
}
|
||||
return getProgressLabel(progressData.stage)
|
||||
return getRestoreProgressLabel(progressData.stage)
|
||||
}
|
||||
|
||||
RestorePopup.hide = onCancel
|
||||
|
||||
161
src/renderer/src/components/Popups/RichEditPopup.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import RichEditor from '@renderer/components/RichEditor'
|
||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||
import { Modal, ModalProps } from 'antd'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { TopView } from '../TopView'
|
||||
|
||||
interface ShowParams {
|
||||
content: string
|
||||
modalProps?: ModalProps
|
||||
showTranslate?: boolean
|
||||
disableCommands?: string[] // 要禁用的命令列表
|
||||
children?: (props: { onOk?: () => void; onCancel?: () => void }) => React.ReactNode
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({
|
||||
content,
|
||||
modalProps,
|
||||
resolve,
|
||||
children,
|
||||
disableCommands = ['image', 'inlineMath'] // 默认禁用 image 命令
|
||||
}) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const [richContent, setRichContent] = useState(content)
|
||||
const editorRef = useRef<RichEditorRef>(null)
|
||||
const isMounted = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onOk = () => {
|
||||
const finalContent = editorRef.current?.getMarkdown() || richContent
|
||||
resolve(finalContent)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
resolve(null)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve(null)
|
||||
}
|
||||
|
||||
const handleAfterOpenChange = (visible: boolean) => {
|
||||
if (visible && editorRef.current) {
|
||||
// Focus the editor after modal opens
|
||||
setTimeout(() => {
|
||||
editorRef.current?.focus()
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContentChange = (newContent: string) => {
|
||||
setRichContent(newContent)
|
||||
}
|
||||
|
||||
const handleMarkdownChange = (newMarkdown: string) => {
|
||||
// 更新Markdown内容状态
|
||||
setRichContent(newMarkdown)
|
||||
}
|
||||
|
||||
// 处理命令配置
|
||||
const handleCommandsReady = (commandAPI: Pick<RichEditorRef, 'unregisterToolbarCommand' | 'unregisterCommand'>) => {
|
||||
// 禁用指定的命令
|
||||
if (disableCommands?.length) {
|
||||
disableCommands.forEach((commandId) => {
|
||||
commandAPI.unregisterCommand(commandId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
RichEditPopup.hide = onCancel
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('common.edit')}
|
||||
width="70vw"
|
||||
style={{ maxHeight: '80vh' }}
|
||||
transitionName="animation-move-down"
|
||||
okText={t('common.save')}
|
||||
{...modalProps}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
centered>
|
||||
<EditorContainer>
|
||||
<RichEditor
|
||||
ref={editorRef}
|
||||
initialContent={content}
|
||||
placeholder={t('richEditor.placeholder')}
|
||||
onContentChange={handleContentChange}
|
||||
onMarkdownChange={handleMarkdownChange}
|
||||
onCommandsReady={handleCommandsReady}
|
||||
minHeight={300}
|
||||
maxHeight={500}
|
||||
isFullWidth={true}
|
||||
className="rich-edit-popup-editor"
|
||||
/>
|
||||
</EditorContainer>
|
||||
<ChildrenContainer>{children && children({ onOk, onCancel })}</ChildrenContainer>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'RichEditPopup'
|
||||
|
||||
const ChildrenContainer = styled.div`
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const EditorContainer = styled.div`
|
||||
position: relative;
|
||||
|
||||
.rich-edit-popup-editor {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-background);
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-alpha);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default class RichEditPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||