diff --git a/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch b/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch new file mode 100644 index 0000000000..575577acec --- /dev/null +++ b/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch @@ -0,0 +1,48 @@ +diff --git a/dist/index.cjs b/dist/index.cjs +index 8e560a4406c5cc616c11bb9fd5455ac0dcf47fa3..c7cd0d65ddc971bff71e89f610de82cfdaa5a8c7 100644 +--- a/dist/index.cjs ++++ b/dist/index.cjs +@@ -413,6 +413,19 @@ var DragHandlePlugin = ({ + } + return false; + }, ++ scroll(view) { ++ if (!element || locked) { ++ return false; ++ } ++ if (view.hasFocus()) { ++ hideHandle(); ++ currentNode = null; ++ currentNodePos = -1; ++ onNodeChange == null ? void 0 : onNodeChange({ editor, node: null, pos: -1 }); ++ return false; ++ } ++ return false; ++ }, + mouseleave(_view, e) { + if (locked) { + return false; +diff --git a/dist/index.js b/dist/index.js +index 39e4c3ef9986cd25544d9d3994cf6a9ada74b145..378d9130abbfdd0e1e4f743b5b537743c9ab07d0 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -387,6 +387,19 @@ var DragHandlePlugin = ({ + } + return false; + }, ++ scroll(view) { ++ if (!element || locked) { ++ return false; ++ } ++ if (view.hasFocus()) { ++ hideHandle(); ++ currentNode = null; ++ currentNodePos = -1; ++ onNodeChange == null ? void 0 : onNodeChange({ editor, node: null, pos: -1 }); ++ return false; ++ } ++ return false; ++ }, + mouseleave(_view, e) { + if (locked) { + return false; diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f7cbd950f2..69a949f45c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -81,7 +81,8 @@ export default defineConfig({ '@shared': resolve('packages/shared'), '@logger': resolve('src/renderer/src/services/LoggerService'), '@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'), - '@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web') + '@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web'), + '@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src') } }, optimizeDeps: { diff --git a/package.json b/package.json index 517f914748..ab22cab85c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "packages/database", "packages/mcp-trace/trace-core", "packages/mcp-trace/trace-node", - "packages/mcp-trace/trace-web" + "packages/mcp-trace/trace-web", + "packages/extension-table-plus" ] } }, @@ -106,6 +107,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", @@ -144,9 +146,27 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@tiptap/extension-collaboration": "^3.2.0", + "@tiptap/extension-drag-handle": "patch:@tiptap/extension-drag-handle@npm%3A3.2.0#~/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch", + "@tiptap/extension-drag-handle-react": "^3.2.0", + "@tiptap/extension-image": "^3.2.0", + "@tiptap/extension-list": "^3.2.0", + "@tiptap/extension-mathematics": "^3.2.0", + "@tiptap/extension-mention": "^3.2.0", + "@tiptap/extension-node-range": "^3.2.0", + "@tiptap/extension-table-of-contents": "^3.2.0", + "@tiptap/extension-typography": "^3.2.0", + "@tiptap/extension-underline": "^3.2.0", + "@tiptap/pm": "^3.2.0", + "@tiptap/react": "^3.2.0", + "@tiptap/starter-kit": "^3.2.0", + "@tiptap/suggestion": "^3.2.0", + "@tiptap/y-tiptap": "^3.0.0", + "@truto/turndown-plugin-gfm": "^1.0.2", "@tryfabric/martian": "^1.2.4", "@types/cli-progress": "^3", "@types/fs-extra": "^11", + "@types/he": "^1", "@types/lodash": "^4.17.5", "@types/markdown-it": "^14", "@types/md5": "^2.3.5", @@ -157,6 +177,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", @@ -175,6 +196,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", @@ -184,6 +206,7 @@ "dexie-react-hooks": "^1.1.7", "diff": "^8.0.2", "docx": "^9.0.2", + "dompurify": "^3.2.6", "dotenv-cli": "^7.4.2", "electron": "37.4.0", "electron-builder": "26.0.15", @@ -205,6 +228,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", @@ -262,11 +286,13 @@ "shiki": "^3.12.0", "strict-url-sanitise": "^0.0.1", "string-width": "^7.2.0", + "striptags": "^3.2.0", "styled-components": "^6.1.11", "tar": "^7.4.3", "tiny-pinyin": "^1.3.2", "tokenx": "^1.1.0", "tsx": "^4.20.3", + "turndown-plugin-gfm": "^1.0.2", "typescript": "^5.6.2", "undici": "6.21.2", "unified": "^11.0.5", @@ -277,6 +303,8 @@ "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0", "word-extractor": "^1.0.4", + "y-protocols": "^1.0.6", + "yjs": "^13.6.27", "zipread": "^1.3.3", "zod": "^3.25.74" }, diff --git a/packages/extension-table-plus/CHANGELOG.md b/packages/extension-table-plus/CHANGELOG.md new file mode 100755 index 0000000000..6f24f5b060 --- /dev/null +++ b/packages/extension-table-plus/CHANGELOG.md @@ -0,0 +1,1457 @@ +# Change Log + +## 3.0.9 + +### Patch Changes + +- @tiptap/core@3.0.9 +- @tiptap/pm@3.0.9 + +## 3.0.8 + +### Patch Changes + +- @tiptap/core@3.0.8 +- @tiptap/pm@3.0.8 + +## 3.0.7 + +### Patch Changes + +- @tiptap/core@3.0.7 +- @tiptap/pm@3.0.7 + +## 3.0.6 + +### Patch Changes + +- Updated dependencies [2e71d05] + - @tiptap/core@3.0.6 + - @tiptap/pm@3.0.6 + +## 3.0.5 + +### Patch Changes + +- @tiptap/core@3.0.5 +- @tiptap/pm@3.0.5 + +## 3.0.4 + +### Patch Changes + +- Updated dependencies [7ed03fa] + - @tiptap/core@3.0.4 + - @tiptap/pm@3.0.4 + +## 3.0.3 + +### Patch Changes + +- Updated dependencies [75cabde] + - @tiptap/core@3.0.3 + - @tiptap/pm@3.0.3 + +## 3.0.2 + +### Patch Changes + +- @tiptap/core@3.0.2 +- @tiptap/pm@3.0.2 + +## 3.0.1 + +### Major Changes + +- a92f4a6: We are now building packages with tsup which does not support UMD builds, please repackage if you require UMD builds + +### Minor Changes + +- 131c7d0: This adds all of the table packages to the `@tiptap/extension-table` package. + + ## TableKit + + The `TableKit` export allows configuring the entire table with one extension, and is the recommended way of using the table extensions. + + ```ts + import { TableKit } from '@tiptap/extension-table' + + new Editor({ + extensions: [ + TableKit.configure({ + table: { + HTMLAttributes: { + class: 'table', + }, + }, + tableCell: { + HTMLAttributes: { + class: 'table-cell', + }, + }, + tableHeader: { + HTMLAttributes: { + class: 'table-header', + }, + }, + tableRow: { + HTMLAttributes: { + class: 'table-row', + }, + }, + }), + ], + }) + ``` + + ## Table repackaging + + Since we've moved the code out of the table extensions to the `@tiptap/extension-table` package, you can remove the following packages from your project: + + ```bash + npm uninstall @tiptap/extension-table-header @tiptap/extension-table-cell @tiptap/extension-table-row + ``` + + And replace them with the new `@tiptap/extension-table` package: + + ```bash + npm install @tiptap/extension-table + ``` + + ## Want to use the extensions separately? + + For more control, you can also use the extensions separately. + + ### Table + + This extension adds a table to the editor. + + Migrate from default export to named export: + + ```diff + - import Table from '@tiptap/extension-table' + + import { Table } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { Table } from '@tiptap/extension-table' + ``` + + ### TableCell + + This extension adds a table cell to the editor. + + Migrate from `@tiptap/extension-table-cell` to `@tiptap/extension-table`: + + ```diff + - import TableCell from '@tiptap/extension-table-cell' + + import { TableCell } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableCell } from '@tiptap/extension-table' + ``` + + ### TableHeader + + This extension adds a table header to the editor. + + Migrate from `@tiptap/extension-table-header` to `@tiptap/extension-table`: + + ```diff + - import TableHeader from '@tiptap/extension-table-header' + + import { TableHeader } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableHeader } from '@tiptap/extension-table' + ``` + + ### TableRow + + This extension adds a table row to the editor. + + Migrate from `@tiptap/extension-table-row` to `@tiptap/extension-table`: + + ```diff + - import TableRow from '@tiptap/extension-table-row' + + import { TableRow } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableRow } from '@tiptap/extension-table' + ``` + +### Patch Changes + +- 1b4c82b: We are now using pnpm package aliases for versions to enable better version pinning for the monorepository +- 89bd9c7: Enforce type imports so that the bundler ignores TypeScript type imports when generating the index.js file of the dist directory +- 991f43c: Added new export for TableView class +- 8c69002: Synced beta with stable features +- Updated dependencies [1b4c82b] +- Updated dependencies [1e91f9b] +- Updated dependencies [a92f4a6] +- Updated dependencies [8de8e13] +- Updated dependencies [20f68f6] +- Updated dependencies [5e957e5] +- Updated dependencies [89bd9c7] +- Updated dependencies [d0fda30] +- Updated dependencies [0e3207f] +- Updated dependencies [37913d5] +- Updated dependencies [28c5418] +- Updated dependencies [32958d6] +- Updated dependencies [12bb31a] +- Updated dependencies [9f207a6] +- Updated dependencies [412e1bd] +- Updated dependencies [062afaf] +- Updated dependencies [ff8eed6] +- Updated dependencies [704f462] +- Updated dependencies [95b8c71] +- Updated dependencies [8c69002] +- Updated dependencies [664834f] +- Updated dependencies [ac897e7] +- Updated dependencies [087d114] +- Updated dependencies [32958d6] +- Updated dependencies [fc17b21] +- Updated dependencies [62b0877] +- Updated dependencies [e20006b] +- Updated dependencies [5ba480b] +- Updated dependencies [d6c7558] +- Updated dependencies [062afaf] +- Updated dependencies [9ceeab4] +- Updated dependencies [32958d6] +- Updated dependencies [bf835b0] +- Updated dependencies [4e2f6d8] +- Updated dependencies [32958d6] + - @tiptap/core@3.0.1 + - @tiptap/pm@3.0.1 + +## 3.0.0-beta.30 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.30 +- @tiptap/pm@3.0.0-beta.30 + +## 3.0.0-beta.29 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.29 +- @tiptap/pm@3.0.0-beta.29 + +## 3.0.0-beta.28 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.28 +- @tiptap/pm@3.0.0-beta.28 + +## 3.0.0-beta.27 + +### Patch Changes + +- Updated dependencies [412e1bd] + - @tiptap/core@3.0.0-beta.27 + - @tiptap/pm@3.0.0-beta.27 + +## 3.0.0-beta.26 + +### Patch Changes + +- Updated dependencies [5ba480b] + - @tiptap/core@3.0.0-beta.26 + - @tiptap/pm@3.0.0-beta.26 + +## 3.0.0-beta.25 + +### Patch Changes + +- Updated dependencies [4e2f6d8] + - @tiptap/core@3.0.0-beta.25 + - @tiptap/pm@3.0.0-beta.25 + +## 3.0.0-beta.24 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.24 +- @tiptap/pm@3.0.0-beta.24 + +## 3.0.0-beta.23 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.23 +- @tiptap/pm@3.0.0-beta.23 + +## 3.0.0-beta.22 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.22 +- @tiptap/pm@3.0.0-beta.22 + +## 3.0.0-beta.21 + +### Patch Changes + +- Updated dependencies [813674c] +- Updated dependencies [fc17b21] + - @tiptap/core@3.0.0-beta.21 + - @tiptap/pm@3.0.0-beta.21 + +## 3.0.0-beta.20 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.20 +- @tiptap/pm@3.0.0-beta.20 + +## 3.0.0-beta.19 + +### Patch Changes + +- Updated dependencies [9ceeab4] + - @tiptap/core@3.0.0-beta.19 + - @tiptap/pm@3.0.0-beta.19 + +## 3.0.0-beta.18 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.18 +- @tiptap/pm@3.0.0-beta.18 + +## 3.0.0-beta.17 + +### Patch Changes + +- Updated dependencies [e20006b] + - @tiptap/core@3.0.0-beta.17 + - @tiptap/pm@3.0.0-beta.17 + +## 3.0.0-beta.16 + +### Patch Changes + +- Updated dependencies [ac897e7] +- Updated dependencies [bf835b0] + - @tiptap/core@3.0.0-beta.16 + - @tiptap/pm@3.0.0-beta.16 + +## 3.0.0-beta.15 + +### Patch Changes + +- Updated dependencies [087d114] + - @tiptap/core@3.0.0-beta.15 + - @tiptap/pm@3.0.0-beta.15 + +## 3.0.0-beta.14 + +### Patch Changes + +- Updated dependencies [95b8c71] + - @tiptap/core@3.0.0-beta.14 + - @tiptap/pm@3.0.0-beta.14 + +## 3.0.0-beta.13 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.13 +- @tiptap/pm@3.0.0-beta.13 + +## 3.0.0-beta.12 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.12 +- @tiptap/pm@3.0.0-beta.12 + +## 3.0.0-beta.11 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.11 +- @tiptap/pm@3.0.0-beta.11 + +## 3.0.0-beta.10 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.10 +- @tiptap/pm@3.0.0-beta.10 + +## 3.0.0-beta.9 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.9 +- @tiptap/pm@3.0.0-beta.9 + +## 3.0.0-beta.8 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.8 +- @tiptap/pm@3.0.0-beta.8 + +## 3.0.0-beta.7 + +### Patch Changes + +- Updated dependencies [d0fda30] + - @tiptap/core@3.0.0-beta.7 + - @tiptap/pm@3.0.0-beta.7 + +## 3.0.0-beta.6 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.6 +- @tiptap/pm@3.0.0-beta.6 + +## 3.0.0-beta.5 + +### Patch Changes + +- 8c69002: Synced beta with stable features +- Updated dependencies [8c69002] +- Updated dependencies [62b0877] + - @tiptap/core@3.0.0-beta.5 + - @tiptap/pm@3.0.0-beta.5 + +## 3.0.0-beta.4 + +### Patch Changes + +- Updated dependencies [5e957e5] +- Updated dependencies [9f207a6] + - @tiptap/core@3.0.0-beta.4 + - @tiptap/pm@3.0.0-beta.4 + +## 3.0.0-beta.3 + +### Patch Changes + +- 1b4c82b: We are now using pnpm package aliases for versions to enable better version pinning for the monorepository +- Updated dependencies [1b4c82b] + - @tiptap/core@3.0.0-beta.3 + - @tiptap/pm@3.0.0-beta.3 + +## 3.0.0-beta.2 + +## 3.0.0-beta.1 + +### Patch Changes + +- 991f43c: Added new export for TableView class + +## 3.0.0-beta.0 + +## 3.0.0-next.8 + +## 3.0.0-next.7 + +### Patch Changes + +- 89bd9c7: Enforce type imports so that the bundler ignores TypeScript type imports when generating the index.js file of the dist directory + +## 3.0.0-next.6 + +### Major Changes + +- a92f4a6: We are now building packages with tsup which does not support UMD builds, please repackage if you require UMD builds + +### Minor Changes + +- 131c7d0: This adds all of the table packages to the `@tiptap/extension-table` package. + + ## TableKit + + The `TableKit` export allows configuring the entire table with one extension, and is the recommended way of using the table extensions. + + ```ts + import { TableKit } from '@tiptap/extension-table' + + new Editor({ + extensions: [ + TableKit.configure({ + table: { + HTMLAttributes: { + class: 'table', + }, + }, + tableCell: { + HTMLAttributes: { + class: 'table-cell', + }, + }, + tableHeader: { + HTMLAttributes: { + class: 'table-header', + }, + }, + tableRow: { + HTMLAttributes: { + class: 'table-row', + }, + }, + }), + ], + }) + ``` + + ## Table repackaging + + Since we've moved the code out of the table extensions to the `@tiptap/extension-table` package, you can remove the following packages from your project: + + ```bash + npm uninstall @tiptap/extension-table-header @tiptap/extension-table-cell @tiptap/extension-table-row + ``` + + And replace them with the new `@tiptap/extension-table` package: + + ```bash + npm install @tiptap/extension-table + ``` + + ## Want to use the extensions separately? + + For more control, you can also use the extensions separately. + + ### Table + + This extension adds a table to the editor. + + Migrate from default export to named export: + + ```diff + - import Table from '@tiptap/extension-table' + + import { Table } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { Table } from '@tiptap/extension-table' + ``` + + ### TableCell + + This extension adds a table cell to the editor. + + Migrate from `@tiptap/extension-table-cell` to `@tiptap/extension-table`: + + ```diff + - import TableCell from '@tiptap/extension-table-cell' + + import { TableCell } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableCell } from '@tiptap/extension-table' + ``` + + ### TableHeader + + This extension adds a table header to the editor. + + Migrate from `@tiptap/extension-table-header` to `@tiptap/extension-table`: + + ```diff + - import TableHeader from '@tiptap/extension-table-header' + + import { TableHeader } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableHeader } from '@tiptap/extension-table' + ``` + + ### TableRow + + This extension adds a table row to the editor. + + Migrate from `@tiptap/extension-table-row` to `@tiptap/extension-table`: + + ```diff + - import TableRow from '@tiptap/extension-table-row' + + import { TableRow } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableRow } from '@tiptap/extension-table' + ``` + +## 3.0.0-next.5 + +## 3.0.0-next.4 + +## 3.0.0-next.3 + +## 3.0.0-next.2 + +## 3.0.0-next.1 + +### Major Changes + +- a92f4a6: We are now building packages with tsup which does not support UMD builds, please repackage if you require UMD builds + +### Patch Changes + +- Updated dependencies [a92f4a6] +- Updated dependencies [da76972] + - @tiptap/core@3.0.0-next.1 + - @tiptap/pm@3.0.0-next.1 + +## 3.0.0-next.0 + +### Patch Changes + +- Updated dependencies [0ec0af6] + - @tiptap/core@3.0.0-next.0 + - @tiptap/pm@3.0.0-next.0 + +## 2.12.0 + +## 2.11.9 + +## 2.11.8 + +## 2.11.7 + +### Patch Changes + +- a44a311: Added new export for TableView class + +## 2.11.6 + +## 2.11.5 + +## 2.11.4 + +## 2.11.3 + +## 2.11.2 + +## 2.11.1 + +## 2.11.0 + +## 2.10.4 + +## 2.10.3 + +## 2.10.2 + +## 2.10.1 + +## 2.10.0 + +### Patch Changes + +- 7619215: enforce cellMinWidth even on column not resized by the user, fixes #5435 + +## 2.9.1 + +## 2.9.0 + +## 2.8.0 + +### Minor Changes + +- 131c7d0: This change repackages all of the table extensions to be within the `@tiptap/extension-table` package (other packages are just a re-export of the `@tiptap/extension-table` package). It also adds the `TableKit` export which will allow configuring the entire table with one extension. + +## 2.5.8 + +### Patch Changes + +- Updated dependencies [a08bf85] + - @tiptap/core@2.5.8 + - @tiptap/pm@2.5.8 + +## 2.5.7 + +### Patch Changes + +- Updated dependencies [b012471] +- Updated dependencies [cc3497e] + - @tiptap/core@2.5.7 + - @tiptap/pm@2.5.7 + +## 2.5.6 + +### Patch Changes + +- c7f5550: Set correct `min-width` for a table fixes #5217 +- Updated dependencies [b5c1b32] +- Updated dependencies [618bca9] +- Updated dependencies [35682d1] +- Updated dependencies [2104f0f] + - @tiptap/pm@2.5.6 + - @tiptap/core@2.5.6 + +## 2.5.5 + +### Patch Changes + +- Updated dependencies [4cca382] +- Updated dependencies [3b67e8a] + - @tiptap/core@2.5.5 + - @tiptap/pm@2.5.5 + +## 2.5.4 + +### Patch Changes + +- dd7f9ac: There was an issue with the cjs bundling of packages and default exports, now we resolve default exports in legacy compatible way +- Updated dependencies [dd7f9ac] + - @tiptap/core@2.5.4 + - @tiptap/pm@2.5.4 + +## 2.5.3 + +### Patch Changes + +- @tiptap/core@2.5.3 +- @tiptap/pm@2.5.3 + +## 2.5.2 + +### Patch Changes + +- Updated dependencies [07f4c03] + - @tiptap/core@2.5.2 + - @tiptap/pm@2.5.2 + +## 2.5.1 + +### Patch Changes + +- @tiptap/core@2.5.1 +- @tiptap/pm@2.5.1 + +## 2.5.0 + +### Patch Changes + +- Updated dependencies [fb45149] +- Updated dependencies [fb45149] +- Updated dependencies [fb45149] +- Updated dependencies [fb45149] + - @tiptap/core@2.5.0 + - @tiptap/pm@2.5.0 + +## 2.5.0-pre.16 + +### Patch Changes + +- @tiptap/core@2.5.0-pre.16 +- @tiptap/pm@2.5.0-pre.16 + +## 2.5.0-pre.15 + +### Patch Changes + +- @tiptap/core@2.5.0-pre.15 +- @tiptap/pm@2.5.0-pre.15 + +## 2.5.0-pre.14 + +### Patch Changes + +- @tiptap/core@2.5.0-pre.14 +- @tiptap/pm@2.5.0-pre.14 + +## 2.5.0-pre.13 + +### Patch Changes + +- Updated dependencies [74a37ff] + - @tiptap/core@2.5.0-pre.13 + - @tiptap/pm@2.5.0-pre.13 + +## 2.5.0-pre.12 + +### Patch Changes + +- Updated dependencies [74a37ff] + - @tiptap/core@2.5.0-pre.12 + - @tiptap/pm@2.5.0-pre.12 + +## 2.5.0-pre.11 + +### Patch Changes + +- Updated dependencies [74a37ff] + - @tiptap/core@2.5.0-pre.11 + - @tiptap/pm@2.5.0-pre.11 + +## 2.5.0-pre.10 + +### Patch Changes + +- Updated dependencies [74a37ff] + - @tiptap/core@2.5.0-pre.10 + - @tiptap/pm@2.5.0-pre.10 + +## 2.5.0-pre.9 + +### Patch Changes + +- Updated dependencies [14a00f4] + - @tiptap/core@2.5.0-pre.9 + - @tiptap/pm@2.5.0-pre.9 + +## 2.5.0-pre.8 + +### Patch Changes + +- Updated dependencies [509676e] + - @tiptap/core@2.5.0-pre.8 + - @tiptap/pm@2.5.0-pre.8 + +## 2.5.0-pre.7 + +### Patch Changes + +- @tiptap/core@2.5.0-pre.7 +- @tiptap/pm@2.5.0-pre.7 + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [2.4.0](https://github.com/ueberdosis/tiptap/compare/v2.3.2...v2.4.0) (2024-05-14) + +### Features + +- added jsdocs ([#4356](https://github.com/ueberdosis/tiptap/issues/4356)) ([b941eea](https://github.com/ueberdosis/tiptap/commit/b941eea6daba09d48a5d18ccc1b9a1d84b2249dd)) + +## [2.3.2](https://github.com/ueberdosis/tiptap/compare/v2.3.1...v2.3.2) (2024-05-08) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.3.1](https://github.com/ueberdosis/tiptap/compare/v2.3.0...v2.3.1) (2024-04-30) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.3.0](https://github.com/ueberdosis/tiptap/compare/v2.2.6...v2.3.0) (2024-04-09) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.6](https://github.com/ueberdosis/tiptap/compare/v2.2.5...v2.2.6) (2024-04-06) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.5](https://github.com/ueberdosis/tiptap/compare/v2.2.4...v2.2.5) (2024-04-05) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.4](https://github.com/ueberdosis/tiptap/compare/v2.2.3...v2.2.4) (2024-02-23) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.3](https://github.com/ueberdosis/tiptap/compare/v2.2.2...v2.2.3) (2024-02-15) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.2](https://github.com/ueberdosis/tiptap/compare/v2.2.1...v2.2.2) (2024-02-07) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.1](https://github.com/ueberdosis/tiptap/compare/v2.2.0...v2.2.1) (2024-01-31) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.2.0](https://github.com/ueberdosis/tiptap/compare/v2.1.16...v2.2.0) (2024-01-29) + +### Bug Fixes + +- fix imports, fix demos, unpin y-prosemirror ([681aa57](https://github.com/ueberdosis/tiptap/commit/681aa577bff500015c3f925e300c55a71c73efaf)) + +# [2.2.0-rc.8](https://github.com/ueberdosis/tiptap/compare/v2.1.14...v2.2.0-rc.8) (2024-01-08) + +# [2.2.0-rc.7](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.6...v2.2.0-rc.7) (2023-11-27) + +# [2.2.0-rc.6](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.5...v2.2.0-rc.6) (2023-11-23) + +# [2.2.0-rc.4](https://github.com/ueberdosis/tiptap/compare/v2.1.11...v2.2.0-rc.4) (2023-10-10) + +# [2.2.0-rc.3](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.2...v2.2.0-rc.3) (2023-08-18) + +# [2.2.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.0...v2.2.0-rc.1) (2023-08-18) + +# [2.2.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.1.5...v2.2.0-rc.0) (2023-08-18) + +## [2.1.16](https://github.com/ueberdosis/tiptap/compare/v2.1.15...v2.1.16) (2024-01-10) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.15](https://github.com/ueberdosis/tiptap/compare/v2.1.14...v2.1.15) (2024-01-08) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.14](https://github.com/ueberdosis/tiptap/compare/v2.1.13...v2.1.14) (2024-01-08) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.13](https://github.com/ueberdosis/tiptap/compare/v2.1.12...v2.1.13) (2023-11-30) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.12](https://github.com/ueberdosis/tiptap/compare/v2.1.11...v2.1.12) (2023-10-11) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.11](https://github.com/ueberdosis/tiptap/compare/v2.1.10...v2.1.11) (2023-09-20) + +### Reverts + +- Revert "v2.2.11" ([6aa755a](https://github.com/ueberdosis/tiptap/commit/6aa755a04b9955fc175c7ab33dee527d0d5deef0)) + +## [2.1.10](https://github.com/ueberdosis/tiptap/compare/v2.1.9...v2.1.10) (2023-09-15) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.9](https://github.com/ueberdosis/tiptap/compare/v2.1.8...v2.1.9) (2023-09-14) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.8](https://github.com/ueberdosis/tiptap/compare/v2.1.7...v2.1.8) (2023-09-04) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.7](https://github.com/ueberdosis/tiptap/compare/v2.1.6...v2.1.7) (2023-09-04) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.6](https://github.com/ueberdosis/tiptap/compare/v2.1.5...v2.1.6) (2023-08-18) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.5](https://github.com/ueberdosis/tiptap/compare/v2.1.4...v2.1.5) (2023-08-18) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.4](https://github.com/ueberdosis/tiptap/compare/v2.1.3...v2.1.4) (2023-08-18) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.3](https://github.com/ueberdosis/tiptap/compare/v2.1.2...v2.1.3) (2023-08-18) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.2](https://github.com/ueberdosis/tiptap/compare/v2.1.1...v2.1.2) (2023-08-17) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.1](https://github.com/ueberdosis/tiptap/compare/v2.1.0...v2.1.1) (2023-08-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.14...v2.1.0) (2023-08-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.14](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.13...v2.1.0-rc.14) (2023-08-11) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.13](https://github.com/ueberdosis/tiptap/compare/v2.0.4...v2.1.0-rc.13) (2023-08-11) + +# [2.1.0-rc.12](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.11...v2.1.0-rc.12) (2023-07-14) + +# [2.1.0-rc.11](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.10...v2.1.0-rc.11) (2023-07-07) + +# [2.1.0-rc.10](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.9...v2.1.0-rc.10) (2023-07-07) + +# [2.1.0-rc.9](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.8...v2.1.0-rc.9) (2023-06-15) + +# [2.1.0-rc.8](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.7...v2.1.0-rc.8) (2023-05-25) + +# [2.1.0-rc.5](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.4...v2.1.0-rc.5) (2023-05-25) + +# [2.1.0-rc.4](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.3...v2.1.0-rc.4) (2023-04-27) + +# [2.1.0-rc.3](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.2...v2.1.0-rc.3) (2023-04-26) + +# [2.1.0-rc.2](https://github.com/ueberdosis/tiptap/compare/v2.0.3...v2.1.0-rc.2) (2023-04-26) + +# [2.1.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.0...v2.1.0-rc.1) (2023-04-12) + +# [2.1.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.1.0-rc.0) (2023-04-05) + +### Bug Fixes + +- Update peerDependencies to fix lerna version tasks ([#3914](https://github.com/ueberdosis/tiptap/issues/3914)) ([0c1bba3](https://github.com/ueberdosis/tiptap/commit/0c1bba3137b535776bcef95ff3c55e13f5a2db46)) + +# [2.1.0-rc.12](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.11...v2.1.0-rc.12) (2023-07-14) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.11](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.10...v2.1.0-rc.11) (2023-07-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.10](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.9...v2.1.0-rc.10) (2023-07-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.9](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.8...v2.1.0-rc.9) (2023-06-15) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.8](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.7...v2.1.0-rc.8) (2023-05-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.7](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.6...v2.1.0-rc.7) (2023-05-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.6](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.5...v2.1.0-rc.6) (2023-05-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.5](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.4...v2.1.0-rc.5) (2023-05-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.4](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.3...v2.1.0-rc.4) (2023-04-27) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.3](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.2...v2.1.0-rc.3) (2023-04-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.2](https://github.com/ueberdosis/tiptap/compare/v2.0.3...v2.1.0-rc.2) (2023-04-26) + +# [2.1.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.0...v2.1.0-rc.1) (2023-04-12) + +# [2.1.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.1.0-rc.0) (2023-04-05) + +### Bug Fixes + +- Update peerDependencies to fix lerna version tasks ([#3914](https://github.com/ueberdosis/tiptap/issues/3914)) ([0c1bba3](https://github.com/ueberdosis/tiptap/commit/0c1bba3137b535776bcef95ff3c55e13f5a2db46)) + +# [2.1.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.0...v2.1.0-rc.1) (2023-04-12) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.1.0-rc.0) (2023-04-05) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.0.3](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.0.3) (2023-04-13) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.0.2](https://github.com/ueberdosis/tiptap/compare/v2.0.1...v2.0.2) (2023-04-03) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.0.1](https://github.com/ueberdosis/tiptap/compare/v2.0.0...v2.0.1) (2023-03-30) + +### Bug Fixes + +- Update peerDependencies to fix lerna version tasks ([#3914](https://github.com/ueberdosis/tiptap/issues/3914)) ([0534f76](https://github.com/ueberdosis/tiptap/commit/0534f76401bf5399c01ca7f39d87f7221d91b4f7)) + +# [2.0.0-beta.220](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.219...v2.0.0-beta.220) (2023-02-28) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.219](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.218...v2.0.0-beta.219) (2023-02-27) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.218](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.217...v2.0.0-beta.218) (2023-02-18) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.217](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.216...v2.0.0-beta.217) (2023-02-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.216](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.215...v2.0.0-beta.216) (2023-02-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.215](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.214...v2.0.0-beta.215) (2023-02-08) + +### Bug Fixes + +- fix builds including prosemirror ([a380ec4](https://github.com/ueberdosis/tiptap/commit/a380ec41d198ebacc80cea9e79b0a8aa3092618a)) + +# [2.0.0-beta.214](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.213...v2.0.0-beta.214) (2023-02-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.213](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.212...v2.0.0-beta.213) (2023-02-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.212](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.211...v2.0.0-beta.212) (2023-02-03) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.211](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.210...v2.0.0-beta.211) (2023-02-02) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.210](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.209...v2.0.0-beta.210) (2023-02-02) + +### Features + +- **pm:** new prosemirror package for dependency resolving ([f387ad3](https://github.com/ueberdosis/tiptap/commit/f387ad3dd4c2b30eaea33fb0ba0b42e0cd39263b)) + +# [2.0.0-beta.209](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.208...v2.0.0-beta.209) (2022-12-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.208](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.207...v2.0.0-beta.208) (2022-12-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.207](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.206...v2.0.0-beta.207) (2022-12-08) + +### Bug Fixes + +- **extension-table:** add prosemirror-tables to peerDependencies ([c187e0e](https://github.com/ueberdosis/tiptap/commit/c187e0e2586f1d0069e93ab41a144ae14d5172e0)) + +# [2.0.0-beta.206](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.205...v2.0.0-beta.206) (2022-12-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.205](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.204...v2.0.0-beta.205) (2022-12-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.204](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.203...v2.0.0-beta.204) (2022-11-25) + +### Bug Fixes + +- **core:** rename esm modules to esm.js ([c1a0c3a](https://github.com/ueberdosis/tiptap/commit/c1a0c3ae43baac9dd5ed90903d3a0d4eaeea7702)) + +# [2.0.0-beta.203](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.202...v2.0.0-beta.203) (2022-11-24) + +### Bug Fixes + +- **extension/table:** move dependency from @\_ueberdosis to [@tiptap](https://github.com/tiptap) ([#3448](https://github.com/ueberdosis/tiptap/issues/3448)) ([31c3a9a](https://github.com/ueberdosis/tiptap/commit/31c3a9aad9eb37f445eadcd27135611291178ca6)) + +# [2.0.0-beta.202](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.201...v2.0.0-beta.202) (2022-11-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.201](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.200...v2.0.0-beta.201) (2022-11-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.200](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.199...v2.0.0-beta.200) (2022-11-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.199](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.198...v2.0.0-beta.199) (2022-09-30) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.198](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.197...v2.0.0-beta.198) (2022-09-29) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.197](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.196...v2.0.0-beta.197) (2022-09-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.196](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.195...v2.0.0-beta.196) (2022-09-20) + +### Bug Fixes + +- **types:** fix link and table type errors ([#3208](https://github.com/ueberdosis/tiptap/issues/3208)) ([ae13cf6](https://github.com/ueberdosis/tiptap/commit/ae13cf61ad0ead942515d8c597f96a4b4d026412)) + +# [2.0.0-beta.195](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.194...v2.0.0-beta.195) (2022-09-14) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.194](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.193...v2.0.0-beta.194) (2022-09-11) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.54](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.53...@tiptap/extension-table@2.0.0-beta.54) (2022-06-27) + +### Bug Fixes + +- **maintainment:** fix cjs issues with prosemirror-tables ([eb92597](https://github.com/ueberdosis/tiptap/commit/eb925976038fbf59f6ba333ccc57ea84113da00e)) + +# [2.0.0-beta.53](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.52...@tiptap/extension-table@2.0.0-beta.53) (2022-06-20) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.52](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.50...@tiptap/extension-table@2.0.0-beta.52) (2022-06-17) + +### Reverts + +- Revert "Publish" ([9c38d27](https://github.com/ueberdosis/tiptap/commit/9c38d2713e6feac5645ad9c1bfc57abdbf054576)) + +# [2.0.0-beta.50](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.50...@tiptap/extension-table@2.0.0-beta.50) (2022-06-17) + +### Reverts + +- Revert "Publish" ([9c38d27](https://github.com/ueberdosis/tiptap/commit/9c38d2713e6feac5645ad9c1bfc57abdbf054576)) + +# [2.0.0-beta.49](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.48...@tiptap/extension-table@2.0.0-beta.49) (2022-05-18) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.48](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.47...@tiptap/extension-table@2.0.0-beta.48) (2022-01-25) + +### Bug Fixes + +- use toggleHeader from prosemirror-tables ([#2412](https://github.com/ueberdosis/tiptap/issues/2412)), fix [#548](https://github.com/ueberdosis/tiptap/issues/548) ([c6bea9a](https://github.com/ueberdosis/tiptap/commit/c6bea9aa5c4d38523f2f1095a570cdfc6936392e)) + +# [2.0.0-beta.47](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.46...@tiptap/extension-table@2.0.0-beta.47) (2022-01-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.46](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.45...@tiptap/extension-table@2.0.0-beta.46) (2022-01-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.45](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.44...@tiptap/extension-table@2.0.0-beta.45) (2021-12-03) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.44](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.43...@tiptap/extension-table@2.0.0-beta.44) (2021-12-02) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.43](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.42...@tiptap/extension-table@2.0.0-beta.43) (2021-11-17) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.42](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.41...@tiptap/extension-table@2.0.0-beta.42) (2021-11-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.41](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.40...@tiptap/extension-table@2.0.0-beta.41) (2021-11-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.40](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.39...@tiptap/extension-table@2.0.0-beta.40) (2021-11-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.39](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.38...@tiptap/extension-table@2.0.0-beta.39) (2021-11-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.38](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.37...@tiptap/extension-table@2.0.0-beta.38) (2021-11-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.37](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.36...@tiptap/extension-table@2.0.0-beta.37) (2021-10-31) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.36](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.35...@tiptap/extension-table@2.0.0-beta.36) (2021-10-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.35](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.34...@tiptap/extension-table@2.0.0-beta.35) (2021-10-22) + +### Features + +- Add extension storage ([#2069](https://github.com/ueberdosis/tiptap/issues/2069)) ([7ffabf2](https://github.com/ueberdosis/tiptap/commit/7ffabf251c408a652eec1931cc78a8bd43cccb67)) + +# [2.0.0-beta.34](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.33...@tiptap/extension-table@2.0.0-beta.34) (2021-10-14) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.33](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.32...@tiptap/extension-table@2.0.0-beta.33) (2021-10-14) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.32](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.31...@tiptap/extension-table@2.0.0-beta.32) (2021-10-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.31](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.30...@tiptap/extension-table@2.0.0-beta.31) (2021-09-15) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.30](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.29...@tiptap/extension-table@2.0.0-beta.30) (2021-09-06) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.29](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.28...@tiptap/extension-table@2.0.0-beta.29) (2021-08-20) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.28](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.27...@tiptap/extension-table@2.0.0-beta.28) (2021-08-13) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.27](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.26...@tiptap/extension-table@2.0.0-beta.27) (2021-08-09) + +### Bug Fixes + +- don’t resize tables if editable is set to false, fix [#1549](https://github.com/ueberdosis/tiptap/issues/1549) ([239a2e3](https://github.com/ueberdosis/tiptap/commit/239a2e36a47e4d0ad3012a54cda2d8b5c4f7a3ca)) + +# [2.0.0-beta.26](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.25...@tiptap/extension-table@2.0.0-beta.26) (2021-07-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.25](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.24...@tiptap/extension-table@2.0.0-beta.25) (2021-07-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.24](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.23...@tiptap/extension-table@2.0.0-beta.24) (2021-06-23) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.23](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.22...@tiptap/extension-table@2.0.0-beta.23) (2021-06-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.22](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.21...@tiptap/extension-table@2.0.0-beta.22) (2021-05-27) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.21](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.20...@tiptap/extension-table@2.0.0-beta.21) (2021-05-18) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.20](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.19...@tiptap/extension-table@2.0.0-beta.20) (2021-05-17) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.19](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.18...@tiptap/extension-table@2.0.0-beta.19) (2021-05-13) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.18](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.17...@tiptap/extension-table@2.0.0-beta.18) (2021-05-07) + +### Bug Fixes + +- revert adding exports ([bc320d0](https://github.com/ueberdosis/tiptap/commit/bc320d0b4b80b0e37a7e47a56e0f6daec6e65d98)) + +# [2.0.0-beta.17](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.16...@tiptap/extension-table@2.0.0-beta.17) (2021-05-06) + +### Bug Fixes + +- revert adding type: module ([f8d6475](https://github.com/ueberdosis/tiptap/commit/f8d6475e2151faea6f96baecdd6bd75880d50d2c)) + +# [2.0.0-beta.16](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.15...@tiptap/extension-table@2.0.0-beta.16) (2021-05-06) + +### Bug Fixes + +- add exports to package.json ([1277fa4](https://github.com/ueberdosis/tiptap/commit/1277fa47151e9c039508cdb219bdd0ffe647f4ee)) + +# [2.0.0-beta.15](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.14...@tiptap/extension-table@2.0.0-beta.15) (2021-05-06) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.14](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.13...@tiptap/extension-table@2.0.0-beta.14) (2021-05-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.13](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.12...@tiptap/extension-table@2.0.0-beta.13) (2021-05-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.12](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.11...@tiptap/extension-table@2.0.0-beta.12) (2021-04-27) + +### Features + +- add setCellSelection command ([eb7e92f](https://github.com/ueberdosis/tiptap/commit/eb7e92f10aff60e68cae613750903eb0adce5933)) + +# [2.0.0-beta.11](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.10...@tiptap/extension-table@2.0.0-beta.11) (2021-04-23) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.10](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.9...@tiptap/extension-table@2.0.0-beta.10) (2021-04-22) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.9](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.8...@tiptap/extension-table@2.0.0-beta.9) (2021-04-21) + +### Bug Fixes + +- add name to context ([a43d4c7](https://github.com/ueberdosis/tiptap/commit/a43d4c7bcb5ba5e386f268a2a71a7449bc2f658e)) + +# [2.0.0-beta.8](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.7...@tiptap/extension-table@2.0.0-beta.8) (2021-04-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.7](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.6...@tiptap/extension-table@2.0.0-beta.7) (2021-04-15) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.6](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.5...@tiptap/extension-table@2.0.0-beta.6) (2021-04-12) + +### Features + +- add parentConfig to extension context for more extendable extensions, fix [#259](https://github.com/ueberdosis/tiptap/issues/259) ([5e1ec5d](https://github.com/ueberdosis/tiptap/commit/5e1ec5d2a66be164f505d631f97861ab9344ba96)) + +# [2.0.0-beta.5](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.4...@tiptap/extension-table@2.0.0-beta.5) (2021-03-31) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.4](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.3...@tiptap/extension-table@2.0.0-beta.4) (2021-03-28) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.3](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.2...@tiptap/extension-table@2.0.0-beta.3) (2021-03-24) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.2](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.1...@tiptap/extension-table@2.0.0-beta.2) (2021-03-18) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.1](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.12...@tiptap/extension-table@2.0.0-beta.1) (2021-03-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.12](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.11...@tiptap/extension-table@2.0.0-alpha.12) (2021-02-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.11](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.10...@tiptap/extension-table@2.0.0-alpha.11) (2021-02-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.10](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.9...@tiptap/extension-table@2.0.0-alpha.10) (2021-02-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.9](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.8...@tiptap/extension-table@2.0.0-alpha.9) (2021-02-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.8](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.7...@tiptap/extension-table@2.0.0-alpha.8) (2021-01-29) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.7](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.6...@tiptap/extension-table@2.0.0-alpha.7) (2021-01-29) + +**Note:** Version bump only for package @tiptap/extension-table + +# 2.0.0-alpha.6 (2021-01-28) + +**Note:** Version bump only for package @tiptap/extension-table diff --git a/packages/extension-table-plus/README.md b/packages/extension-table-plus/README.md new file mode 100755 index 0000000000..09164acab0 --- /dev/null +++ b/packages/extension-table-plus/README.md @@ -0,0 +1,18 @@ +# @tiptap/extension-table + +[![Version](https://img.shields.io/npm/v/@tiptap/extension-table.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-table) +[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-table.svg)](https://npmcharts.com/compare/tiptap?minimal=true) +[![License](https://img.shields.io/npm/l/@tiptap/extension-table.svg)](https://www.npmjs.com/package/@tiptap/extension-table) +[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis) + +## Introduction + +Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_. + +## Official Documentation + +Documentation can be found on the [Tiptap website](https://tiptap.dev). + +## License + +Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md). diff --git a/packages/extension-table-plus/package.json b/packages/extension-table-plus/package.json new file mode 100755 index 0000000000..d34c25ccd7 --- /dev/null +++ b/packages/extension-table-plus/package.json @@ -0,0 +1,93 @@ +{ + "name": "@cherrystudio/extension-table-plus", + "description": "table extension for tiptap forked from tiptap/extension-table", + "version": "3.0.11", + "homepage": "https://cherry-ai.com", + "keywords": [ + "tiptap", + "tiptap extension" + ], + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": { + "import": "./dist/index.d.ts", + "require": "./dist/index.d.cts" + }, + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./table": { + "types": { + "import": "./dist/table/index.d.ts", + "require": "./dist/table/index.d.cts" + }, + "import": "./dist/table/index.js", + "require": "./dist/table/index.cjs" + }, + "./cell": { + "types": { + "import": "./dist/cell/index.d.ts", + "require": "./dist/cell/index.d.cts" + }, + "import": "./dist/cell/index.js", + "require": "./dist/cell/index.cjs" + }, + "./header": { + "types": { + "import": "./dist/header/index.d.ts", + "require": "./dist/header/index.d.cts" + }, + "import": "./dist/header/index.js", + "require": "./dist/header/index.cjs" + }, + "./kit": { + "types": { + "import": "./dist/kit/index.d.ts", + "require": "./dist/kit/index.d.cts" + }, + "import": "./dist/kit/index.js", + "require": "./dist/kit/index.cjs" + }, + "./row": { + "types": { + "import": "./dist/row/index.d.ts", + "require": "./dist/row/index.d.cts" + }, + "import": "./dist/row/index.js", + "require": "./dist/row/index.cjs" + } + }, + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "src", + "dist" + ], + "devDependencies": { + "@tiptap/core": "^3.2.0", + "@tiptap/pm": "^3.2.0", + "eslint": "^9.22.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-unused-imports": "^4.1.4", + "prettier": "^3.5.3", + "tsdown": "^0.13.3" + }, + "peerDependencies": { + "@tiptap/core": "^3.0.9", + "@tiptap/pm": "^3.0.9" + }, + "repository": { + "type": "git", + "url": "https://github.com/CherryHQ/cherry-studio", + "directory": "packages/extension-table-plus" + }, + "scripts": { + "build": "tsdown", + "lint": "prettier ./src/ --write && eslint --fix ./src/" + }, + "packageManager": "yarn@4.9.1" +} diff --git a/packages/extension-table-plus/src/cell/index.ts b/packages/extension-table-plus/src/cell/index.ts new file mode 100755 index 0000000000..cabf450700 --- /dev/null +++ b/packages/extension-table-plus/src/cell/index.ts @@ -0,0 +1 @@ +export * from './table-cell.js' diff --git a/packages/extension-table-plus/src/cell/table-cell.ts b/packages/extension-table-plus/src/cell/table-cell.ts new file mode 100755 index 0000000000..fa549d7f9f --- /dev/null +++ b/packages/extension-table-plus/src/cell/table-cell.ts @@ -0,0 +1,150 @@ +import '../types.js' + +import { mergeAttributes, Node } from '@tiptap/core' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import type { Selection } from '@tiptap/pm/state' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { CellSelection, TableMap } from '@tiptap/pm/tables' +import { Decoration, DecorationSet } from '@tiptap/pm/view' + +export interface TableCellOptions { + /** + * The HTML attributes for a table cell node. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record + /** + * Whether nodes can be nested inside a cell. + * @default false + */ + allowNestedNodes: boolean +} + +const cellSelectionPluginKey = new PluginKey('cellSelectionStyling') + +function isTableNode(node: ProseMirrorNode): boolean { + const spec = node.type.spec as { tableRole?: string } | undefined + return node.type.name === 'table' || spec?.tableRole === 'table' +} + +function createCellSelectionDecorationSet(doc: ProseMirrorNode, selection: Selection): DecorationSet { + if (!(selection instanceof CellSelection)) { + return DecorationSet.empty + } + + const $anchor = selection.$anchorCell || selection.$anchor + let tableNode: ProseMirrorNode | null = null + let tablePos = -1 + + for (let depth = $anchor.depth; depth > 0; depth--) { + const nodeAtDepth = $anchor.node(depth) as ProseMirrorNode + if (isTableNode(nodeAtDepth)) { + tableNode = nodeAtDepth + tablePos = $anchor.before(depth) + break + } + } + + if (!tableNode) { + return DecorationSet.empty + } + + const map = TableMap.get(tableNode) + const tableStart = tablePos + 1 + + type Rect = { top: number; bottom: number; left: number; right: number } + type Item = { pos: number; node: ProseMirrorNode; rect: Rect } + + const items: Item[] = [] + let minRow = Number.POSITIVE_INFINITY + let maxRow = Number.NEGATIVE_INFINITY + let minCol = Number.POSITIVE_INFINITY + let maxCol = Number.NEGATIVE_INFINITY + + selection.forEachCell((cell, pos) => { + const rect = map.findCell(pos - tableStart) + items.push({ pos, node: cell, rect }) + + minRow = Math.min(minRow, rect.top) + maxRow = Math.max(maxRow, rect.bottom - 1) + minCol = Math.min(minCol, rect.left) + maxCol = Math.max(maxCol, rect.right - 1) + }) + + const decorations: Decoration[] = [] + for (const { pos, node, rect } of items) { + const classes: string[] = ['selectedCell'] + if (rect.top === minRow) classes.push('selection-top') + if (rect.bottom - 1 === maxRow) classes.push('selection-bottom') + if (rect.left === minCol) classes.push('selection-left') + if (rect.right - 1 === maxCol) classes.push('selection-right') + + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + class: classes.join(' ') + }) + ) + } + + return DecorationSet.create(doc, decorations) +} +/** + * This extension allows you to create table cells. + * @see https://www.tiptap.dev/api/nodes/table-cell + */ +export const TableCell = Node.create({ + name: 'tableCell', + + addOptions() { + return { + HTMLAttributes: {}, + allowNestedNodes: false + } + }, + + content: '(paragraph | image)+', + + addAttributes() { + return { + colspan: { + default: 1 + }, + rowspan: { + default: 1 + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute('colwidth') + const value = colwidth ? colwidth.split(',').map((width) => parseInt(width, 10)) : null + + return value + } + } + } + }, + + tableRole: 'cell', + + isolating: true, + + parseHTML() { + return [{ tag: 'td' }] + }, + + renderHTML({ HTMLAttributes }) { + return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: cellSelectionPluginKey, + props: { + decorations: ({ doc, selection }) => createCellSelectionDecorationSet(doc as ProseMirrorNode, selection) + } + }) + ] + } +}) diff --git a/packages/extension-table-plus/src/header/index.ts b/packages/extension-table-plus/src/header/index.ts new file mode 100755 index 0000000000..0bd179194c --- /dev/null +++ b/packages/extension-table-plus/src/header/index.ts @@ -0,0 +1 @@ +export * from './table-header.js' diff --git a/packages/extension-table-plus/src/header/table-header.ts b/packages/extension-table-plus/src/header/table-header.ts new file mode 100755 index 0000000000..50c30ac4a6 --- /dev/null +++ b/packages/extension-table-plus/src/header/table-header.ts @@ -0,0 +1,60 @@ +import '../types.js' + +import { mergeAttributes, Node } from '@tiptap/core' + +export interface TableHeaderOptions { + /** + * The HTML attributes for a table header node. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record +} + +/** + * This extension allows you to create table headers. + * @see https://www.tiptap.dev/api/nodes/table-header + */ +export const TableHeader = Node.create({ + name: 'tableHeader', + + addOptions() { + return { + HTMLAttributes: {} + } + }, + + content: 'paragraph+', + + addAttributes() { + return { + colspan: { + default: 1 + }, + rowspan: { + default: 1 + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute('colwidth') + const value = colwidth ? colwidth.split(',').map((width) => parseInt(width, 10)) : null + + return value + } + } + } + }, + + tableRole: 'header_cell', + + isolating: true, + + parseHTML() { + return [{ tag: 'th' }] + }, + + renderHTML({ HTMLAttributes }) { + return ['th', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + } +}) diff --git a/packages/extension-table-plus/src/index.ts b/packages/extension-table-plus/src/index.ts new file mode 100755 index 0000000000..c16b6c4e46 --- /dev/null +++ b/packages/extension-table-plus/src/index.ts @@ -0,0 +1,6 @@ +export * from './cell/index.js' +export * from './header/index.js' +export * from './kit/index.js' +export * from './row/index.js' +export * from './table/index.js' +export * from './table/TableView.js' diff --git a/packages/extension-table-plus/src/kit/index.ts b/packages/extension-table-plus/src/kit/index.ts new file mode 100755 index 0000000000..00221c5bfe --- /dev/null +++ b/packages/extension-table-plus/src/kit/index.ts @@ -0,0 +1,64 @@ +import { Extension, Node } from '@tiptap/core' + +import type { TableCellOptions } from '../cell/index.js' +import { TableCell } from '../cell/index.js' +import type { TableHeaderOptions } from '../header/index.js' +import { TableHeader } from '../header/index.js' +import type { TableRowOptions } from '../row/index.js' +import { TableRow } from '../row/index.js' +import type { TableOptions } from '../table/index.js' +import { Table } from '../table/index.js' + +export interface TableKitOptions { + /** + * If set to false, the table extension will not be registered + * @example table: false + */ + table: Partial | false + /** + * If set to false, the table extension will not be registered + * @example tableCell: false + */ + tableCell: Partial | false + /** + * If set to false, the table extension will not be registered + * @example tableHeader: false + */ + tableHeader: Partial | false + /** + * If set to false, the table extension will not be registered + * @example tableRow: false + */ + tableRow: Partial | false +} + +/** + * The table kit is a collection of table editor extensions. + * + * It’s a good starting point for building your own table in Tiptap. + */ +export const TableKit = Extension.create({ + name: 'tableKit', + + addExtensions() { + const extensions: Node[] = [] + + if (this.options.table !== false) { + extensions.push(Table.configure(this.options.table)) + } + + if (this.options.tableCell !== false) { + extensions.push(TableCell.configure(this.options.tableCell)) + } + + if (this.options.tableHeader !== false) { + extensions.push(TableHeader.configure(this.options.tableHeader)) + } + + if (this.options.tableRow !== false) { + extensions.push(TableRow.configure(this.options.tableRow)) + } + + return extensions + } +}) diff --git a/packages/extension-table-plus/src/row/index.ts b/packages/extension-table-plus/src/row/index.ts new file mode 100755 index 0000000000..8a3564c008 --- /dev/null +++ b/packages/extension-table-plus/src/row/index.ts @@ -0,0 +1 @@ +export * from './table-row.js' diff --git a/packages/extension-table-plus/src/row/table-row.ts b/packages/extension-table-plus/src/row/table-row.ts new file mode 100755 index 0000000000..382954397f --- /dev/null +++ b/packages/extension-table-plus/src/row/table-row.ts @@ -0,0 +1,38 @@ +import '../types.js' + +import { mergeAttributes, Node } from '@tiptap/core' + +export interface TableRowOptions { + /** + * The HTML attributes for a table row node. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record +} + +/** + * This extension allows you to create table rows. + * @see https://www.tiptap.dev/api/nodes/table-row + */ +export const TableRow = Node.create({ + name: 'tableRow', + + addOptions() { + return { + HTMLAttributes: {} + } + }, + + content: '(tableCell | tableHeader)*', + + tableRole: 'row', + + parseHTML() { + return [{ tag: 'tr' }] + }, + + renderHTML({ HTMLAttributes }) { + return ['tr', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + } +}) diff --git a/packages/extension-table-plus/src/table/TableView.ts b/packages/extension-table-plus/src/table/TableView.ts new file mode 100755 index 0000000000..1a06255364 --- /dev/null +++ b/packages/extension-table-plus/src/table/TableView.ts @@ -0,0 +1,558 @@ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { TextSelection } from '@tiptap/pm/state' +import { addColumnAfter, addRowAfter, CellSelection, TableMap } from '@tiptap/pm/tables' +import type { EditorView, NodeView, ViewMutationRecord } from '@tiptap/pm/view' + +import { getColStyleDeclaration } from './utilities/colStyle.js' +import { getElementBorderWidth } from './utilities/getBorderWidth.js' +import { isCellSelection } from './utilities/isCellSelection.js' +import { getCellSelectionBounds } from './utilities/selectionBounds.js' + +export function updateColumns( + node: ProseMirrorNode, + colgroup: HTMLTableColElement, // has the same prototype as + table: HTMLTableElement, + cellMinWidth: number, + overrideCol?: number, + overrideValue?: number +) { + let totalWidth = 0 + let fixedWidth = true + let nextDOM = colgroup.firstChild + const row = node.firstChild + + if (row !== null) { + for (let i = 0, col = 0; i < row.childCount; i += 1) { + const { colspan, colwidth } = row.child(i).attrs + + for (let j = 0; j < colspan; j += 1, col += 1) { + const hasWidth = overrideCol === col ? overrideValue : ((colwidth && colwidth[j]) as number | undefined) + const cssWidth = hasWidth ? `${hasWidth}px` : '' + + totalWidth += hasWidth || cellMinWidth + + if (!hasWidth) { + fixedWidth = false + } + + if (!nextDOM) { + const colElement = document.createElement('col') + + const [propertyKey, propertyValue] = getColStyleDeclaration(cellMinWidth, hasWidth) + + colElement.style.setProperty(propertyKey, propertyValue) + + colgroup.appendChild(colElement) + } else { + if ((nextDOM as HTMLTableColElement).style.width !== cssWidth) { + const [propertyKey, propertyValue] = getColStyleDeclaration(cellMinWidth, hasWidth) + + ;(nextDOM as HTMLTableColElement).style.setProperty(propertyKey, propertyValue) + } + + nextDOM = nextDOM.nextSibling + } + } + } + } + + while (nextDOM) { + const after = nextDOM.nextSibling + + nextDOM.parentNode?.removeChild(nextDOM) + nextDOM = after + } + + if (fixedWidth) { + table.style.width = `${totalWidth}px` + table.style.minWidth = '' + } else { + table.style.width = '' + table.style.minWidth = `${totalWidth}px` + } +} + +// Callbacks are now handled by a decorations plugin; keep type removed here + +type ButtonPosition = { x: number; y: number } +type RowActionCallback = (args: { rowIndex: number; view: EditorView; position?: ButtonPosition }) => void +type ColumnActionCallback = (args: { colIndex: number; view: EditorView; position?: ButtonPosition }) => void + +export class TableView implements NodeView { + node: ProseMirrorNode + + cellMinWidth: number + + dom: HTMLDivElement + + table: HTMLTableElement + + colgroup: HTMLTableColElement + + contentDOM: HTMLTableSectionElement + + view: EditorView + + addRowButton: HTMLButtonElement + + addColumnButton: HTMLButtonElement + + tableContainer: HTMLDivElement + + // Hover add buttons are kept; overlay endpoints absolute on wrapper + private selectionChangeDisposer?: () => void + private rowEndpoint?: HTMLButtonElement + private colEndpoint?: HTMLButtonElement + private overlayUpdateRafId: number | null = null + private actionCallbacks?: { + onRowActionClick?: RowActionCallback + onColumnActionClick?: ColumnActionCallback + } + + constructor( + node: ProseMirrorNode, + cellMinWidth: number, + view: EditorView, + actionCallbacks?: { onRowActionClick?: RowActionCallback; onColumnActionClick?: ColumnActionCallback } + ) { + this.node = node + this.cellMinWidth = cellMinWidth + this.view = view + this.actionCallbacks = actionCallbacks + // selection triggers handled by decorations plugin + + // Create the wrapper with grid layout + this.dom = document.createElement('div') + this.dom.className = 'tableWrapper' + + // Create table container + this.tableContainer = document.createElement('div') + this.tableContainer.className = 'table-container' + + this.table = this.tableContainer.appendChild(document.createElement('table')) + this.colgroup = this.table.appendChild(document.createElement('colgroup')) + updateColumns(node, this.colgroup, this.table, cellMinWidth) + this.contentDOM = this.table.appendChild(document.createElement('tbody')) + + this.addRowButton = document.createElement('button') + this.addColumnButton = document.createElement('button') + this.createHoverButtons() + + this.dom.appendChild(this.tableContainer) + this.dom.appendChild(this.addColumnButton) + this.dom.appendChild(this.addRowButton) + + this.syncEditableState() + + this.setupEventListeners() + + // create overlay endpoints + this.rowEndpoint = document.createElement('button') + this.rowEndpoint.className = 'row-action-trigger' + this.rowEndpoint.type = 'button' + this.rowEndpoint.setAttribute('contenteditable', 'false') + this.rowEndpoint.style.position = 'absolute' + this.rowEndpoint.style.display = 'none' + this.rowEndpoint.tabIndex = -1 + + this.colEndpoint = document.createElement('button') + this.colEndpoint.className = 'column-action-trigger' + this.colEndpoint.type = 'button' + this.colEndpoint.setAttribute('contenteditable', 'false') + this.colEndpoint.style.position = 'absolute' + this.colEndpoint.style.display = 'none' + this.colEndpoint.tabIndex = -1 + + this.dom.appendChild(this.rowEndpoint) + this.dom.appendChild(this.colEndpoint) + + this.bindOverlayHandlers() + this.startSelectionWatcher() + } + + update(node: ProseMirrorNode) { + if (node.type !== this.node.type) { + return false + } + + this.node = node + updateColumns(node, this.colgroup, this.table, this.cellMinWidth) + + // Keep buttons' disabled state in sync during updates + this.syncEditableState() + + // Recalculate overlay positions after node/table mutations so triggers follow the updated layout + this.scheduleOverlayUpdate() + + return true + } + + ignoreMutation(mutation: ViewMutationRecord) { + return ( + (mutation.type === 'attributes' && (mutation.target === this.table || this.colgroup.contains(mutation.target))) || + // Ignore mutations on our action buttons + (mutation.target as Element)?.classList?.contains('row-action-trigger') || + (mutation.target as Element)?.classList?.contains('column-action-trigger') + ) + } + + private isEditable(): boolean { + // Rely on DOM attribute to avoid depending on EditorView internals + return this.view.dom.getAttribute('contenteditable') !== 'false' + } + + private syncEditableState() { + const editable = this.isEditable() + this.addRowButton.toggleAttribute('disabled', !editable) + this.addColumnButton.toggleAttribute('disabled', !editable) + + this.addRowButton.style.display = editable ? '' : 'none' + this.addColumnButton.style.display = editable ? '' : 'none' + this.dom.classList.toggle('is-readonly', !editable) + } + + createHoverButtons() { + this.addRowButton.className = 'add-row-button' + this.addRowButton.type = 'button' + this.addRowButton.setAttribute('contenteditable', 'false') + + this.addColumnButton.className = 'add-column-button' + this.addColumnButton.type = 'button' + this.addColumnButton.setAttribute('contenteditable', 'false') + } + + private addTableRowOrColumn(isRow: boolean) { + if (!this.isEditable()) return + + this.view.focus() + + // Save current selection info and calculate position in table + const { state } = this.view + const originalSelection = state.selection + + // Find which cell we're currently in and the relative position within that cell + let tablePos = -1 + let currentCellRow = -1 + let currentCellCol = -1 + let relativeOffsetInCell = 0 + + state.doc.descendants((node: ProseMirrorNode, pos: number) => { + if (node.type.name === 'table' && node === this.node) { + tablePos = pos + const map = TableMap.get(this.node) + + // Find which cell contains our selection + const selectionPos = originalSelection.from + for (let row = 0; row < map.height; row++) { + for (let col = 0; col < map.width; col++) { + const cellIndex = row * map.width + col + const cellStart = pos + 1 + map.map[cellIndex] + const cellNode = state.doc.nodeAt(cellStart) + if (cellNode) { + const cellEnd = cellStart + cellNode.nodeSize + if (selectionPos >= cellStart && selectionPos < cellEnd) { + currentCellRow = row + currentCellCol = col + relativeOffsetInCell = selectionPos - cellStart + return false + } + } + } + } + return false + } + return true + }) + + // Set selection to appropriate position for adding + if (isRow) { + this.setSelectionToLastRow() + } else { + this.setSelectionToLastColumn() + } + + setTimeout(() => { + const { state, dispatch } = this.view + const addFunction = isRow ? addRowAfter : addColumnAfter + + if (addFunction(state, dispatch)) { + setTimeout(() => { + const newState = this.view.state + + // Calculate new position for the same logical cell with same relative offset + if (tablePos >= 0 && currentCellRow >= 0 && currentCellCol >= 0) { + newState.doc.descendants((node: ProseMirrorNode, pos: number) => { + if (node.type.name === 'table' && pos === tablePos) { + const newMap = TableMap.get(node) + const newCellIndex = currentCellRow * newMap.width + currentCellCol + const newCellStart = pos + 1 + newMap.map[newCellIndex] + const newCellNode = newState.doc.nodeAt(newCellStart) + + if (newCellNode) { + // Try to maintain the same relative position within the cell + const newCellEnd = newCellStart + newCellNode.nodeSize + const targetPos = Math.min(newCellStart + relativeOffsetInCell, newCellEnd - 1) + const newSelection = TextSelection.create(newState.doc, targetPos) + const newTr = newState.tr.setSelection(newSelection) + this.view.dispatch(newTr) + } + return false + } + return true + }) + } + }, 10) + } + }, 10) + } + + setupEventListeners() { + // Add row button click handler + this.addRowButton.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + this.addTableRowOrColumn(true) + }) + + // Add column button click handler + this.addColumnButton.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + this.addTableRowOrColumn(false) + }) + } + + private bindOverlayHandlers() { + if (!this.rowEndpoint || !this.colEndpoint) return + this.rowEndpoint.addEventListener('mousedown', (e) => e.preventDefault()) + this.colEndpoint.addEventListener('mousedown', (e) => e.preventDefault()) + this.rowEndpoint.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + const bounds = getCellSelectionBounds(this.view, this.node) + if (!bounds) return + this.selectRow(bounds.maxRow) + const rect = this.rowEndpoint!.getBoundingClientRect() + const position = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } + this.actionCallbacks?.onRowActionClick?.({ rowIndex: bounds.maxRow, view: this.view, position }) + this.scheduleOverlayUpdate() + }) + this.colEndpoint.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + const bounds = getCellSelectionBounds(this.view, this.node) + if (!bounds) return + this.selectColumn(bounds.maxCol) + const rect = this.colEndpoint!.getBoundingClientRect() + const position = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } + this.actionCallbacks?.onColumnActionClick?.({ colIndex: bounds.maxCol, view: this.view, position }) + this.scheduleOverlayUpdate() + }) + } + + private startSelectionWatcher() { + const owner = this.view.dom.ownerDocument || document + const handler = () => this.scheduleOverlayUpdate() + owner.addEventListener('selectionchange', handler) + this.selectionChangeDisposer = () => owner.removeEventListener('selectionchange', handler) + this.scheduleOverlayUpdate() + } + + private scheduleOverlayUpdate() { + if (this.overlayUpdateRafId !== null) { + cancelAnimationFrame(this.overlayUpdateRafId) + } + this.overlayUpdateRafId = requestAnimationFrame(() => { + this.overlayUpdateRafId = null + this.updateOverlayPositions() + }) + } + + private updateOverlayPositions() { + if (!this.rowEndpoint || !this.colEndpoint) return + const bounds = getCellSelectionBounds(this.view, this.node) + if (!bounds) { + this.rowEndpoint.style.display = 'none' + this.colEndpoint.style.display = 'none' + return + } + + const { map, tableStart, maxRow, maxCol } = bounds + + const getCellDomAndRect = (row: number, col: number) => { + const cellIndex = row * map.width + col + const cellPos = tableStart + map.map[cellIndex] + const cellDom = this.view.nodeDOM(cellPos) as HTMLElement | null + return { + dom: cellDom, + rect: cellDom?.getBoundingClientRect() + } + } + + // Position row endpoint (left side) + const bottomLeft = getCellDomAndRect(maxRow, 0) + const topLeft = getCellDomAndRect(0, 0) + + if (bottomLeft.dom && bottomLeft.rect && topLeft.rect) { + const midY = (bottomLeft.rect.top + bottomLeft.rect.bottom) / 2 + this.rowEndpoint.style.display = 'flex' + const borderWidth = getElementBorderWidth(this.rowEndpoint) + this.rowEndpoint.style.left = `${bottomLeft.rect.left - topLeft.rect.left - this.rowEndpoint.getBoundingClientRect().width / 2 + borderWidth.left / 2}px` + this.rowEndpoint.style.top = `${midY - topLeft.rect.top - this.rowEndpoint.getBoundingClientRect().height / 2}px` + } else { + this.rowEndpoint.style.display = 'none' + } + + // Position column endpoint (top side) + const topRight = getCellDomAndRect(0, maxCol) + const topLeftForCol = getCellDomAndRect(0, 0) + + if (topRight.dom && topRight.rect && topLeftForCol.rect) { + const midX = topRight.rect.left + topRight.rect.width / 2 + const borderWidth = getElementBorderWidth(this.colEndpoint) + this.colEndpoint.style.display = 'flex' + this.colEndpoint.style.left = `${midX - topLeftForCol.rect.left - this.colEndpoint.getBoundingClientRect().width / 2}px` + this.colEndpoint.style.top = `${topRight.rect.top - topLeftForCol.rect.top - this.colEndpoint.getBoundingClientRect().height / 2 + borderWidth.top / 2}px` + } else { + this.colEndpoint.style.display = 'none' + } + } + + setSelectionToTable() { + const { state } = this.view + + let tablePos = -1 + state.doc.descendants((node: ProseMirrorNode, pos: number) => { + if (node.type.name === 'table' && node === this.node) { + tablePos = pos + return false + } + return true + }) + + if (tablePos >= 0) { + const firstCellPos = tablePos + 3 + const selection = TextSelection.create(state.doc, firstCellPos) + const tr = state.tr.setSelection(selection) + this.view.dispatch(tr) + } + } + + setSelectionToLastRow() { + const { state } = this.view + + let tablePos = -1 + state.doc.descendants((node: ProseMirrorNode, pos: number) => { + if (node.type.name === 'table' && node === this.node) { + tablePos = pos + return false + } + return true + }) + + if (tablePos >= 0) { + const map = TableMap.get(this.node) + const lastRowIndex = map.height - 1 + const lastRowFirstCell = map.map[lastRowIndex * map.width] + const lastRowFirstCellPos = tablePos + 1 + lastRowFirstCell + + const selection = TextSelection.create(state.doc, lastRowFirstCellPos) + const tr = state.tr.setSelection(selection) + this.view.dispatch(tr) + } + } + + setSelectionToLastColumn() { + const { state } = this.view + + let tablePos = -1 + state.doc.descendants((node: ProseMirrorNode, pos: number) => { + if (node.type.name === 'table' && node === this.node) { + tablePos = pos + return false + } + return true + }) + + if (tablePos >= 0) { + const map = TableMap.get(this.node) + const lastColumnIndex = map.width - 1 + const lastColumnFirstCell = map.map[lastColumnIndex] + const lastColumnFirstCellPos = tablePos + 1 + lastColumnFirstCell + + const selection = TextSelection.create(state.doc, lastColumnFirstCellPos) + const tr = state.tr.setSelection(selection) + this.view.dispatch(tr) + } + } + + // selection triggers moved to decorations plugin + + hasTableCellSelection(): boolean { + const selection = this.view.state.selection + return isCellSelection(selection) + } + + selectRow(rowIndex: number) { + const { state, dispatch } = this.view + + // Find the table position + let tablePos = -1 + state.doc.descendants((node: ProseMirrorNode, pos: number) => { + if (node.type.name === 'table' && node === this.node) { + tablePos = pos + return false + } + return true + }) + + if (tablePos >= 0) { + const map = TableMap.get(this.node) + const firstCellInRow = map.map[rowIndex * map.width] + const lastCellInRow = map.map[rowIndex * map.width + map.width - 1] + + const firstCellPos = tablePos + 1 + firstCellInRow + const lastCellPos = tablePos + 1 + lastCellInRow + + const selection = CellSelection.create(state.doc, firstCellPos, lastCellPos) + const tr = state.tr.setSelection(selection) + dispatch(tr) + } + } + + selectColumn(colIndex: number) { + const { state, dispatch } = this.view + + // Find the table position + let tablePos = -1 + state.doc.descendants((node: ProseMirrorNode, pos: number) => { + if (node.type.name === 'table' && node === this.node) { + tablePos = pos + return false + } + return true + }) + + if (tablePos >= 0) { + const map = TableMap.get(this.node) + const firstCellInCol = map.map[colIndex] + const lastCellInCol = map.map[(map.height - 1) * map.width + colIndex] + + const firstCellPos = tablePos + 1 + firstCellInCol + const lastCellPos = tablePos + 1 + lastCellInCol + + const selection = CellSelection.create(state.doc, firstCellPos, lastCellPos) + const tr = state.tr.setSelection(selection) + dispatch(tr) + } + } + + destroy() { + this.addRowButton?.remove() + this.addColumnButton?.remove() + if (this.rowEndpoint) this.rowEndpoint.remove() + if (this.colEndpoint) this.colEndpoint.remove() + if (this.selectionChangeDisposer) this.selectionChangeDisposer() + if (this.overlayUpdateRafId !== null) cancelAnimationFrame(this.overlayUpdateRafId) + } +} diff --git a/packages/extension-table-plus/src/table/index.ts b/packages/extension-table-plus/src/table/index.ts new file mode 100755 index 0000000000..040a250704 --- /dev/null +++ b/packages/extension-table-plus/src/table/index.ts @@ -0,0 +1,3 @@ +export * from './table.js' +export * from './utilities/createColGroup.js' +export * from './utilities/createTable.js' diff --git a/packages/extension-table-plus/src/table/table.ts b/packages/extension-table-plus/src/table/table.ts new file mode 100755 index 0000000000..d0cdf8304b --- /dev/null +++ b/packages/extension-table-plus/src/table/table.ts @@ -0,0 +1,486 @@ +import '../types.js' + +import { callOrReturn, getExtensionField, mergeAttributes, Node } from '@tiptap/core' +import type { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model' +import { TextSelection } from '@tiptap/pm/state' +import { + addColumnAfter, + addColumnBefore, + addRowAfter, + addRowBefore, + CellSelection, + columnResizing, + deleteColumn, + deleteRow, + deleteTable, + fixTables, + goToNextCell, + mergeCells, + setCellAttr, + splitCell, + tableEditing, + toggleHeader, + toggleHeaderCell +} from '@tiptap/pm/tables' +import { type EditorView, type NodeView } from '@tiptap/pm/view' + +import { TableView } from './TableView.js' +import { createColGroup } from './utilities/createColGroup.js' +import { createTable } from './utilities/createTable.js' +import { deleteTableWhenAllCellsSelected } from './utilities/deleteTableWhenAllCellsSelected.js' + +export interface TableOptions { + /** + * HTML attributes for the table element. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record + + /** + * Enables the resizing of tables. + * @default false + * @example true + */ + resizable: boolean + + /** + * The width of the resize handle. + * @default 5 + * @example 10 + */ + handleWidth: number + + /** + * The minimum width of a cell. + * @default 25 + * @example 50 + */ + cellMinWidth: number + + /** + * The node view to render the table. + * @default TableView + */ + View: (new (node: ProseMirrorNode, cellMinWidth: number, view: EditorView) => NodeView) | null + + /** + * Enables the resizing of the last column. + * @default true + * @example false + */ + lastColumnResizable: boolean + + /** + * Allow table node selection. + * @default false + * @example true + */ + allowTableNodeSelection: boolean + + /** + * Optional callbacks for row/column action triggers + */ + onRowActionClick?: (args: { rowIndex: number; view: EditorView; position?: { x: number; y: number } }) => void + onColumnActionClick?: (args: { colIndex: number; view: EditorView; position?: { x: number; y: number } }) => void +} + +declare module '@tiptap/core' { + interface Commands { + table: { + /** + * Insert a table + * @param options The table attributes + * @returns True if the command was successful, otherwise false + * @example editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + */ + insertTable: (options?: { rows?: number; cols?: number; withHeaderRow?: boolean }) => ReturnType + + /** + * Add a column before the current column + * @returns True if the command was successful, otherwise false + * @example editor.commands.addColumnBefore() + */ + addColumnBefore: () => ReturnType + + /** + * Add a column after the current column + * @returns True if the command was successful, otherwise false + * @example editor.commands.addColumnAfter() + */ + addColumnAfter: () => ReturnType + + /** + * Delete the current column + * @returns True if the command was successful, otherwise false + * @example editor.commands.deleteColumn() + */ + deleteColumn: () => ReturnType + + /** + * Add a row before the current row + * @returns True if the command was successful, otherwise false + * @example editor.commands.addRowBefore() + */ + addRowBefore: () => ReturnType + + /** + * Add a row after the current row + * @returns True if the command was successful, otherwise false + * @example editor.commands.addRowAfter() + */ + addRowAfter: () => ReturnType + + /** + * Delete the current row + * @returns True if the command was successful, otherwise false + * @example editor.commands.deleteRow() + */ + deleteRow: () => ReturnType + + /** + * Delete the current table + * @returns True if the command was successful, otherwise false + * @example editor.commands.deleteTable() + */ + deleteTable: () => ReturnType + + /** + * Merge the currently selected cells + * @returns True if the command was successful, otherwise false + * @example editor.commands.mergeCells() + */ + mergeCells: () => ReturnType + + /** + * Split the currently selected cell + * @returns True if the command was successful, otherwise false + * @example editor.commands.splitCell() + */ + splitCell: () => ReturnType + + /** + * Toggle the header column + * @returns True if the command was successful, otherwise false + * @example editor.commands.toggleHeaderColumn() + */ + toggleHeaderColumn: () => ReturnType + + /** + * Toggle the header row + * @returns True if the command was successful, otherwise false + * @example editor.commands.toggleHeaderRow() + */ + toggleHeaderRow: () => ReturnType + + /** + * Toggle the header cell + * @returns True if the command was successful, otherwise false + * @example editor.commands.toggleHeaderCell() + */ + toggleHeaderCell: () => ReturnType + + /** + * Merge or split the currently selected cells + * @returns True if the command was successful, otherwise false + * @example editor.commands.mergeOrSplit() + */ + mergeOrSplit: () => ReturnType + + /** + * Set a cell attribute + * @param name The attribute name + * @param value The attribute value + * @returns True if the command was successful, otherwise false + * @example editor.commands.setCellAttribute('align', 'right') + */ + setCellAttribute: (name: string, value: any) => ReturnType + + /** + * Moves the selection to the next cell + * @returns True if the command was successful, otherwise false + * @example editor.commands.goToNextCell() + */ + goToNextCell: () => ReturnType + + /** + * Moves the selection to the previous cell + * @returns True if the command was successful, otherwise false + * @example editor.commands.goToPreviousCell() + */ + goToPreviousCell: () => ReturnType + + /** + * Try to fix the table structure if necessary + * @returns True if the command was successful, otherwise false + * @example editor.commands.fixTables() + */ + fixTables: () => ReturnType + + /** + * Set a cell selection inside the current table + * @param position The cell position + * @returns True if the command was successful, otherwise false + * @example editor.commands.setCellSelection({ anchorCell: 1, headCell: 2 }) + */ + setCellSelection: (position: { anchorCell: number; headCell?: number }) => ReturnType + } + } +} + +/** + * This extension allows you to create tables. + * @see https://www.tiptap.dev/api/nodes/table + */ +export const Table = Node.create({ + name: 'table', + + // @ts-ignore - TODO: fix + addOptions() { + return { + HTMLAttributes: {}, + resizable: false, + handleWidth: 5, + cellMinWidth: 25, + // TODO: fix + View: TableView, + lastColumnResizable: true, + allowTableNodeSelection: false + } + }, + + content: 'tableRow+', + + tableRole: 'table', + + isolating: true, + + group: 'block', + + parseHTML() { + return [{ tag: 'table' }] + }, + + renderHTML({ node, HTMLAttributes }) { + const { colgroup, tableWidth, tableMinWidth } = createColGroup(node, this.options.cellMinWidth) + + const table: DOMOutputSpec = [ + 'table', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + style: tableWidth ? `width: ${tableWidth}` : `min-width: ${tableMinWidth}` + }), + colgroup, + ['tbody', 0] + ] + + return table + }, + + addCommands() { + return { + insertTable: + ({ rows = 3, cols = 3, withHeaderRow = true } = {}) => + ({ tr, dispatch, editor }) => { + // Disallow inserting table inside nested nodes when TableCell option allowNestedNodes is false + const tableCellExtension = this.editor.extensionManager.extensions.find((ext) => ext.name === 'tableCell') + const allowNestedNodes: boolean = tableCellExtension + ? Boolean((tableCellExtension.options as { allowNestedNodes?: boolean }).allowNestedNodes) + : false + + if (!allowNestedNodes) { + const { $from } = tr.selection + // Only allow table insertion at top-level (depth <= 1), + // disallow when selection is inside any nested node (list, blockquote, table, etc.) + if ($from.depth > 1) { + return false + } + } + + const node = createTable(editor.schema, rows, cols, withHeaderRow) + + if (dispatch) { + const offset = tr.selection.from + 1 + + tr.replaceSelectionWith(node) + .scrollIntoView() + .setSelection(TextSelection.near(tr.doc.resolve(offset))) + } + + return true + }, + addColumnBefore: + () => + ({ state, dispatch }) => { + return addColumnBefore(state, dispatch) + }, + addColumnAfter: + () => + ({ state, dispatch }) => { + return addColumnAfter(state, dispatch) + }, + deleteColumn: + () => + ({ state, dispatch }) => { + return deleteColumn(state, dispatch) + }, + addRowBefore: + () => + ({ state, dispatch }) => { + return addRowBefore(state, dispatch) + }, + addRowAfter: + () => + ({ state, dispatch }) => { + return addRowAfter(state, dispatch) + }, + deleteRow: + () => + ({ state, dispatch }) => { + return deleteRow(state, dispatch) + }, + deleteTable: + () => + ({ state, dispatch }) => { + return deleteTable(state, dispatch) + }, + mergeCells: + () => + ({ state, dispatch }) => { + return mergeCells(state, dispatch) + }, + splitCell: + () => + ({ state, dispatch }) => { + return splitCell(state, dispatch) + }, + toggleHeaderColumn: + () => + ({ state, dispatch }) => { + return toggleHeader('column')(state, dispatch) + }, + toggleHeaderRow: + () => + ({ state, dispatch }) => { + return toggleHeader('row')(state, dispatch) + }, + toggleHeaderCell: + () => + ({ state, dispatch }) => { + return toggleHeaderCell(state, dispatch) + }, + mergeOrSplit: + () => + ({ state, dispatch }) => { + if (mergeCells(state, dispatch)) { + return true + } + + return splitCell(state, dispatch) + }, + setCellAttribute: + (name, value) => + ({ state, dispatch }) => { + return setCellAttr(name, value)(state, dispatch) + }, + goToNextCell: + () => + ({ state, dispatch }) => { + return goToNextCell(1)(state, dispatch) + }, + goToPreviousCell: + () => + ({ state, dispatch }) => { + return goToNextCell(-1)(state, dispatch) + }, + fixTables: + () => + ({ state, dispatch }) => { + if (dispatch) { + fixTables(state) + } + + return true + }, + setCellSelection: + (position) => + ({ tr, dispatch }) => { + if (dispatch) { + const selection = CellSelection.create(tr.doc, position.anchorCell, position.headCell) + + // @ts-ignore - TODO: fix + tr.setSelection(selection) + } + + return true + } + } + }, + + addNodeView() { + return (props) => { + const { node, view } = props + const ViewClass = this.options.View || TableView + if (ViewClass === TableView) { + return new TableView(node, this.options.cellMinWidth, view, { + onRowActionClick: this.options.onRowActionClick, + onColumnActionClick: this.options.onColumnActionClick + }) + } + return new ViewClass(node, this.options.cellMinWidth, view) + } + }, + + addKeyboardShortcuts() { + return { + Tab: () => { + if (this.editor.commands.goToNextCell()) { + return true + } + + if (!this.editor.can().addRowAfter()) { + return false + } + + return this.editor.chain().addRowAfter().goToNextCell().run() + }, + 'Shift-Tab': () => this.editor.commands.goToPreviousCell(), + Backspace: deleteTableWhenAllCellsSelected, + 'Mod-Backspace': deleteTableWhenAllCellsSelected, + Delete: deleteTableWhenAllCellsSelected, + 'Mod-Delete': deleteTableWhenAllCellsSelected + } + }, + + addProseMirrorPlugins() { + const isResizable = this.options.resizable && this.editor.isEditable + + return [ + ...(isResizable + ? [ + columnResizing({ + handleWidth: this.options.handleWidth, + cellMinWidth: this.options.cellMinWidth, + defaultCellMinWidth: this.options.cellMinWidth, + View: this.options.View, + lastColumnResizable: this.options.lastColumnResizable + }) + ] + : []), + tableEditing({ + allowTableNodeSelection: this.options.allowTableNodeSelection + }) + ] + }, + + extendNodeSchema(extension) { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage + } + + return { + tableRole: callOrReturn(getExtensionField(extension, 'tableRole', context)) + } + } +}) diff --git a/packages/extension-table-plus/src/table/utilities/colStyle.ts b/packages/extension-table-plus/src/table/utilities/colStyle.ts new file mode 100755 index 0000000000..d54a259fda --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/colStyle.ts @@ -0,0 +1,9 @@ +export function getColStyleDeclaration(minWidth: number, width: number | undefined): [string, string] { + if (width) { + // apply the stored width unless it is below the configured minimum cell width + return ['width', `${Math.max(width, minWidth)}px`] + } + + // set the minimum with on the column if it has no stored width + return ['min-width', `${minWidth}px`] +} diff --git a/packages/extension-table-plus/src/table/utilities/createCell.ts b/packages/extension-table-plus/src/table/utilities/createCell.ts new file mode 100755 index 0000000000..2d95471c5c --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/createCell.ts @@ -0,0 +1,12 @@ +import type { Fragment, Node as ProsemirrorNode, NodeType } from '@tiptap/pm/model' + +export function createCell( + cellType: NodeType, + cellContent?: Fragment | ProsemirrorNode | Array +): ProsemirrorNode | null | undefined { + if (cellContent) { + return cellType.createChecked(null, cellContent) + } + + return cellType.createAndFill() +} diff --git a/packages/extension-table-plus/src/table/utilities/createColGroup.ts b/packages/extension-table-plus/src/table/utilities/createColGroup.ts new file mode 100755 index 0000000000..4a12d10cd8 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/createColGroup.ts @@ -0,0 +1,68 @@ +import type { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model' + +import { getColStyleDeclaration } from './colStyle.js' + +export type ColGroup = + | { + colgroup: DOMOutputSpec + tableWidth: string + tableMinWidth: string + } + | Record + +/** + * Creates a colgroup element for a table node in ProseMirror. + * + * @param node - The ProseMirror node representing the table. + * @param cellMinWidth - The minimum width of a cell in the table. + * @param overrideCol - (Optional) The index of the column to override the width of. + * @param overrideValue - (Optional) The width value to use for the overridden column. + * @returns An object containing the colgroup element, the total width of the table, and the minimum width of the table. + */ +export function createColGroup(node: ProseMirrorNode, cellMinWidth: number): ColGroup +export function createColGroup( + node: ProseMirrorNode, + cellMinWidth: number, + overrideCol: number, + overrideValue: number +): ColGroup +export function createColGroup( + node: ProseMirrorNode, + cellMinWidth: number, + overrideCol?: number, + overrideValue?: number +): ColGroup { + let totalWidth = 0 + let fixedWidth = true + const cols: DOMOutputSpec[] = [] + const row = node.firstChild + + if (!row) { + return {} + } + + for (let i = 0, col = 0; i < row.childCount; i += 1) { + const { colspan, colwidth } = row.child(i).attrs + + for (let j = 0; j < colspan; j += 1, col += 1) { + const hasWidth = overrideCol === col ? overrideValue : colwidth && (colwidth[j] as number | undefined) + + totalWidth += hasWidth || cellMinWidth + + if (!hasWidth) { + fixedWidth = false + } + + const [property, value] = getColStyleDeclaration(cellMinWidth, hasWidth) + + cols.push(['col', { style: `${property}: ${value}` }]) + } + } + + const tableWidth = fixedWidth ? `${totalWidth}px` : '' + const tableMinWidth = fixedWidth ? '' : `${totalWidth}px` + + const colgroup: DOMOutputSpec = ['colgroup', {}, ...cols] + + return { colgroup, tableWidth, tableMinWidth } +} diff --git a/packages/extension-table-plus/src/table/utilities/createTable.ts b/packages/extension-table-plus/src/table/utilities/createTable.ts new file mode 100755 index 0000000000..ae6d78c412 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/createTable.ts @@ -0,0 +1,40 @@ +import type { Fragment, Node as ProsemirrorNode, Schema } from '@tiptap/pm/model' + +import { createCell } from './createCell.js' +import { getTableNodeTypes } from './getTableNodeTypes.js' + +export function createTable( + schema: Schema, + rowsCount: number, + colsCount: number, + withHeaderRow: boolean, + cellContent?: Fragment | ProsemirrorNode | Array +): ProsemirrorNode { + const types = getTableNodeTypes(schema) + const headerCells: ProsemirrorNode[] = [] + const cells: ProsemirrorNode[] = [] + + for (let index = 0; index < colsCount; index += 1) { + const cell = createCell(types.cell, cellContent) + + if (cell) { + cells.push(cell) + } + + if (withHeaderRow) { + const headerCell = createCell(types.header_cell, cellContent) + + if (headerCell) { + headerCells.push(headerCell) + } + } + } + + const rows: ProsemirrorNode[] = [] + + for (let index = 0; index < rowsCount; index += 1) { + rows.push(types.row.createChecked(null, withHeaderRow && index === 0 ? headerCells : cells)) + } + + return types.table.createChecked(null, rows) +} diff --git a/packages/extension-table-plus/src/table/utilities/deleteTableWhenAllCellsSelected.ts b/packages/extension-table-plus/src/table/utilities/deleteTableWhenAllCellsSelected.ts new file mode 100755 index 0000000000..43eceefe07 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/deleteTableWhenAllCellsSelected.ts @@ -0,0 +1,38 @@ +import type { KeyboardShortcutCommand } from '@tiptap/core' +import { findParentNodeClosestToPos } from '@tiptap/core' + +import { isCellSelection } from './isCellSelection.js' + +export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => { + const { selection } = editor.state + + if (!isCellSelection(selection)) { + return false + } + + let cellCount = 0 + const table = findParentNodeClosestToPos(selection.ranges[0].$from, (node) => { + return node.type.name === 'table' + }) + + table?.node.descendants((node) => { + if (node.type.name === 'table') { + return false + } + + if (['tableCell', 'tableHeader'].includes(node.type.name)) { + cellCount += 1 + } + return true + }) + + const allCellsSelected = cellCount === selection.ranges.length + + if (!allCellsSelected) { + return false + } + + editor.commands.deleteTable() + + return true +} diff --git a/packages/extension-table-plus/src/table/utilities/getBorderWidth.ts b/packages/extension-table-plus/src/table/utilities/getBorderWidth.ts new file mode 100644 index 0000000000..29cb80f6f9 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/getBorderWidth.ts @@ -0,0 +1,14 @@ +export function getElementBorderWidth(element: HTMLElement): { + top: number + right: number + bottom: number + left: number +} { + const style = window.getComputedStyle(element) + return { + top: parseFloat(style.borderTopWidth), + right: parseFloat(style.borderRightWidth), + bottom: parseFloat(style.borderBottomWidth), + left: parseFloat(style.borderLeftWidth) + } +} diff --git a/packages/extension-table-plus/src/table/utilities/getTableNodeTypes.ts b/packages/extension-table-plus/src/table/utilities/getTableNodeTypes.ts new file mode 100755 index 0000000000..2365f4a3ad --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/getTableNodeTypes.ts @@ -0,0 +1,21 @@ +import type { NodeType, Schema } from '@tiptap/pm/model' + +export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } { + if (schema.cached.tableNodeTypes) { + return schema.cached.tableNodeTypes + } + + const roles: { [key: string]: NodeType } = {} + + Object.keys(schema.nodes).forEach((type) => { + const nodeType = schema.nodes[type] + + if (nodeType.spec.tableRole) { + roles[nodeType.spec.tableRole] = nodeType + } + }) + + schema.cached.tableNodeTypes = roles + + return roles +} diff --git a/packages/extension-table-plus/src/table/utilities/isCellSelection.ts b/packages/extension-table-plus/src/table/utilities/isCellSelection.ts new file mode 100755 index 0000000000..59d8919f02 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/isCellSelection.ts @@ -0,0 +1,5 @@ +import { CellSelection } from '@tiptap/pm/tables' + +export function isCellSelection(value: unknown): value is CellSelection { + return value instanceof CellSelection +} diff --git a/packages/extension-table-plus/src/table/utilities/selectionBounds.ts b/packages/extension-table-plus/src/table/utilities/selectionBounds.ts new file mode 100644 index 0000000000..186bfa884e --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/selectionBounds.ts @@ -0,0 +1,68 @@ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { CellSelection, TableMap } from '@tiptap/pm/tables' +import type { EditorView } from '@tiptap/pm/view' + +export interface SelectionBounds { + tablePos: number + tableStart: number + map: ReturnType + minRow: number + maxRow: number + minCol: number + maxCol: number + topLeftPos: number + topRightPos: number +} + +/** + * Compute logical bounds for current CellSelection inside the provided table node. + * Returns null if current selection is not a CellSelection or not within the table node. + */ +export function getCellSelectionBounds(view: EditorView, tableNode: ProseMirrorNode): SelectionBounds | null { + const selection = view.state.selection + if (!(selection instanceof CellSelection)) return null + + const $anchor = selection.$anchorCell || selection.$anchor + let tablePos = -1 + let currentTable: ProseMirrorNode | null = null + for (let d = $anchor.depth; d > 0; d--) { + const n = $anchor.node(d) + const role = (n.type.spec as { tableRole?: string } | undefined)?.tableRole + if (n.type.name === 'table' || role === 'table') { + tablePos = $anchor.before(d) + currentTable = n + break + } + } + if (tablePos < 0 || currentTable !== tableNode) return null + + const map = TableMap.get(tableNode) + const tableStart = tablePos + 1 + + let minRow = Number.POSITIVE_INFINITY + let maxRow = Number.NEGATIVE_INFINITY + let minCol = Number.POSITIVE_INFINITY + let maxCol = Number.NEGATIVE_INFINITY + let topLeftPos: number | null = null + let topRightPos: number | null = null + + selection.forEachCell((_cell, pos) => { + const rect = map.findCell(pos - tableStart) + if (rect.top < minRow) minRow = rect.top + if (rect.left < minCol) minCol = rect.left + if (rect.bottom - 1 > maxRow) maxRow = rect.bottom - 1 + if (rect.right - 1 > maxCol) maxCol = rect.right - 1 + + if (rect.top === minRow && rect.left === minCol) { + if (topLeftPos === null || pos < topLeftPos) topLeftPos = pos + } + if (rect.top === minRow && rect.right - 1 === maxCol) { + if (topRightPos === null || pos < topRightPos) topRightPos = pos + } + }) + + if (!isFinite(minRow) || !isFinite(minCol) || topLeftPos == null) return null + if (topRightPos == null) topRightPos = topLeftPos + + return { tablePos, tableStart, map, minRow, maxRow, minCol, maxCol, topLeftPos, topRightPos } +} diff --git a/packages/extension-table-plus/src/types.ts b/packages/extension-table-plus/src/types.ts new file mode 100755 index 0000000000..eef697b269 --- /dev/null +++ b/packages/extension-table-plus/src/types.ts @@ -0,0 +1,19 @@ +import type { ParentConfig } from '@tiptap/core' + +declare module '@tiptap/core' { + interface NodeConfig { + /** + * A string or function to determine the role of the table. + * @default 'table' + * @example () => 'table' + */ + tableRole?: + | string + | ((this: { + name: string + options: Options + storage: Storage + parent: ParentConfig>['tableRole'] + }) => string) + } +} diff --git a/packages/extension-table-plus/tsdown.config.ts b/packages/extension-table-plus/tsdown.config.ts new file mode 100755 index 0000000000..8e7b52e10c --- /dev/null +++ b/packages/extension-table-plus/tsdown.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig( + [ + 'src/table/index.ts', + 'src/cell/index.ts', + 'src/header/index.ts', + 'src/kit/index.ts', + 'src/row/index.ts', + 'src/index.ts' + ].map((entry) => ({ + entry: [entry], + tsconfig: '../../tsconfig.build.json', + outDir: `dist${entry.replace('src', '').split('/').slice(0, -1).join('/')}`, + dts: true, + sourcemap: true, + format: ['esm', 'cjs'], + external: [/^[^./]/] + })) +) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 19df95332e..26e6a2764a 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -140,16 +140,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 +168,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', diff --git a/packages/shared/config/types.ts b/packages/shared/config/types.ts index 28bb4acf65..d46717b47e 100644 --- a/packages/shared/config/types.ts +++ b/packages/shared/config/types.ts @@ -9,3 +9,11 @@ export type LoaderReturn = { message?: string messageSource?: 'preprocess' | 'embedding' } + +export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir' + +export type FileChangeEvent = { + eventType: FileChangeEventType + filePath: string + watchPath: string +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 33d45531e0..365b6173cd 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -58,7 +58,15 @@ import { setOpenLinkExternal } from './services/WebviewService' import { windowService } from './services/WindowService' import { calculateDirectorySize, getResourcePath } from './utils' import { decrypt, encrypt } from './utils/aes' -import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, isPathInside, untildify } from './utils/file' +import { + getCacheDir, + getConfigDir, + getFilesDir, + getNotesDir, + hasWritePermission, + isPathInside, + untildify +} from './utils/file' import { updateAppDataConfig } from './utils/init' import { compress, decompress } from './utils/zip' @@ -83,6 +91,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { isPackaged: app.isPackaged, appPath: app.getAppPath(), filesPath: getFilesDir(), + notesPath: getNotesDir(), configPath: getConfigDir(), appDataPath: app.getPath('userData'), resourcesPath: getResourcePath(), @@ -430,16 +439,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 +465,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) => { diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 39a16713d7..985f6dfef9 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -1,8 +1,18 @@ import { loggerService } from '@logger' -import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file' +import { + checkName, + getFilesDir, + getFileType, + getName, + getNotesDir, + getTempDir, + readTextFileWithAutoEncoding, + scanDir +} from '@main/utils/file' import { documentExts, imageExts, KB, MB } from '@shared/config/constant' -import { FileMetadata } from '@types' +import { FileMetadata, NotesTreeNode } from '@types' import chardet from 'chardet' +import chokidar, { FSWatcher } from 'chokidar' import * as crypto from 'crypto' import { dialog, @@ -26,9 +36,39 @@ import WordExtractor from 'word-extractor' const logger = loggerService.withContext('FileStorage') +interface FileWatcherConfig { + watchExtensions?: string[] + ignoredPatterns?: (string | RegExp)[] + debounceMs?: number + maxDepth?: number + usePolling?: boolean + retryOnError?: boolean + retryDelayMs?: number + stabilityThreshold?: number + eventChannel?: string +} + +const DEFAULT_WATCHER_CONFIG: Required = { + watchExtensions: ['.md', '.markdown', '.txt'], + ignoredPatterns: [/(^|[/\\])\../, '**/node_modules/**', '**/.git/**', '**/*.tmp', '**/*.temp', '**/.DS_Store'], + debounceMs: 1000, + maxDepth: 10, + usePolling: false, + retryOnError: true, + retryDelayMs: 5000, + stabilityThreshold: 500, + eventChannel: 'file-change' +} + class FileStorage { private storageDir = getFilesDir() + private notesDir = getNotesDir() private tempDir = getTempDir() + private watcher?: FSWatcher + private watcherSender?: Electron.WebContents + private currentWatchPath?: string + private debounceTimer?: NodeJS.Timeout + private watcherConfig: Required = DEFAULT_WATCHER_CONFIG constructor() { this.initStorageDir() @@ -39,6 +79,9 @@ class FileStorage { if (!fs.existsSync(this.storageDir)) { fs.mkdirSync(this.storageDir, { recursive: true }) } + if (!fs.existsSync(this.notesDir)) { + fs.mkdirSync(this.storageDir, { recursive: true }) + } if (!fs.existsSync(this.tempDir)) { fs.mkdirSync(this.tempDir, { recursive: true }) } @@ -209,7 +252,7 @@ class FileStorage { const ext = path.extname(filePath) const fileType = getFileType(ext) - const fileInfo: FileMetadata = { + return { id: uuidv4(), origin_name: path.basename(filePath), name: path.basename(filePath), @@ -220,8 +263,6 @@ class FileStorage { type: fileType, count: 1 } - - return fileInfo } // @TraceProperty({ spanName: 'deleteFile', tag: 'FileStorage' }) @@ -239,6 +280,122 @@ class FileStorage { await fs.promises.rm(path.join(this.storageDir, id), { recursive: true }) } + public deleteExternalFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise => { + try { + if (!fs.existsSync(filePath)) { + return + } + + await fs.promises.rm(filePath, { force: true }) + logger.debug(`External file deleted successfully: ${filePath}`) + } catch (error) { + logger.error('Failed to delete external file:', error as Error) + throw error + } + } + + public deleteExternalDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise => { + try { + if (!fs.existsSync(dirPath)) { + return + } + + await fs.promises.rm(dirPath, { recursive: true, force: true }) + logger.debug(`External directory deleted successfully: ${dirPath}`) + } catch (error) { + logger.error('Failed to delete external directory:', error as Error) + throw error + } + } + + public moveFile = async (_: Electron.IpcMainInvokeEvent, filePath: string, newPath: string): Promise => { + try { + if (!fs.existsSync(filePath)) { + throw new Error(`Source file does not exist: ${filePath}`) + } + + // 确保目标目录存在 + const destDir = path.dirname(newPath) + if (!fs.existsSync(destDir)) { + await fs.promises.mkdir(destDir, { recursive: true }) + } + + // 移动文件 + await fs.promises.rename(filePath, newPath) + logger.debug(`File moved successfully: ${filePath} to ${newPath}`) + } catch (error) { + logger.error('Move file failed:', error as Error) + throw error + } + } + + public moveDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string, newDirPath: string): Promise => { + try { + if (!fs.existsSync(dirPath)) { + throw new Error(`Source directory does not exist: ${dirPath}`) + } + + // 确保目标父目录存在 + const parentDir = path.dirname(newDirPath) + if (!fs.existsSync(parentDir)) { + await fs.promises.mkdir(parentDir, { recursive: true }) + } + + // 移动目录 + await fs.promises.rename(dirPath, newDirPath) + logger.debug(`Directory moved successfully: ${dirPath} to ${newDirPath}`) + } catch (error) { + logger.error('Move directory failed:', error as Error) + throw error + } + } + + public renameFile = async (_: Electron.IpcMainInvokeEvent, filePath: string, newName: string): Promise => { + try { + if (!fs.existsSync(filePath)) { + throw new Error(`Source file does not exist: ${filePath}`) + } + + const dirPath = path.dirname(filePath) + const newFilePath = path.join(dirPath, newName + '.md') + + // 如果目标文件已存在,抛出错误 + if (fs.existsSync(newFilePath)) { + throw new Error(`Target file already exists: ${newFilePath}`) + } + + // 重命名文件 + await fs.promises.rename(filePath, newFilePath) + logger.debug(`File renamed successfully: ${filePath} to ${newFilePath}`) + } catch (error) { + logger.error('Rename file failed:', error as Error) + throw error + } + } + + public renameDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string, newName: string): Promise => { + try { + if (!fs.existsSync(dirPath)) { + throw new Error(`Source directory does not exist: ${dirPath}`) + } + + const parentDir = path.dirname(dirPath) + const newDirPath = path.join(parentDir, newName) + + // 如果目标目录已存在,抛出错误 + if (fs.existsSync(newDirPath)) { + throw new Error(`Target directory already exists: ${newDirPath}`) + } + + // 重命名目录 + await fs.promises.rename(dirPath, newDirPath) + logger.debug(`Directory renamed successfully: ${dirPath} to ${newDirPath}`) + } catch (error) { + logger.error('Rename directory failed:', error as Error) + throw error + } + } + public readFile = async ( _: Electron.IpcMainInvokeEvent, id: string, @@ -282,6 +439,51 @@ class FileStorage { } } + public readExternalFile = async ( + _: Electron.IpcMainInvokeEvent, + filePath: string, + detectEncoding: boolean = false + ): Promise => { + if (!fs.existsSync(filePath)) { + throw new Error(`File does not exist: ${filePath}`) + } + + const fileExtension = path.extname(filePath) + + if (documentExts.includes(fileExtension)) { + const originalCwd = process.cwd() + try { + chdir(this.tempDir) + + if (fileExtension === '.doc') { + const extractor = new WordExtractor() + const extracted = await extractor.extract(filePath) + chdir(originalCwd) + return extracted.getBody() + } + + const data = await officeParser.parseOfficeAsync(filePath) + chdir(originalCwd) + return data + } catch (error) { + chdir(originalCwd) + logger.error('Failed to read file:', error as Error) + throw error + } + } + + try { + if (detectEncoding) { + return readTextFileWithAutoEncoding(filePath) + } else { + return fs.readFileSync(filePath, 'utf-8') + } + } catch (error) { + logger.error('Failed to read file:', error as Error) + throw new Error(`Failed to read file: ${filePath}.`) + } + } + public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise => { if (!fs.existsSync(this.tempDir)) { fs.mkdirSync(this.tempDir, { recursive: true }) @@ -298,6 +500,32 @@ class FileStorage { await fs.promises.writeFile(filePath, data) } + public fileNameGuard = async ( + _: Electron.IpcMainInvokeEvent, + dirPath: string, + fileName: string, + isFile: boolean + ): Promise<{ safeName: string; exists: boolean }> => { + const safeName = checkName(fileName) + const finalName = getName(dirPath, safeName, isFile) + const fullPath = path.join(dirPath, finalName + (isFile ? '.md' : '')) + const exists = fs.existsSync(fullPath) + + logger.debug(`File name guard: ${fileName} -> ${finalName}, exists: ${exists}`) + return { safeName: finalName, exists } + } + + public mkdir = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise => { + try { + logger.debug(`Attempting to create directory: ${dirPath}`) + await fs.promises.mkdir(dirPath, { recursive: true }) + return dirPath + } catch (error) { + logger.error('Failed to create directory:', error as Error) + throw new Error(`Failed to create directory: ${dirPath}. Error: ${(error as Error).message}`) + } + } + public base64Image = async ( _: Electron.IpcMainInvokeEvent, id: string @@ -340,7 +568,7 @@ class FileStorage { await fs.promises.writeFile(destPath, buffer) - const fileMetadata: FileMetadata = { + return { id: uuid, origin_name: uuid + ext, name: uuid + ext, @@ -351,14 +579,84 @@ class FileStorage { type: getFileType(ext), count: 1 } - - return fileMetadata } catch (error) { logger.error('Failed to save base64 image:', error as Error) throw error } } + public savePastedImage = async ( + _: Electron.IpcMainInvokeEvent, + imageData: Uint8Array | Buffer, + extension?: string + ): Promise => { + try { + const uuid = uuidv4() + const ext = extension || '.png' + const destPath = path.join(this.storageDir, uuid + ext) + + logger.debug('Saving pasted image:', { + storageDir: this.storageDir, + destPath, + bufferSize: imageData.length + }) + + // 确保目录存在 + if (!fs.existsSync(this.storageDir)) { + fs.mkdirSync(this.storageDir, { recursive: true }) + } + + // 确保 imageData 是 Buffer + const buffer = Buffer.isBuffer(imageData) ? imageData : Buffer.from(imageData) + + // 如果图片大于1MB,进行压缩处理 + if (buffer.length > MB) { + await this.compressImageBuffer(buffer, destPath, ext) + } else { + await fs.promises.writeFile(destPath, buffer) + } + + const stats = await fs.promises.stat(destPath) + + return { + id: uuid, + origin_name: `pasted_image_${uuid}${ext}`, + name: uuid + ext, + path: destPath, + created_at: new Date().toISOString(), + size: stats.size, + ext: ext.slice(1), + type: getFileType(ext), + count: 1 + } + } catch (error) { + logger.error('Failed to save pasted image:', error as Error) + throw error + } + } + + private async compressImageBuffer(imageBuffer: Buffer, destPath: string, ext: string): Promise { + try { + // 创建临时文件 + const tempPath = path.join(this.tempDir, `temp_${uuidv4()}${ext}`) + await fs.promises.writeFile(tempPath, imageBuffer) + + // 使用现有的压缩方法 + await this.compressImage(tempPath, destPath) + + // 清理临时文件 + try { + await fs.promises.unlink(tempPath) + } catch (error) { + logger.warn('Failed to cleanup temp file:', error as Error) + } + } catch (error) { + logger.error('Image buffer compression failed, saving original:', error as Error) + // 压缩失败时保存原始文件 + await fs.promises.writeFile(destPath, imageBuffer) + } + } + public base64File = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: string; mime: string }> => { const filePath = path.join(this.storageDir, id) const buffer = await fs.promises.readFile(filePath) @@ -384,7 +682,7 @@ class FileStorage { public clear = async (): Promise => { await fs.promises.rm(this.storageDir, { recursive: true }) - await this.initStorageDir() + this.initStorageDir() } public clearTemp = async (): Promise => { @@ -432,6 +730,7 @@ class FileStorage { /** * 通过相对路径打开文件,跨设备时使用 + * @param _ * @param file */ public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise => { @@ -443,6 +742,79 @@ class FileStorage { } } + public getDirectoryStructure = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise => { + try { + return await scanDir(dirPath) + } catch (error) { + logger.error('Failed to get directory structure:', error as Error) + throw error + } + } + + public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise => { + try { + if (!dirPath || typeof dirPath !== 'string') { + return false + } + + // Normalize path + const normalizedPath = path.resolve(dirPath) + + // Check if directory exists + if (!fs.existsSync(normalizedPath)) { + return false + } + + // Check if it's actually a directory + const stats = fs.statSync(normalizedPath) + if (!stats.isDirectory()) { + return false + } + + // Get app paths to prevent selection of restricted directories + const appDataPath = path.resolve(process.env.APPDATA || path.join(require('os').homedir(), '.config')) + const filesDir = path.resolve(getFilesDir()) + const currentNotesDir = path.resolve(getNotesDir()) + + // Prevent selecting app data directories + if ( + normalizedPath.startsWith(filesDir) || + normalizedPath.startsWith(appDataPath) || + normalizedPath === currentNotesDir + ) { + logger.warn(`Invalid directory selection: ${normalizedPath} (app data directory)`) + return false + } + + // Prevent selecting system root directories + const isSystemRoot = + process.platform === 'win32' + ? /^[a-zA-Z]:[\\/]?$/.test(normalizedPath) + : normalizedPath === '/' || + normalizedPath === '/usr' || + normalizedPath === '/etc' || + normalizedPath === '/System' + + if (isSystemRoot) { + logger.warn(`Invalid directory selection: ${normalizedPath} (system root directory)`) + return false + } + + // Check write permissions + try { + fs.accessSync(normalizedPath, fs.constants.W_OK) + } catch (error) { + logger.warn(`Directory not writable: ${normalizedPath}`) + return false + } + + return true + } catch (error) { + logger.error('Failed to validate notes directory:', error as Error) + return false + } + } + public save = async ( _: Electron.IpcMainInvokeEvent, fileName: string, @@ -461,7 +833,7 @@ class FileStorage { } if (!result.canceled && result.filePath) { - await writeFileSync(result.filePath, content, { encoding: 'utf-8' }) + writeFileSync(result.filePath, content, { encoding: 'utf-8' }) } return result.filePath @@ -552,7 +924,7 @@ class FileStorage { const stats = await fs.promises.stat(destPath) const fileType = getFileType(ext) - const fileMetadata: FileMetadata = { + return { id: uuid, origin_name: filename, name: uuid + ext, @@ -563,8 +935,6 @@ class FileStorage { type: fileType, count: 1 } - - return fileMetadata } catch (error) { logger.error('Download file error:', error as Error) throw error @@ -629,6 +999,205 @@ class FileStorage { } } + public startFileWatcher = async ( + event: Electron.IpcMainInvokeEvent, + dirPath: string, + config?: FileWatcherConfig + ): Promise => { + try { + this.watcherConfig = { ...DEFAULT_WATCHER_CONFIG, ...config } + + if (!dirPath?.trim()) { + throw new Error('Directory path is required') + } + + const normalizedPath = path.resolve(dirPath.trim()) + + if (!fs.existsSync(normalizedPath)) { + throw new Error(`Directory does not exist: ${normalizedPath}`) + } + + const stats = fs.statSync(normalizedPath) + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${normalizedPath}`) + } + + if (this.currentWatchPath === normalizedPath && this.watcher) { + this.watcherSender = event.sender + logger.debug('Already watching directory, updated sender', { path: normalizedPath }) + return + } + + await this.stopFileWatcher() + + logger.info('Starting file watcher', { + path: normalizedPath, + config: { + extensions: this.watcherConfig.watchExtensions, + debounceMs: this.watcherConfig.debounceMs, + maxDepth: this.watcherConfig.maxDepth + } + }) + + this.currentWatchPath = normalizedPath + this.watcherSender = event.sender + + const watchOptions = { + ignored: this.watcherConfig.ignoredPatterns, + persistent: true, + ignoreInitial: true, + depth: this.watcherConfig.maxDepth, + usePolling: this.watcherConfig.usePolling, + awaitWriteFinish: { + stabilityThreshold: this.watcherConfig.stabilityThreshold, + pollInterval: 100 + }, + alwaysStat: false, + atomic: true + } + + this.watcher = chokidar.watch(normalizedPath, watchOptions) + + const handleChange = this.createChangeHandler() + + this.watcher + .on('add', (filePath: string) => handleChange('add', filePath)) + .on('unlink', (filePath: string) => handleChange('unlink', filePath)) + .on('addDir', (dirPath: string) => handleChange('addDir', dirPath)) + .on('unlinkDir', (dirPath: string) => handleChange('unlinkDir', dirPath)) + .on('error', (error: unknown) => { + logger.error('File watcher error', { error: error as Error, path: normalizedPath }) + if (this.watcherConfig.retryOnError) { + this.handleWatcherError(error as Error) + } + }) + .on('ready', () => { + logger.debug('File watcher ready', { path: normalizedPath }) + }) + + logger.info('File watcher started successfully') + } catch (error) { + logger.error('Failed to start file watcher', error as Error) + this.cleanup() + throw error + } + } + + private createChangeHandler() { + return (eventType: string, filePath: string) => { + if (!this.shouldWatchFile(filePath, eventType)) { + return + } + + logger.debug('File change detected', { eventType, filePath, path: this.currentWatchPath }) + + // 对于目录操作,立即触发同步,不使用防抖 + if (eventType === 'addDir' || eventType === 'unlinkDir') { + logger.debug('Directory operation detected, triggering immediate sync', { eventType, filePath }) + this.notifyChange(eventType, filePath) + return + } + + // 对于文件操作,使用防抖机制 + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + } + + this.debounceTimer = setTimeout(() => { + this.notifyChange(eventType, filePath) + this.debounceTimer = undefined + }, this.watcherConfig.debounceMs) + } + } + + private shouldWatchFile(filePath: string, eventType: string): boolean { + if (eventType.includes('Dir')) { + return true + } + + const ext = path.extname(filePath).toLowerCase() + return this.watcherConfig.watchExtensions.includes(ext) + } + + private notifyChange(eventType: string, filePath: string) { + try { + if (!this.watcherSender || this.watcherSender.isDestroyed()) { + logger.warn('Sender destroyed, stopping watcher') + this.stopFileWatcher() + return + } + + logger.debug('Sending file change event', { + eventType, + filePath, + channel: this.watcherConfig.eventChannel, + senderExists: !!this.watcherSender, + senderDestroyed: this.watcherSender.isDestroyed() + }) + this.watcherSender.send(this.watcherConfig.eventChannel, { + eventType, + filePath, + watchPath: this.currentWatchPath + }) + logger.debug('File change event sent successfully') + } catch (error) { + logger.error('Failed to send notification', error as Error) + } + } + + private handleWatcherError(error: Error) { + const retryableErrors = ['EMFILE', 'ENFILE', 'ENOSPC'] + const isRetryable = retryableErrors.some((code) => error.message.includes(code)) + + if (isRetryable && this.currentWatchPath && this.watcherSender && !this.watcherSender.isDestroyed()) { + logger.warn('Attempting restart due to recoverable error', { error: error.message }) + + setTimeout(async () => { + try { + if (this.currentWatchPath && this.watcherSender && !this.watcherSender.isDestroyed()) { + const mockEvent = { sender: this.watcherSender } as Electron.IpcMainInvokeEvent + await this.startFileWatcher(mockEvent, this.currentWatchPath, this.watcherConfig) + } + } catch (retryError) { + logger.error('Restart failed', retryError as Error) + } + }, this.watcherConfig.retryDelayMs) + } + } + + private cleanup() { + this.currentWatchPath = undefined + this.watcherSender = undefined + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = undefined + } + } + + public stopFileWatcher = async (): Promise => { + try { + if (this.watcher) { + logger.info('Stopping file watcher', { path: this.currentWatchPath }) + await this.watcher.close() + this.watcher = undefined + logger.debug('File watcher stopped') + } + this.cleanup() + } catch (error) { + logger.error('Failed to stop file watcher', error as Error) + this.watcher = undefined + this.cleanup() + } + } + + public getWatcherStatus(): { isActive: boolean; watchPath?: string; hasValidSender: boolean } { + return { + isActive: !!this.watcher, + watchPath: this.currentWatchPath, + hasValidSender: !!this.watcherSender && !this.watcherSender.isDestroyed() + } + } + public getFilePathById(file: FileMetadata): string { return path.join(this.storageDir, file.id + file.ext) } diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 150a28eaca..e683f4faea 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -5,7 +5,7 @@ import path from 'node:path' import { loggerService } from '@logger' import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant' -import { FileMetadata, FileTypes } from '@types' +import { FileMetadata, FileTypes, NotesTreeNode } from '@types' import chardet from 'chardet' import { app } from 'electron' import iconv from 'iconv-lite' @@ -148,6 +148,15 @@ export function getFilesDir() { return path.join(app.getPath('userData'), 'Data', 'Files') } +export function getNotesDir() { + const notesDir = path.join(app.getPath('userData'), 'Data', 'Notes') + if (!fs.existsSync(notesDir)) { + fs.mkdirSync(notesDir, { recursive: true }) + logger.info(`Notes directory created at: ${notesDir}`) + } + return notesDir +} + export function getConfigDir() { return path.join(os.homedir(), '.cherrystudio', 'config') } @@ -195,3 +204,216 @@ export async function readTextFileWithAutoEncoding(filePath: string): Promise { + const options = { + includeFiles: true, + includeDirectories: true, + fileExtensions: ['.md'], + ignoreHiddenFiles: true, + recursive: true, + maxDepth: 10 + } + + // 如果是第一次调用,设置basePath为当前目录 + if (!basePath) { + basePath = dirPath + } + + if (options.maxDepth !== undefined && depth > options.maxDepth) { + return [] + } + + if (!fs.existsSync(dirPath)) { + loggerService.withContext('Utils:File').warn(`Dir not exist: ${dirPath}`) + return [] + } + + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }) + const result: NotesTreeNode[] = [] + + for (const entry of entries) { + if (options.ignoreHiddenFiles && entry.name.startsWith('.')) { + continue + } + + const entryPath = path.join(dirPath, entry.name) + + const relativePath = path.relative(basePath, entryPath) + const treePath = '/' + relativePath.replace(/\\/g, '/') + + if (entry.isDirectory() && options.includeDirectories) { + const stats = await fs.promises.stat(entryPath) + const dirTreeNode: NotesTreeNode = { + id: uuidv4(), + name: entry.name, + treePath: treePath, + externalPath: entryPath, + createdAt: stats.birthtime.toISOString(), + updatedAt: stats.mtime.toISOString(), + type: 'folder', + children: [] // 添加 children 属性 + } + + // 如果启用了递归扫描,则递归调用 scanDir + if (options.recursive) { + dirTreeNode.children = await scanDir(entryPath, depth + 1, basePath) + } + + result.push(dirTreeNode) + } else if (entry.isFile() && options.includeFiles) { + const ext = path.extname(entry.name).toLowerCase() + if (options.fileExtensions.length > 0 && !options.fileExtensions.includes(ext)) { + continue + } + + const stats = await fs.promises.stat(entryPath) + const name = entry.name.endsWith(options.fileExtensions[0]) + ? entry.name.slice(0, -options.fileExtensions[0].length) + : entry.name + + // 对于文件,treePath应该使用不带扩展名的路径 + const nameWithoutExt = path.basename(entryPath, path.extname(entryPath)) + const dirRelativePath = path.relative(basePath, path.dirname(entryPath)) + const fileTreePath = dirRelativePath + ? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}` + : `/${nameWithoutExt}` + + const fileTreeNode: NotesTreeNode = { + id: uuidv4(), + name: name, + treePath: fileTreePath, + externalPath: entryPath, + createdAt: stats.birthtime.toISOString(), + updatedAt: stats.mtime.toISOString(), + type: 'file' + } + result.push(fileTreeNode) + } + } + + return result +} + +/** + * 文件名唯一性约束 + * @param baseDir 基础目录 + * @param fileName 文件名 + * @param isFile 是否为文件 + * @returns 唯一的文件名 + */ +export function getName(baseDir: string, fileName: string, isFile: boolean): string { + // 首先清理文件名 + const sanitizedName = sanitizeFilename(fileName) + const baseName = sanitizedName.replace(/\d+$/, '') + let candidate = isFile ? baseName + '.md' : baseName + let counter = 1 + + while (fs.existsSync(path.join(baseDir, candidate))) { + candidate = isFile ? `${baseName}${counter}.md` : `${baseName}${counter}` + counter++ + } + + return isFile ? candidate.slice(0, -3) : candidate +} + +/** + * 文件名合法性校验 + * @param fileName 文件名 + * @param platform 平台,默认为当前运行平台 + * @returns 验证结果 + */ +export function validateFileName(fileName: string, platform = process.platform): { valid: boolean; error?: string } { + if (!fileName) { + return { valid: false, error: 'File name cannot be empty' } + } + + // 通用检查 + if (fileName.length === 0 || fileName.length > 255) { + return { valid: false, error: 'File name length must be between 1 and 255 characters' } + } + + // 检查 null 字符(所有系统都不允许) + if (fileName.includes('\0')) { + return { valid: false, error: 'File name cannot contain null characters.' } + } + + // Windows 特殊限制 + if (platform === 'win32') { + const winInvalidChars = /[<>:"/\\|?*]/ + if (winInvalidChars.test(fileName)) { + return { valid: false, error: 'File name contains characters not supported by Windows: < > : " / \\ | ? *' } + } + + const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i + if (reservedNames.test(fileName)) { + return { valid: false, error: 'File name is a Windows reserved name.' } + } + + if (fileName.endsWith('.') || fileName.endsWith(' ')) { + return { valid: false, error: 'File name cannot end with a dot or a space' } + } + } + + // Unix/Linux/macOS 限制 + if (platform !== 'win32') { + if (fileName.includes('/')) { + return { valid: false, error: 'File name cannot contain slashes /' } + } + } + + // macOS 额外限制 + if (platform === 'darwin') { + if (fileName.includes(':')) { + return { valid: false, error: 'macOS filenames cannot contain a colon :' } + } + } + + return { valid: true } +} + +/** + * 文件名合法性检查 + * @param fileName 文件名 + * @throws 如果文件名不合法则抛出异常 + * @returns 合法的文件名 + */ +export function checkName(fileName: string): string { + const validation = validateFileName(fileName) + if (!validation.valid) { + throw new Error(`Invalid file name: ${fileName}. ${validation.error}`) + } + return fileName +} + +/** + * 清理文件名,替换不合法字符 + * @param fileName 原始文件名 + * @param replacement 替换字符,默认为下划线 + * @returns 清理后的文件名 + */ +export function sanitizeFilename(fileName: string, replacement = '_'): string { + if (!fileName) return '' + + // 移除或替换非法字符 + let sanitized = fileName + // eslint-disable-next-line no-control-regex + .replace(/[<>:"/\\|?*\x00-\x1f]/g, replacement) // Windows 非法字符 + .replace(/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i, replacement + '$2') // Windows 保留名 + .replace(/[\s.]+$/, '') // 移除末尾的空格和点 + .substring(0, 255) // 限制长度 + + // 确保不为空 + if (!sanitized) { + sanitized = 'untitled' + } + + return sanitized +} diff --git a/src/preload/index.ts b/src/preload/index.ts index fb9e37c89e..0f3c358962 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,6 +4,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import { SpanContext } from '@opentelemetry/api' import { UpgradeChannel } from '@shared/config/constant' import type { LogLevel, LogSourceWithContext } from '@shared/config/logger' +import type { FileChangeEvent } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' import { AddMemoryOptions, @@ -141,33 +142,33 @@ const api = { upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file), delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId), deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath), + deleteExternalFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteExternalFile, filePath), + deleteExternalDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteExternalDir, dirPath), + move: (path: string, newPath: string) => ipcRenderer.invoke(IpcChannel.File_Move, path, newPath), + moveDir: (dirPath: string, newDirPath: string) => ipcRenderer.invoke(IpcChannel.File_MoveDir, dirPath, newDirPath), + rename: (path: string, newName: string) => ipcRenderer.invoke(IpcChannel.File_Rename, path, newName), + renameDir: (dirPath: string, newName: string) => ipcRenderer.invoke(IpcChannel.File_RenameDir, dirPath, newName), read: (fileId: string, detectEncoding?: boolean) => ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding), + readExternal: (filePath: string, detectEncoding?: boolean) => + ipcRenderer.invoke(IpcChannel.File_ReadExternal, filePath, detectEncoding), clear: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_Clear, spanContext), get: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_Get, filePath), - /** - * 创建一个空的临时文件 - * @param fileName 文件名 - * @returns 临时文件路径 - */ createTempFile: (fileName: string): Promise => ipcRenderer.invoke(IpcChannel.File_CreateTempFile, fileName), - /** - * 写入文件 - * @param filePath 文件路径 - * @param data 数据 - */ + mkdir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_Mkdir, dirPath), write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke(IpcChannel.File_Write, filePath, data), - writeWithId: (id: string, content: string) => ipcRenderer.invoke(IpcChannel.File_WriteWithId, id, content), open: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Open, options), openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path), save: (path: string, content: string | NodeJS.ArrayBufferView, options?: any) => ipcRenderer.invoke(IpcChannel.File_Save, path, content, options), - selectFolder: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_SelectFolder, spanContext), + selectFolder: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_SelectFolder, options), saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data), binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId), base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId), saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data), + savePastedImage: (imageData: Uint8Array, extension?: string) => + ipcRenderer.invoke(IpcChannel.File_SavePastedImage, imageData, extension), download: (url: string, isUseContentType?: boolean) => ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType), copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath), @@ -175,7 +176,23 @@ const api = { pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId), getPathForFile: (file: File) => webUtils.getPathForFile(file), openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file), - isTextFile: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath) + isTextFile: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath), + getDirectoryStructure: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_GetDirectoryStructure, dirPath), + checkFileName: (dirPath: string, fileName: string, isFile: boolean) => + ipcRenderer.invoke(IpcChannel.File_CheckFileName, dirPath, fileName, isFile), + validateNotesDirectory: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_ValidateNotesDirectory, dirPath), + startFileWatcher: (dirPath: string, config?: any) => + ipcRenderer.invoke(IpcChannel.File_StartWatcher, dirPath, config), + stopFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_StopWatcher), + onFileChange: (callback: (data: FileChangeEvent) => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: any) => { + if (data && typeof data === 'object') { + callback(data) + } + } + ipcRenderer.on('file-change', listener) + return () => ipcRenderer.off('file-change', listener) + } }, fs: { read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ad18a9b193..703015e30e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2,6 +2,7 @@ import '@renderer/databases' import { loggerService } from '@logger' import store, { persistor } from '@renderer/store' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Provider } from 'react-redux' import { PersistGate } from 'redux-persist/integration/react' @@ -15,26 +16,38 @@ import Router from './Router' const logger = loggerService.withContext('App.tsx') +// 创建 React Query 客户端 +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5 minutes + refetchOnWindowFocus: false + } + } +}) + function App(): React.ReactElement { logger.info('App initialized') return ( - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + ) } diff --git a/src/renderer/src/Router.tsx b/src/renderer/src/Router.tsx index 36d045aae5..8985a1a41d 100644 --- a/src/renderer/src/Router.tsx +++ b/src/renderer/src/Router.tsx @@ -15,6 +15,7 @@ import HomePage from './pages/home/HomePage' import KnowledgePage from './pages/knowledge/KnowledgePage' import LaunchpadPage from './pages/launchpad/LaunchpadPage' import MinAppsPage from './pages/minapps/MinAppsPage' +import NotesPage from './pages/notes/NotesPage' import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage' import SettingsPage from './pages/settings/SettingsPage' import TranslatePage from './pages/translate/TranslatePage' @@ -31,6 +32,7 @@ const Router: FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/renderer/src/assets/styles/CommandListPopover.scss b/src/renderer/src/assets/styles/CommandListPopover.scss new file mode 100644 index 0000000000..e2521c57b3 --- /dev/null +++ b/src/renderer/src/assets/styles/CommandListPopover.scss @@ -0,0 +1,59 @@ +.command-list-popover { + // Base styles are handled inline for theme support + + // Arrow styles based on placement + &[data-placement^='bottom'] { + transform-origin: top center; + animation: slideDownAndFadeIn 0.15s ease-out; + } + + &[data-placement^='top'] { + transform-origin: bottom center; + animation: slideUpAndFadeIn 0.15s ease-out; + } + + &[data-placement*='start'] { + transform-origin: left center; + } + + &[data-placement*='end'] { + transform-origin: right center; + } +} + +@keyframes slideDownAndFadeIn { + 0% { + opacity: 0; + transform: translateY(-8px) scale(0.95); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes slideUpAndFadeIn { + 0% { + opacity: 0; + transform: translateY(8px) scale(0.95); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +// Ensure smooth scrolling in virtual list +.command-list-popover .dynamic-virtual-list { + scroll-behavior: smooth; +} + +// Better focus indicators +.command-list-popover [data-index] { + position: relative; + + &:focus-visible { + outline: 2px solid var(--color-primary, #1677ff); + outline-offset: -2px; + } +} diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.scss index 1cb7d030b0..517160e76c 100644 --- a/src/renderer/src/assets/styles/color.scss +++ b/src/renderer/src/assets/styles/color.scss @@ -35,6 +35,8 @@ --color-error: #ff4d50; --color-link: #338cff; --color-code-background: #323232; + --color-inline-code-background: #323232; + --color-inline-code-text: rgb(218, 97, 92); --color-hover: rgba(40, 40, 40, 1); --color-active: rgba(55, 55, 55, 1); --color-frame-border: #333; @@ -115,6 +117,8 @@ --color-error: #ff4d50; --color-link: #1677ff; --color-code-background: #e3e3e3; + --color-inline-code-background: rgba(0, 0, 0, 0.06); + --color-inline-code-text: rgba(235, 87, 87); --color-hover: var(--color-white-mute); --color-active: var(--color-white-soft); --color-frame-border: #ddd; diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 0a6696bd9b..974457dc4d 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -5,6 +5,7 @@ @use './scrollbar.scss'; @use './container.scss'; @use './animation.scss'; +@use './richtext.scss'; @import '../fonts/icon-fonts/iconfont.css'; @import '../fonts/ubuntu/ubuntu.css'; @import '../fonts/country-flag-fonts/flag.css'; diff --git a/src/renderer/src/assets/styles/richtext.scss b/src/renderer/src/assets/styles/richtext.scss new file mode 100644 index 0000000000..91ebf0940d --- /dev/null +++ b/src/renderer/src/assets/styles/richtext.scss @@ -0,0 +1,493 @@ +.tiptap { + padding: 12px 60px; + outline: none; + min-height: 120px; + overflow-wrap: break-word; + word-break: break-word; + + &:focus { + outline: none; + } + + :first-child { + margin-top: 0; + } + + h1:first-child, + h2:first-child, + h3:first-child, + h4:first-child, + h5:first-child, + h6:first-child { + margin-top: 0; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 1.5rem 0 1rem 0; + line-height: 1.1; + text-wrap: pretty; + font-weight: 600; + + code { + font-size: inherit; + font-weight: inherit; + } + } + + h1 { + margin-top: 0; + font-size: 2rem; + } + + h2 { + font-size: 1.5rem; + } + + h3 { + font-size: 1.2rem; + } + + h4, + h5, + h6 { + font-size: 1rem; + } + + p { + margin: 1.1rem 0 0.5rem 0; + white-space: normal; + overflow-wrap: break-word; + word-break: break-word; + width: 100%; + line-height: 1.6; + hyphens: auto; + + &:has(+ ul) { + margin-bottom: 0; + } + } + + a { + color: var(--color-link); + text-decoration: none; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + blockquote { + border-left: 4px solid var(--color-primary); + margin: 1.5rem 0; + padding-left: 1rem; + } + + code { + background-color: var(--color-inline-code-background); + border-radius: 0.4rem; + color: var(--color-inline-code-text); + font-size: 0.85rem; + padding: 0.25em 0.3em; + font-family: var(--code-font-family); + } + + pre { + background: var(--color-code-background); + border-radius: 0.5rem; + color: var(--color-text); + font-family: var(--code-font-family); + margin: 1.5rem 0; + padding: 0.75rem 1rem; + border: 1px solid var(--color-border-soft); + + code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; + border: none; + } + } + + hr { + border: none; + border-top: 1px solid var(--color-gray-2); + margin: 2rem 0; + } + + em { + font-style: italic; + } + + u { + text-decoration: underline; + } + + strong, + strong * { + font-weight: 600; + } + + .placeholder { + position: relative; + + &:before { + content: attr(data-placeholder); + position: absolute; + color: var(--color-text-secondary); + opacity: 0.6; + pointer-events: none; + font-style: italic; + left: 0; + right: 0; + } + } + + /* Show placeholder only when focused or when it's the only empty node */ + .placeholder.has-focus:before { + opacity: 0.8; + } + + img { + max-width: 800px; + width: 100%; + height: auto; + } + + table { + border-collapse: collapse; + margin: 0; + /* Allow action endpoints (rendered as decorations) to slightly overflow table edges */ + overflow: visible; + table-layout: fixed; + width: 100%; + + td, + th { + border: 1px solid var(--color-border-soft); + box-sizing: border-box; + display: table-cell; + min-width: 120px; + padding: 6px 8px; + position: relative; + vertical-align: top; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + > * { + margin-bottom: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + th, + th * { + background-color: var(--color-gray-3); + font-weight: bold; + text-align: left; + } + + .selectedCell { + position: relative; // 确保伪元素定位 + } + .selectedCell::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + border: 0 solid var(--color-primary); + border-radius: 0; + } + .selectedCell.selection-top::after { + border-top-width: 2px; + } + .selectedCell.selection-bottom::after { + border-bottom-width: 2px; + } + .selectedCell.selection-left::after { + border-left-width: 2px; + } + .selectedCell.selection-right::after { + border-right-width: 2px; + } + + .column-resize-handle { + background-color: var(--color-primary); + bottom: -2px; + pointer-events: none; + position: absolute; + right: -2px; + top: 0; + width: 4px; + } + + &:has(.selectedCell) { + caret-color: transparent !important; + user-select: none !important; + + *::selection { + background: transparent !important; + } + + .column-resize-handle { + display: none; + } + } + + // Position row action buttons relative to first column cells + tbody tr td:first-child, + tbody tr th:first-child { + position: relative; + } + + // Position column action buttons relative to first row cells + tbody tr:first-child td, + tbody tr:first-child th { + position: relative; + } + } + + .tableWrapper { + position: relative; + margin: 1rem 0; + display: grid; + grid-template-columns: 1fr 25px; + grid-template-rows: 1fr 25px; + grid-template-areas: + 'table column-btn' + 'row-btn corner'; + gap: 5px; + + .table-container { + grid-area: table; + overflow-x: auto; + overflow-y: visible; + + &::-webkit-scrollbar { + cursor: default; + } + + &::-webkit-scrollbar:horizontal { + cursor: default; + } + + &::-webkit-scrollbar-thumb { + cursor: default; + } + + &::-webkit-scrollbar-track { + cursor: default; + } + + table { + width: max-content; + min-width: 100%; + } + } + + .add-row-button, + .add-column-button { + border: 1px solid var(--color-border); + background: var(--color-bg-base); + border-radius: 4px; + font-size: 12px; + line-height: 1; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + color: var(--color-text); + z-index: 20; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + pointer-events: auto; + + &:hover { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); + } + + &:active { + transform: scale(0.98); + } + + &::before { + content: '+'; + font-weight: bold; + } + } + + .add-row-button { + grid-area: row-btn; + } + + .add-column-button { + grid-area: column-btn; + } + + &:hover, + &:has(.add-row-button:hover), + &:has(.add-column-button:hover) { + .add-row-button, + .add-column-button { + display: flex; + } + } + + /* Do not show in readonly even on hover */ + &.is-readonly, + &.is-readonly:hover { + .add-row-button, + .add-column-button { + display: none !important; + } + } + + .add-row-button:hover, + .add-column-button:hover { + display: flex !important; + } + + /* Row/Column action triggers (visible on cell selection) */ + .row-action-trigger, + .column-action-trigger { + position: absolute; + height: 20px; + border-radius: 8px; + background: var(--color-primary); + color: #fff; + border: 1px solid var(--color-primary); + display: none; + align-items: center; + justify-content: center; + font-size: 12px; + line-height: 1; + z-index: 30; + pointer-events: auto; + } + + .row-action-trigger::before, + .column-action-trigger::before { + content: '•••'; + } + } + + &.resize-cursor { + cursor: ew-resize; + cursor: col-resize; + } + + ul, + ol { + padding: 0 1rem; + margin: 1.25rem 1rem 1.25rem 0.4rem; + + li p { + margin-top: 0.25em; + margin-bottom: 0.25em; + } + + // Reduce spacing for nested lists + ul, + ol { + margin: 0.5rem 0.5rem 0.5rem 0.2rem; + } + } + + ul { + list-style: disc; + } + + ol { + list-style: decimal; + } + + ul[data-type='taskList'] { + list-style: none; + margin-left: 0; + padding: 0; + + li { + align-items: center; + display: flex; + + > label { + flex: 0 0 auto; + margin-right: 0.5rem; + user-select: none; + display: flex; + align-items: center; + } + + > div { + flex: 1 1 auto; + + p { + margin: 0; + } + } + } + + /* Checked task item appearance */ + li[data-checked='true'] { + > div { + color: var(--color-text-2); + text-decoration: line-through; + } + } + + input[type='checkbox'] { + cursor: pointer; + } + + /* Use primary color for checked checkbox */ + input[type='checkbox']:checked { + accent-color: var(--color-primary); + background-color: var(--color-primary); + border-color: var(--color-primary); + } + + ul[data-type='taskList'] { + margin: 0; + } + } + + /* Math block */ + .block-math-inner { + display: flex; + justify-content: center; + align-items: center; + font-size: 1.2rem; + } +} + +// Code block wrapper and header styles +.code-block-wrapper { + position: relative; + + .code-block-header { + display: flex; + align-items: center; + gap: 6px; + position: absolute; + top: 4px; + right: 6px; + opacity: 0; + transition: opacity 0.2s; + } + + &:hover .code-block-header { + opacity: 1; + } +} diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx index 4bb70e1b75..174327ae68 100644 --- a/src/renderer/src/components/CodeBlockView/view.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -345,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); diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx index 637af96de4..084a79a439 100644 --- a/src/renderer/src/components/ContentSearch.tsx +++ b/src/renderer/src/components/ContentSearch.tsx @@ -18,6 +18,15 @@ interface Props { filter: NodeFilter includeUser?: boolean onIncludeUserChange?: (value: boolean) => void + /** + * 是否显示“包含用户问题”切换按钮(默认为 true)。 + * 在富文本编辑器场景通常不需要该按钮。 + */ + showUserToggle?: boolean + /** + * 搜索条定位方式 + */ + positionMode?: 'fixed' | 'absolute' | 'sticky' } enum SearchCompletedState { @@ -125,7 +134,10 @@ const findRangesInTarget = ( // eslint-disable-next-line @eslint-react/no-forward-ref export const ContentSearch = React.forwardRef( - ({ searchTarget, filter, includeUser = false, onIncludeUserChange }, ref) => { + ( + { searchTarget, filter, includeUser = false, onIncludeUserChange, showUserToggle = true, positionMode = 'fixed' }, + ref + ) => { const target: HTMLElement | null = (() => { if (searchTarget instanceof HTMLElement) { return searchTarget @@ -335,9 +347,12 @@ export const ContentSearch = React.forwardRef( } return ( - + - + ( style={{ lineHeight: '20px' }} /> - - - - - + {showUserToggle && ( + + + + + + )} ( ContentSearch.displayName = 'ContentSearch' -const Container = styled.div` +const Container = styled.div<{ $overlayPosition: 'static' | 'absolute' }>` display: flex; flex-direction: row; - z-index: 2; + position: ${({ $overlayPosition }) => $overlayPosition}; + top: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')}; + left: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')}; + right: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')}; + z-index: 999; ` -const SearchBarContainer = styled.div` +const SearchBarContainer = styled.div<{ $position: 'fixed' | 'absolute' | 'sticky' }>` border: 1px solid var(--color-primary); border-radius: 10px; transition: all 0.2s ease; - position: fixed; + position: ${({ $position }) => $position}; top: 15px; left: 20px; right: 20px; diff --git a/src/renderer/src/components/Popups/RichEditPopup.tsx b/src/renderer/src/components/Popups/RichEditPopup.tsx new file mode 100644 index 0000000000..1c7b32e188 --- /dev/null +++ b/src/renderer/src/components/Popups/RichEditPopup.tsx @@ -0,0 +1,159 @@ +import RichEditor from '@renderer/components/RichEditor' +import { RichEditorRef } from '@renderer/components/RichEditor/types' +import { Modal, ModalProps } from 'antd' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { TopView } from '../TopView' + +interface ShowParams { + content: string + modalProps?: ModalProps + showTranslate?: boolean + disableCommands?: string[] // 要禁用的命令列表 + children?: (props: { onOk?: () => void; onCancel?: () => void }) => React.ReactNode +} + +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ + content, + modalProps, + resolve, + children, + disableCommands = ['image', 'inlineMath'] // 默认禁用 image 命令 +}) => { + const [open, setOpen] = useState(true) + const { t } = useTranslation() + const [richContent, setRichContent] = useState(content) + const editorRef = useRef(null) + const isMounted = useRef(true) + + useEffect(() => { + return () => { + isMounted.current = false + } + }, []) + + const onOk = () => { + const finalContent = editorRef.current?.getMarkdown() || richContent + resolve(finalContent) + setOpen(false) + } + + const onCancel = () => { + resolve(null) + setOpen(false) + } + + const onClose = () => { + resolve(null) + } + + const handleAfterOpenChange = (visible: boolean) => { + if (visible && editorRef.current) { + // Focus the editor after modal opens + setTimeout(() => { + editorRef.current?.focus() + }, 100) + } + } + + const handleContentChange = (newContent: string) => { + setRichContent(newContent) + } + + const handleMarkdownChange = (newMarkdown: string) => { + // 更新Markdown内容状态 + setRichContent(newMarkdown) + } + + // 处理命令配置 + const handleCommandsReady = (commandAPI: Pick) => { + // 禁用指定的命令 + if (disableCommands?.length) { + disableCommands.forEach((commandId) => { + commandAPI.unregisterCommand(commandId) + }) + } + } + + RichEditPopup.hide = onCancel + + return ( + + + + + {children && children({ onOk, onCancel })} + + ) +} + +const TopViewKey = 'RichEditPopup' + +const ChildrenContainer = styled.div` + position: relative; +` + +const EditorContainer = styled.div` + position: relative; + + .rich-edit-popup-editor { + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-background); + + &:focus-within { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-alpha); + } + } +` + +export default class RichEditPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx index cfc190399d..f8789735e9 100644 --- a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx +++ b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx @@ -4,6 +4,7 @@ import { TopView } from '@renderer/components/TopView' import { useKnowledge, useKnowledgeBases } from '@renderer/hooks/useKnowledge' import { Topic } from '@renderer/types' import { Message } from '@renderer/types/newMessage' +import { NotesTreeNode } from '@renderer/types/note' import { analyzeMessageContent, analyzeTopicContent, @@ -77,7 +78,10 @@ interface ContentTypeOption { description: string } -type ContentSource = { type: 'message'; data: Message } | { type: 'topic'; data: Topic } +type ContentSource = + | { type: 'message'; data: Message } + | { type: 'topic'; data: Topic } + | { type: 'note'; data: NotesTreeNode } interface ShowParams { source: ContentSource @@ -106,10 +110,16 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { const { t } = useTranslation() const isTopicMode = source?.type === 'topic' + const isNoteMode = source?.type === 'note' // 异步分析内容统计 useEffect(() => { const analyze = async () => { + if (isNoteMode) { + setAnalysisLoading(false) + return + } + setAnalysisLoading(true) setContentStats(null) try { @@ -136,11 +146,11 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { } } analyze() - }, [source, isTopicMode]) + }, [source, isTopicMode, isNoteMode]) // 生成内容类型选项 const contentTypeOptions: ContentTypeOption[] = useMemo(() => { - if (!contentStats) return [] + if (!contentStats || isNoteMode) return [] return Object.entries(CONTENT_TYPE_CONFIG) .map(([type, config]) => { @@ -159,7 +169,7 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { } }) .filter((option) => option.enabled) - }, [contentStats, t, isTopicMode]) + }, [contentStats, t, isTopicMode, isNoteMode]) // 知识库选项 const knowledgeBaseOptions = useMemo( @@ -175,19 +185,24 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { // 表单状态 const formState = useMemo(() => { const hasValidBase = selectedBaseId && bases.find((base) => base.id === selectedBaseId)?.version - const hasContent = contentTypeOptions.length > 0 - const selectedCount = contentTypeOptions - .filter((option) => selectedTypes.includes(option.type)) - .reduce((sum, option) => sum + option.count, 0) + const hasContent = isNoteMode || contentTypeOptions.length > 0 + + const canSubmit = hasValidBase && (isNoteMode || (selectedTypes.length > 0 && hasContent)) + + const selectedCount = isNoteMode + ? 1 + : contentTypeOptions + .filter((option) => selectedTypes.includes(option.type)) + .reduce((sum, option) => sum + option.count, 0) return { hasValidBase, hasContent, - canSubmit: hasValidBase && selectedTypes.length > 0 && hasContent, + canSubmit, selectedCount, - hasNoSelection: selectedTypes.length === 0 && hasContent + hasNoSelection: !isNoteMode && selectedTypes.length === 0 && hasContent } - }, [selectedBaseId, bases, contentTypeOptions, selectedTypes]) + }, [selectedBaseId, bases, contentTypeOptions, selectedTypes, isNoteMode]) // 默认选择第一个可用知识库 useEffect(() => { @@ -201,28 +216,31 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { // 默认选择所有可用内容类型 useEffect(() => { - if (!hasInitialized && contentTypeOptions.length > 0) { + if (!hasInitialized && contentTypeOptions.length > 0 && !isNoteMode) { setSelectedTypes(contentTypeOptions.map((option) => option.type)) setHasInitialized(true) } - }, [contentTypeOptions, hasInitialized]) + }, [contentTypeOptions, hasInitialized, isNoteMode]) // UI状态 const uiState = useMemo(() => { if (analysisLoading) { return { type: 'loading', message: t('chat.save.topic.knowledge.loading') } } - if (!formState.hasContent) { + + if (!formState.hasContent && !isNoteMode) { return { type: 'empty', message: t(isTopicMode ? 'chat.save.topic.knowledge.empty.no_content' : 'chat.save.knowledge.empty.no_content') } } + if (bases.length === 0) { return { type: 'empty', message: t('chat.save.knowledge.empty.no_knowledge_base') } } + return { type: 'form' } - }, [analysisLoading, formState.hasContent, bases.length, t, isTopicMode]) + }, [analysisLoading, formState.hasContent, bases.length, t, isTopicMode, isNoteMode]) const handleContentTypeToggle = (type: ContentType) => { setSelectedTypes((prev) => (prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type])) @@ -235,18 +253,28 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { let savedCount = 0 try { - const result = isTopicMode - ? await processTopicContent(source?.data as Topic, selectedTypes) - : processMessageContent(source?.data as Message, selectedTypes) + if (isNoteMode) { + const note = source.data as NotesTreeNode + const content = await window.api.file.read(note.id + '.md') + logger.debug('Note content:', content) + await addNote(content) + savedCount = 1 + } else { + // 原有的消息或主题处理逻辑 + const result = isTopicMode + ? await processTopicContent(source?.data as Topic, selectedTypes) + : processMessageContent(source?.data as Message, selectedTypes) - if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) { - await addNote(result.text) - savedCount++ - } + logger.debug('Processed content:', result) + if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) { + await addNote(result.text) + savedCount++ + } - if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) { - addFiles(result.files) - savedCount += result.files.length + if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) { + addFiles(result.files) + savedCount += result.files.length + } } setOpen(false) @@ -285,66 +313,81 @@ const PopupContainer: React.FC = ({ source, title, resolve }) => { /> - - - {contentTypeOptions.map((option) => ( - handleContentTypeToggle(option.type)}> - - - {option.count} - - {option.label} - - - - - {selectedTypes.includes(option.type) && } - - ))} - - + {!isNoteMode && ( + + + {contentTypeOptions.map((option) => ( + handleContentTypeToggle(option.type)}> + + + {option.count} + + {option.label} + + + + + {selectedTypes.includes(option.type) && } + + ))} + + + )} - - {formState.selectedCount > 0 && ( - - {t( - isTopicMode - ? 'chat.save.topic.knowledge.select.content.selected_tip' - : 'chat.save.knowledge.select.content.tip', - { - count: formState.selectedCount, - ...(isTopicMode && { messages: (contentStats as TopicContentStats)?.messages || 0 }) - } - )} - - )} - {formState.hasNoSelection && ( - - {t('chat.save.knowledge.error.no_content_selected')} - - )} - {!formState.hasNoSelection && formState.selectedCount === 0 && ( - -   - - )} - + {!isNoteMode && ( + + {formState.selectedCount > 0 && ( + + {t( + isTopicMode + ? 'chat.save.topic.knowledge.select.content.selected_tip' + : 'chat.save.knowledge.select.content.tip', + { + count: formState.selectedCount, + ...(isTopicMode && { messages: (contentStats as TopicContentStats)?.messages || 0 }) + } + )} + + )} + {formState.hasNoSelection && ( + + {t('chat.save.knowledge.error.no_content_selected')} + + )} + {!formState.hasNoSelection && formState.selectedCount === 0 && ( + +   + + )} + + )} ) return ( { return this.show({ source: { type: 'topic', data: topic }, title }) } + + static showForNote(note: NotesTreeNode, title?: string): Promise { + return this.show({ source: { type: 'note', data: note }, title }) + } } const EmptyContainer = styled.div` diff --git a/src/renderer/src/components/RichEditor/CommandListPopover.tsx b/src/renderer/src/components/RichEditor/CommandListPopover.tsx new file mode 100644 index 0000000000..f75e651866 --- /dev/null +++ b/src/renderer/src/components/RichEditor/CommandListPopover.tsx @@ -0,0 +1,227 @@ +import '@renderer/assets/styles/CommandListPopover.scss' + +import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' +import { useTheme } from '@renderer/context/ThemeProvider' +import type { SuggestionProps } from '@tiptap/suggestion' +import { Typography } from 'antd' +import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import type { Command } from './command' + +const { Text } = Typography + +export interface CommandListPopoverProps extends SuggestionProps { + ref?: React.RefObject +} + +export interface CommandListPopoverRef extends SuggestionProps { + updateSelectedIndex: (index: number) => void + selectCurrent: () => void + onKeyDown: (event: KeyboardEvent) => boolean +} + +const CommandListPopover = ({ + ref, + ...props +}: SuggestionProps & { ref?: React.RefObject }) => { + const { items, command } = props + const [internalSelectedIndex, setInternalSelectedIndex] = useState(0) + const listRef = useRef(null) + const virtualListRef = useRef(null) + const shouldAutoScrollRef = useRef(true) + const { t } = useTranslation() + + // Helper function to get translated text with fallback + const getTranslatedCommand = useCallback( + (item: Command, field: 'title' | 'description') => { + const key = `richEditor.commands.${item.id}.${field}` + const translated = t(key) + return translated === key ? item[field] : translated + }, + [t] + ) + + // Reset selected index when items change + useEffect(() => { + shouldAutoScrollRef.current = true + setInternalSelectedIndex(0) + }, [items]) + + // Auto scroll to selected item using virtual list + useEffect(() => { + if (virtualListRef.current && items.length > 0 && shouldAutoScrollRef.current) { + virtualListRef.current.scrollToIndex(internalSelectedIndex, { + align: 'auto' + }) + } + }, [internalSelectedIndex, items.length]) + + const selectItem = useCallback( + (index: number) => { + const item = props.items[index] + + if (item) { + command({ id: item.id, label: item.title }) + } + }, + [props.items, command] + ) + + // Handle keyboard navigation + const handleKeyDown = useCallback( + (event: KeyboardEvent): boolean => { + if (!items.length) return false + + switch (event.key) { + case 'ArrowUp': + event.preventDefault() + shouldAutoScrollRef.current = true + setInternalSelectedIndex((prev) => (prev === 0 ? items.length - 1 : prev - 1)) + return true + + case 'ArrowDown': + event.preventDefault() + shouldAutoScrollRef.current = true + setInternalSelectedIndex((prev) => (prev === items.length - 1 ? 0 : prev + 1)) + return true + + case 'Enter': + event.preventDefault() + if (items[internalSelectedIndex]) { + selectItem(internalSelectedIndex) + } + return true + + case 'Escape': + event.preventDefault() + return true + + default: + return false + } + }, + [items, internalSelectedIndex, selectItem] + ) + + // Expose methods via ref + useImperativeHandle( + ref, + () => ({ + ...props, + updateSelectedIndex: (index: number) => { + shouldAutoScrollRef.current = true + setInternalSelectedIndex(index) + }, + selectCurrent: () => selectItem(internalSelectedIndex), + onKeyDown: handleKeyDown + }), + [handleKeyDown, props, internalSelectedIndex, selectItem] + ) + + // Get theme from context + const { theme } = useTheme() + + // Get background and selected colors that work with both light and dark themes + const colors = useMemo(() => { + const isDark = theme === 'dark' + return { + background: isDark ? 'var(--color-background-soft, #222222)' : 'white', + border: isDark ? 'var(--color-border, #ffffff19)' : '#e1e5e9', + selectedBackground: isDark ? 'var(--color-hover, rgba(40, 40, 40, 1))' : '#f0f0f0', + boxShadow: isDark ? '0 4px 12px rgba(0, 0, 0, 0.3)' : '0 4px 12px rgba(0, 0, 0, 0.1)' + } + }, [theme]) + + // Handle mouse enter for hover effect + const handleItemMouseEnter = useCallback((index: number) => { + shouldAutoScrollRef.current = false + setInternalSelectedIndex(index) + }, []) + + // Estimate size for virtual list items + const estimateSize = useCallback(() => 50, []) // Estimated height per item + + // Render virtual list item + const renderVirtualItem = useCallback( + (item: Command, index: number) => { + return ( +
selectItem(index)} + onMouseEnter={() => handleItemMouseEnter(index)}> +
+
+ +
+
+ + {getTranslatedCommand(item, 'title')} + + + {getTranslatedCommand(item, 'description')} + +
+
+
+ ) + }, + [internalSelectedIndex, colors.selectedBackground, selectItem, handleItemMouseEnter, getTranslatedCommand] + ) + + const style: React.CSSProperties = { + background: colors.background, + border: `1px solid ${colors.border}`, + borderRadius: '6px', + boxShadow: colors.boxShadow, + maxHeight: '280px', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column' + } + + return ( +
+ {items.length === 0 ? ( +
+ {t('richEditor.commands.noCommandsFound')} +
+ ) : ( + + )} +
+ ) +} + +CommandListPopover.displayName = 'CommandListPopover' + +export default CommandListPopover diff --git a/src/renderer/src/components/RichEditor/TableOfContent.tsx b/src/renderer/src/components/RichEditor/TableOfContent.tsx new file mode 100644 index 0000000000..7afc64ab3b --- /dev/null +++ b/src/renderer/src/components/RichEditor/TableOfContent.tsx @@ -0,0 +1,158 @@ +import type { Editor } from '@tiptap/core' +import type { TableOfContentDataItem } from '@tiptap/extension-table-of-contents' +import { TextSelection } from '@tiptap/pm/state' +import React, { useEffect, useState } from 'react' + +import { TableOfContentsWrapper, ToCDock } from './styles' + +interface ToCItemProps { + item: TableOfContentDataItem + onItemClick: (e: React.MouseEvent, id: string) => void +} + +export const ToCItem: React.FC = ({ item, onItemClick }) => { + // Fix: Always show active state when selected by algorithm, regardless of scroll position + const isActive = item.isActive + const isScrolledOver = item.isScrolledOver + const className = `toc-item ${isActive ? 'is-active' : ''} ${isScrolledOver ? 'is-scrolled-over' : ''}` + + return ( + + ) +} + +interface ToCProps { + items?: TableOfContentDataItem[] + editor?: Editor | null + scrollContainerRef?: React.RefObject +} + +export const ToC: React.FC = ({ items = [], editor, scrollContainerRef }) => { + // Filter to only show first 3 levels (H1-H3) to avoid overcrowding + const filteredItems = items.filter((item) => item.level <= 3) + const [maxDisplayItems, setMaxDisplayItems] = useState(30) + + // Dynamic calculation based on container height + useEffect(() => { + const calculateMaxItems = () => { + if (!scrollContainerRef?.current) return + + const containerHeight = scrollContainerRef.current.clientHeight + // Each button: 4px height + 4px gap = 8px total + // Reserve 40px for padding + const availableHeight = containerHeight - 40 + const itemHeight = 8 // 4px button + 4px gap + const calculatedMax = Math.floor(availableHeight / itemHeight) + + setMaxDisplayItems(Math.max(10, Math.min(calculatedMax, 50))) // Min 10, max 50 + } + + calculateMaxItems() + + // Recalculate on resize + const resizeObserver = new ResizeObserver(calculateMaxItems) + if (scrollContainerRef?.current) { + resizeObserver.observe(scrollContainerRef.current) + } + + return () => resizeObserver.disconnect() + }, [scrollContainerRef, filteredItems.length]) + + // Smart sampling: if too many items, sample evenly to maintain scroll highlighting + const displayItems = + filteredItems.length <= maxDisplayItems + ? filteredItems + : (() => { + const step = filteredItems.length / maxDisplayItems + const sampled: TableOfContentDataItem[] = [] + for (let i = 0; i < maxDisplayItems; i++) { + const index = Math.floor(i * step) + sampled.push(filteredItems[index]) + } + return sampled + })() + + if (displayItems.length === 0) { + return null + } + + const onItemClick = (e: React.MouseEvent, id: string) => { + e.preventDefault() + + if (editor && scrollContainerRef?.current) { + const element = editor.view.dom.querySelector(`[data-toc-id="${id}"]`) as HTMLElement + if (element) { + const container = scrollContainerRef.current + const pos = editor.view.posAtDOM(element, 0) + + const tr = editor.view.state.tr + + tr.setSelection(new TextSelection(tr.doc.resolve(pos))) + + editor.view.dispatch(tr) + + editor.view.focus() + + if (history.pushState) { + history.pushState(null, '', `#${id}`) + } + + // Calculate correct scroll position to put element at top of viewport + const elementTop = element.getBoundingClientRect().top + const containerTop = container.getBoundingClientRect().top + const targetScrollTop = container.scrollTop + (elementTop - containerTop) + + // Smooth scroll to target position + container.scrollTo({ + top: targetScrollTop, + behavior: 'smooth' + }) + + // Force TableOfContents extension to recalculate highlighting after scroll + setTimeout(() => { + const scrollEvent = new Event('scroll', { bubbles: true }) + container.dispatchEvent(scrollEvent) + }, 300) // Wait for smooth scroll to complete + } + } + } + + return ( + +
+ {displayItems.map((item) => ( +
+ + {/* floating panel */} +
+ +
+ {filteredItems.map((item) => ( + + ))} +
+
+
+
+ ) +} + +export default React.memo(ToC) diff --git a/src/renderer/src/components/RichEditor/command.ts b/src/renderer/src/components/RichEditor/command.ts new file mode 100644 index 0000000000..a460e210d4 --- /dev/null +++ b/src/renderer/src/components/RichEditor/command.ts @@ -0,0 +1,648 @@ +import { autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom' +import { loggerService } from '@logger' +import type { Editor } from '@tiptap/core' +import type { MentionNodeAttrs } from '@tiptap/extension-mention' +import { posToDOMRect, ReactRenderer } from '@tiptap/react' +import type { SuggestionOptions } from '@tiptap/suggestion' +import type { LucideIcon } from 'lucide-react' +import { + Bold, + Calculator, + CheckCircle, + Code, + FileCode, + Heading1, + Heading2, + Heading3, + Image, + Italic, + Link, + List, + ListOrdered, + Minus, + Omega, + Quote, + Redo, + Strikethrough, + Table, + Type, + Underline, + Undo, + X +} from 'lucide-react' + +import CommandListPopover from './CommandListPopover' + +const logger = loggerService.withContext('RichEditor.Command') + +export interface Command { + id: string + title: string + description: string + category: CommandCategory + icon: LucideIcon + keywords: string[] + handler: (editor: Editor) => void + isAvailable?: (editor: Editor) => boolean + // Toolbar support + showInToolbar?: boolean + toolbarGroup?: 'text' | 'formatting' | 'blocks' | 'media' | 'structure' | 'history' + formattingCommand?: string // Maps to FormattingCommand for state checking +} + +export enum CommandCategory { + TEXT = 'text', + LISTS = 'lists', + BLOCKS = 'blocks', + MEDIA = 'media', + STRUCTURE = 'structure', + SPECIAL = 'special' +} + +export interface CommandSuggestion { + query: string + range: any + clientRect?: () => DOMRect | null +} + +// Internal dynamic command registry +const commandRegistry = new Map() + +export function registerCommand(cmd: Command): void { + commandRegistry.set(cmd.id, cmd) +} + +export function unregisterCommand(id: string): void { + commandRegistry.delete(id) +} + +export function getCommand(id: string): Command | undefined { + return commandRegistry.get(id) +} + +export function getAllCommands(): Command[] { + return Array.from(commandRegistry.values()) +} + +export function getToolbarCommands(): Command[] { + return getAllCommands().filter((cmd) => cmd.showInToolbar) +} + +export function getCommandsByGroup(group: string): Command[] { + return getAllCommands().filter((cmd) => cmd.toolbarGroup === group) +} + +// Dynamic toolbar management +export function registerToolbarCommand(cmd: Command): void { + if (!cmd.showInToolbar) { + cmd.showInToolbar = true + } + registerCommand(cmd) +} + +export function unregisterToolbarCommand(id: string): void { + const cmd = getCommand(id) + if (cmd) { + cmd.showInToolbar = false + // Keep command for slash menu, just hide from toolbar + } +} + +export function setCommandAvailability(id: string, isAvailable: (editor: Editor) => boolean): void { + const cmd = getCommand(id) + if (cmd) { + cmd.isAvailable = isAvailable + } +} + +// Convenience functions for common scenarios +export function disableCommandsWhen(commandIds: string[], condition: (editor: Editor) => boolean): void { + commandIds.forEach((id) => { + setCommandAvailability(id, (editor) => !condition(editor)) + }) +} + +export function hideToolbarCommandsWhen(commandIds: string[], condition: () => boolean): void { + if (condition()) { + commandIds.forEach((id) => unregisterToolbarCommand(id)) + } else { + commandIds.forEach((id) => { + const cmd = getCommand(id) + if (cmd) { + cmd.showInToolbar = true + } + }) + } +} + +// Default command definitions +const DEFAULT_COMMANDS: Command[] = [ + { + id: 'bold', + title: 'Bold', + description: 'Make text bold', + category: CommandCategory.TEXT, + icon: Bold, + keywords: ['bold', 'strong', 'b'], + handler: (editor: Editor) => { + editor.chain().focus().toggleBold().run() + }, + showInToolbar: true, + toolbarGroup: 'formatting', + formattingCommand: 'bold' + }, + { + id: 'italic', + title: 'Italic', + description: 'Make text italic', + category: CommandCategory.TEXT, + icon: Italic, + keywords: ['italic', 'emphasis', 'i'], + handler: (editor: Editor) => { + editor.chain().focus().toggleItalic().run() + }, + showInToolbar: true, + toolbarGroup: 'formatting', + formattingCommand: 'italic' + }, + { + id: 'underline', + title: 'Underline', + description: 'Underline text', + category: CommandCategory.TEXT, + icon: Underline, + keywords: ['underline', 'u'], + handler: (editor: Editor) => { + editor.chain().focus().toggleUnderline().run() + }, + showInToolbar: true, + toolbarGroup: 'formatting', + formattingCommand: 'underline' + }, + { + id: 'strike', + title: 'Strikethrough', + description: 'Strike through text', + category: CommandCategory.TEXT, + icon: Strikethrough, + keywords: ['strikethrough', 'strike', 's'], + handler: (editor: Editor) => { + editor.chain().focus().toggleStrike().run() + }, + showInToolbar: true, + toolbarGroup: 'formatting', + formattingCommand: 'strike' + }, + { + id: 'inlineCode', + title: 'Inline Code', + description: 'Add inline code', + category: CommandCategory.SPECIAL, + icon: Code, + keywords: ['code', 'inline', 'monospace'], + handler: (editor: Editor) => { + editor.chain().focus().toggleCode().run() + }, + showInToolbar: true, + toolbarGroup: 'formatting', + formattingCommand: 'code' + }, + { + id: 'paragraph', + title: 'Text', + description: 'Start writing with plain text', + category: CommandCategory.TEXT, + icon: Type, + keywords: ['text', 'paragraph', 'p'], + handler: (editor: Editor) => { + editor.chain().focus().setParagraph().run() + }, + showInToolbar: true, + toolbarGroup: 'text', + formattingCommand: 'paragraph' + }, + { + id: 'heading1', + title: 'Heading 1', + description: 'Big section heading', + category: CommandCategory.TEXT, + icon: Heading1, + keywords: ['heading', 'h1', 'title', 'big'], + handler: (editor: Editor) => { + editor.chain().focus().toggleHeading({ level: 1 }).run() + }, + showInToolbar: true, + toolbarGroup: 'text', + formattingCommand: 'heading1' + }, + { + id: 'heading2', + title: 'Heading 2', + description: 'Medium section heading', + category: CommandCategory.TEXT, + icon: Heading2, + keywords: ['heading', 'h2', 'subtitle', 'medium'], + handler: (editor: Editor) => { + editor.chain().focus().toggleHeading({ level: 2 }).run() + }, + showInToolbar: true, + toolbarGroup: 'text', + formattingCommand: 'heading2' + }, + { + id: 'heading3', + title: 'Heading 3', + description: 'Small section heading', + category: CommandCategory.TEXT, + icon: Heading3, + keywords: ['heading', 'h3', 'small'], + handler: (editor: Editor) => { + editor.chain().focus().toggleHeading({ level: 3 }).run() + }, + showInToolbar: true, + toolbarGroup: 'text', + formattingCommand: 'heading3' + }, + { + id: 'bulletList', + title: 'Bulleted list', + description: 'Create a simple bulleted list', + category: CommandCategory.LISTS, + icon: List, + keywords: ['bullet', 'list', 'ul', 'unordered'], + handler: (editor: Editor) => { + editor.chain().focus().toggleBulletList().run() + }, + showInToolbar: true, + toolbarGroup: 'blocks', + formattingCommand: 'bulletList' + }, + { + id: 'orderedList', + title: 'Numbered list', + description: 'Create a list with numbering', + category: CommandCategory.LISTS, + icon: ListOrdered, + keywords: ['number', 'list', 'ol', 'ordered'], + handler: (editor: Editor) => { + editor.chain().focus().toggleOrderedList().run() + }, + showInToolbar: true, + toolbarGroup: 'blocks', + formattingCommand: 'orderedList' + }, + { + id: 'codeBlock', + title: 'Code', + description: 'Capture a code snippet', + category: CommandCategory.BLOCKS, + icon: FileCode, + keywords: ['code', 'block', 'snippet', 'programming'], + handler: (editor: Editor) => { + editor.chain().focus().toggleCodeBlock().run() + }, + showInToolbar: true, + toolbarGroup: 'blocks', + formattingCommand: 'codeBlock' + }, + { + id: 'blockquote', + title: 'Quote', + description: 'Capture a quote', + category: CommandCategory.BLOCKS, + icon: Quote, + keywords: ['quote', 'blockquote', 'citation'], + handler: (editor: Editor) => { + editor.chain().focus().toggleBlockquote().run() + }, + showInToolbar: true, + toolbarGroup: 'blocks', + formattingCommand: 'blockquote' + }, + { + id: 'divider', + title: 'Divider', + description: 'Add a horizontal line', + category: CommandCategory.STRUCTURE, + icon: Minus, + keywords: ['divider', 'hr', 'line', 'separator'], + handler: (editor: Editor) => { + editor.chain().focus().setHorizontalRule().run() + } + }, + { + id: 'image', + title: 'Image', + description: 'Insert an image', + category: CommandCategory.MEDIA, + icon: Image, + keywords: ['image', 'img', 'picture', 'photo'], + handler: (editor: Editor) => { + editor.chain().focus().insertImagePlaceholder().run() + }, + showInToolbar: true, + toolbarGroup: 'media', + formattingCommand: 'image' + }, + { + id: 'link', + title: 'Link', + description: 'Add a link', + category: CommandCategory.SPECIAL, + icon: Link, + keywords: ['link', 'url', 'href'], + handler: (editor: Editor) => { + editor.chain().focus().setEnhancedLink({ href: '' }).run() + }, + showInToolbar: true, + toolbarGroup: 'media', + formattingCommand: 'link' + }, + { + id: 'table', + title: 'Table', + description: 'Insert a table', + category: CommandCategory.STRUCTURE, + icon: Table, + keywords: ['table', 'grid', 'rows', 'columns'], + handler: (editor: Editor) => { + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() + }, + showInToolbar: true, + toolbarGroup: 'structure', + formattingCommand: 'table' + }, + // Additional commands for slash menu only + { + id: 'taskList', + title: 'Task List', + description: 'Create a checklist', + category: CommandCategory.LISTS, + icon: CheckCircle, + keywords: ['task', 'todo', 'checklist', 'checkbox'], + handler: (editor: Editor) => { + editor.chain().focus().toggleTaskList().run() + }, + showInToolbar: true, + toolbarGroup: 'blocks', + formattingCommand: 'taskList' + }, + { + id: 'hardBreak', + title: 'Line Break', + description: 'Insert a line break', + category: CommandCategory.STRUCTURE, + icon: X, + keywords: ['break', 'br', 'newline'], + handler: (editor: Editor) => { + editor.chain().focus().setHardBreak().run() + } + }, + { + id: 'inlineMath', + title: 'Inline Equation', + description: 'Insert inline equation', + category: CommandCategory.BLOCKS, + icon: Omega, + keywords: ['inline', 'math', 'formula', 'equation', 'latex'], + handler: (editor: Editor) => { + editor.chain().focus().insertMathPlaceholder({ mathType: 'inline' }).run() + }, + showInToolbar: true, + toolbarGroup: 'blocks', + formattingCommand: 'inlineMath' + }, + { + id: 'blockMath', + title: 'Math Formula', + description: 'Insert mathematical formula', + category: CommandCategory.BLOCKS, + icon: Calculator, + keywords: ['math', 'formula', 'equation', 'latex'], + handler: (editor: Editor) => { + editor.chain().focus().insertMathPlaceholder({ mathType: 'block' }).run() + }, + showInToolbar: true, + toolbarGroup: 'blocks', + formattingCommand: 'blockMath' + }, + // History commands + { + id: 'undo', + title: 'Undo', + description: 'Undo last action', + category: CommandCategory.SPECIAL, + icon: Undo, + keywords: ['undo', 'revert'], + handler: (editor: Editor) => { + editor.chain().focus().undo().run() + }, + showInToolbar: true, + toolbarGroup: 'history', + formattingCommand: 'undo' + }, + { + id: 'redo', + title: 'Redo', + description: 'Redo last action', + category: CommandCategory.SPECIAL, + icon: Redo, + keywords: ['redo', 'repeat'], + handler: (editor: Editor) => { + editor.chain().focus().redo().run() + }, + showInToolbar: true, + toolbarGroup: 'history', + formattingCommand: 'redo' + } +] + +export interface CommandFilterOptions { + query?: string + category?: CommandCategory + maxResults?: number +} + +// Filter commands based on search query and category +export function filterCommands(options: CommandFilterOptions = {}): Command[] { + const { query = '', category } = options + + let filtered = getAllCommands() + + // Filter by category if specified + if (category) { + filtered = filtered.filter((cmd) => cmd.category === category) + } + + // Filter by search query + if (query) { + const searchTerm = query.toLowerCase().trim() + filtered = filtered.filter((cmd) => { + const searchableText = [cmd.title, cmd.description, ...cmd.keywords].join(' ').toLowerCase() + + return searchableText.includes(searchTerm) + }) + + // Sort by relevance (exact matches first, then title matches, then keyword matches) + filtered.sort((a, b) => { + const aTitle = a.title.toLowerCase() + const bTitle = b.title.toLowerCase() + const aExactMatch = aTitle === searchTerm + const bExactMatch = bTitle === searchTerm + const aTitleMatch = aTitle.includes(searchTerm) + const bTitleMatch = bTitle.includes(searchTerm) + + if (aExactMatch && !bExactMatch) return -1 + if (bExactMatch && !aExactMatch) return 1 + if (aTitleMatch && !bTitleMatch) return -1 + if (bTitleMatch && !aTitleMatch) return 1 + + return a.title.localeCompare(b.title) + }) + } + + return filtered +} + +const updatePosition = (editor: Editor, element: HTMLElement) => { + const virtualElement = { + getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to) + } + + computePosition(virtualElement, element, { + placement: 'bottom-start', + strategy: 'fixed', + middleware: [ + offset(4), // Add small offset from trigger + flip({ + fallbackPlacements: ['top-start', 'bottom-end', 'top-end', 'bottom-start'], + padding: 8 // Ensure some padding from viewport edges + }), + shift({ + padding: 8 // Prevent overflow on sides + }), + size({ + apply({ availableWidth, availableHeight, elements }) { + // Ensure the popover doesn't exceed viewport bounds + const maxHeight = Math.min(400, availableHeight - 16) // 16px total padding + const maxWidth = Math.min(320, availableWidth - 16) + + Object.assign(elements.floating.style, { + maxHeight: `${maxHeight}px`, + maxWidth: `${maxWidth}px`, + minWidth: '240px' + }) + } + }) + ] + }) + .then(({ x, y, strategy, placement }) => { + Object.assign(element.style, { + position: strategy, + left: `${x}px`, + top: `${y}px`, + width: 'max-content' + }) + + // Add data attribute to track current placement for styling + element.setAttribute('data-placement', placement) + }) + .catch((error) => { + logger.error('Error positioning command list:', error) + }) +} + +// Register default commands into the dynamic registry +DEFAULT_COMMANDS.forEach(registerCommand) + +// TipTap suggestion configuration +export const commandSuggestion: Omit, 'editor'> = { + char: '/', + startOfLine: true, + items: ({ query }: { query: string }) => { + try { + return filterCommands({ query }) + } catch (error) { + logger.error('Error filtering commands:', error as Error) + return [] + } + }, + command: ({ editor, range, props }) => { + editor.chain().focus().deleteRange(range).run() + + // Find the original command by id + if (props.id) { + const command = getCommand(props.id) + if (command) { + command.handler(editor) + } + } + }, + + render: () => { + let component: ReactRenderer + let cleanup: (() => void) | undefined + + return { + onStart: (props) => { + if (!props?.items || !props?.clientRect) { + logger.warn('Invalid props in command suggestion onStart') + return + } + + component = new ReactRenderer(CommandListPopover, { + props, + editor: props.editor + }) + const element = component.element as HTMLElement + // element.style.position = 'absolute' + element.style.zIndex = '1001' + + document.body.appendChild(element) + + // Set up auto-updating position that responds to scroll and resize + const virtualElement = { + getBoundingClientRect: () => + posToDOMRect(props.editor.view, props.editor.state.selection.from, props.editor.state.selection.to) + } + + cleanup = autoUpdate(virtualElement, element, () => { + updatePosition(props.editor, element) + }) + + // Initial position update + updatePosition(props.editor, element) + }, + + onUpdate: (props) => { + if (!props?.items || !props.clientRect) return + + component.updateProps(props) + + // Update position when items change (might affect size) + if (component.element) { + setTimeout(() => { + updatePosition(props.editor, component.element as HTMLElement) + }, 0) + } + }, + + onKeyDown: (props) => { + if (props.event.key === 'Escape') { + if (cleanup) cleanup() + component.destroy() + return true + } + + return component.ref?.onKeyDown(props.event) + }, + + onExit: () => { + if (cleanup) cleanup() + const element = component.element as HTMLElement + element.remove() + component.destroy() + } + } + } +} diff --git a/src/renderer/src/components/RichEditor/components/ActionMenu.tsx b/src/renderer/src/components/RichEditor/components/ActionMenu.tsx new file mode 100644 index 0000000000..5f35799da6 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/ActionMenu.tsx @@ -0,0 +1,84 @@ +import { Menu } from 'antd' +import React, { FC, useCallback, useEffect, useMemo, useRef } from 'react' +import { createPortal } from 'react-dom' + +export interface ActionMenuItem { + key: string + label: React.ReactNode + icon?: React.ReactNode + danger?: boolean + onClick: () => void +} + +export interface ActionMenuProps { + show: boolean + position: { x: number; y: number } + items: ActionMenuItem[] + onClose: () => void + minWidth?: number +} + +export const ActionMenu: FC = ({ show, position, items, onClose, minWidth = 168 }) => { + const ref = useRef(null) + + useEffect(() => { + if (!show) return + const onDocMouseDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + onClose() + } + } + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('mousedown', onDocMouseDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('mousedown', onDocMouseDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [show, onClose]) + + const menuItems = useMemo( + () => + items.map((it) => ({ + key: it.key, + label: it.label, + icon: it.icon, + danger: it.danger + })), + [items] + ) + + const onMenuClick = useCallback( + ({ key }: { key: string }) => { + const found = items.find((i) => i.key === key) + if (found) found.onClick() + onClose() + }, + [items, onClose] + ) + + if (!show) return null + + const node = ( +
+ +
+ ) + + return createPortal(node, document.body) +} diff --git a/src/renderer/src/components/RichEditor/components/ImageUploader.tsx b/src/renderer/src/components/RichEditor/components/ImageUploader.tsx new file mode 100644 index 0000000000..02a9b73885 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/ImageUploader.tsx @@ -0,0 +1,206 @@ +import { InboxOutlined, LinkOutlined, LoadingOutlined, UploadOutlined } from '@ant-design/icons' +import { Button, Flex, Input, message, Modal, Spin, Tabs, Upload } from 'antd' + +const { Dragger } = Upload +import type { RcFile } from 'antd/es/upload' +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface ImageUploaderProps { + /** Callback when image is selected/uploaded */ + onImageSelect: (imageUrl: string) => void + /** Whether the uploader is visible */ + visible: boolean + /** Callback when uploader should be closed */ + onClose: () => void +} + +const TabContent = styled.div` + padding: 24px 0; + display: flex; + flex-direction: column; +` + +const UrlInput = styled(Input)` + .ant-input { + padding: 12px 16px + font-size: 14px + border-radius: 4px + border: 1px solid #dadce0 + transition: all 0.2s ease + background: #ffffff + + &:hover { + border-color: #4285f4 + } + + &:focus { + border-color: #4285f4 + box-shadow: 0 0 0 1px rgba(66, 133, 244, 0.3) + } + } +` + +// Function to convert file to base64 URL +const convertFileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result) + } else { + reject(new Error('Failed to convert file to base64')) + } + } + reader.onerror = () => reject(new Error('Failed to read file')) + reader.readAsDataURL(file) + }) +} + +export const ImageUploader: React.FC = ({ onImageSelect, visible, onClose }) => { + const { t } = useTranslation() + const [urlInput, setUrlInput] = useState('') + const [loading, setLoading] = useState(false) + + const handleFileSelect = async (file: RcFile) => { + try { + setLoading(true) + + // Validate file type + const isImage = file.type.startsWith('image/') + if (!isImage) { + message.error(t('richEditor.imageUploader.invalidType')) + return false + } + + // Validate file size (max 10MB) + const isLt10M = file.size / 1024 / 1024 < 10 + if (!isLt10M) { + message.error(t('richEditor.imageUploader.tooLarge')) + return false + } + + // Convert to base64 and call callback + const base64Url = await convertFileToBase64(file) + onImageSelect(base64Url) + message.success(t('richEditor.imageUploader.uploadSuccess')) + onClose() + } catch (error) { + message.error(t('richEditor.imageUploader.uploadError')) + } finally { + setLoading(false) + } + + return false // Prevent default upload + } + + const handleUrlSubmit = () => { + if (!urlInput.trim()) { + message.error(t('richEditor.imageUploader.urlRequired')) + return + } + + // Basic URL validation + try { + new URL(urlInput.trim()) + onImageSelect(urlInput.trim()) + message.success(t('richEditor.imageUploader.embedSuccess')) + setUrlInput('') + onClose() + } catch { + message.error(t('richEditor.imageUploader.invalidUrl')) + } + } + + const handleCancel = () => { + setUrlInput('') + onClose() + } + + const tabItems = [ + { + key: 'upload', + label: ( +
+ + {t('richEditor.imageUploader.upload')} +
+ ), + children: ( + + {}} // Prevent default upload + disabled={loading}> + {loading ? ( + <> + } /> +

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

+

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

+ + ) : ( + <> +

+ +

+

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

+

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

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

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

') + .focus(insertPos + 1) + .run() + + logger.debug('Paragraph inserted before block successfully') + } catch (error) { + logger.error('Failed to insert paragraph before block', error as Error) + throw error + } + } + } +] diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/transform.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/transform.ts new file mode 100644 index 0000000000..9ec34640ca --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/transform.ts @@ -0,0 +1,146 @@ +import { loggerService } from '@logger' + +import type { ActionGroup, MenuAction } from '../types' + +const logger = loggerService.withContext('TransformActions') + +/** + * 节点转换操作集合 + */ +export const transformActions: MenuAction[] = [ + { + id: 'transform-heading-1', + label: 'Heading 1', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().setHeading({ level: 1 }) + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to H1', { nodeType: node.type.name, pos }) + editor.chain().focus().setHeading({ level: 1 }).run() + } catch (error) { + logger.error('Failed to transform to H1', error as Error) + } + } + }, + + { + id: 'transform-heading-2', + label: 'Heading 2', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().setHeading({ level: 2 }) + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to H2', { nodeType: node.type.name, pos }) + editor.chain().focus().setHeading({ level: 2 }).run() + } catch (error) { + logger.error('Failed to transform to H2', error as Error) + } + } + }, + + { + id: 'transform-heading-3', + label: 'Heading 3', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().setHeading({ level: 3 }) + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to H3', { nodeType: node.type.name, pos }) + editor.chain().focus().setHeading({ level: 3 }).run() + } catch (error) { + logger.error('Failed to transform to H3', error as Error) + } + } + }, + + { + id: 'transform-paragraph', + label: 'Text', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return node.type.name === 'heading' && editor.can().setParagraph() + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to paragraph', { nodeType: node.type.name, pos }) + editor.chain().focus().setParagraph().run() + } catch (error) { + logger.error('Failed to transform to paragraph', error as Error) + } + } + }, + + { + id: 'transform-bullet-list', + label: 'Bulleted list', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().toggleBulletList() + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to bullet list', { nodeType: node.type.name, pos }) + editor.chain().focus().toggleBulletList().run() + } catch (error) { + logger.error('Failed to transform to bullet list', error as Error) + } + } + }, + + { + id: 'transform-ordered-list', + label: 'Numbered list', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().toggleOrderedList() + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to ordered list', { nodeType: node.type.name, pos }) + editor.chain().focus().toggleOrderedList().run() + } catch (error) { + logger.error('Failed to transform to ordered list', error as Error) + } + } + }, + + { + id: 'transform-blockquote', + label: 'Quote', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().toggleBlockquote() + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to blockquote', { nodeType: node.type.name, pos }) + editor.chain().focus().toggleBlockquote().run() + } catch (error) { + logger.error('Failed to transform to blockquote', error as Error) + } + } + }, + + { + id: 'transform-code-block', + label: 'Code', + group: 'transform' as ActionGroup, + isEnabled: (editor, node) => { + return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().toggleCodeBlock() + }, + execute: (editor, node, pos) => { + try { + logger.debug('Transforming to code block', { nodeType: node.type.name, pos }) + editor.chain().focus().toggleCodeBlock().run() + } catch (error) { + logger.error('Failed to transform to code block', error as Error) + } + } + } +] diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useDragContextMenu.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useDragContextMenu.ts new file mode 100644 index 0000000000..b2bc68ed71 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useDragContextMenu.ts @@ -0,0 +1,279 @@ +import { loggerService } from '@logger' +import type { Editor } from '@tiptap/core' +import type { Node } from '@tiptap/pm/model' +import React, { useCallback, useRef, useState } from 'react' + +import type { EventHandlers, MenuAction, MenuActionResult, PositionOptions, UseDragContextMenuReturn } from '../types' + +const logger = loggerService.withContext('useDragContextMenu') + +interface UseDragContextMenuOptions { + /** 编辑器实例 */ + editor: Editor + /** 事件处理器 */ + eventHandlers?: EventHandlers + /** 位置计算选项 */ + positionOptions?: PositionOptions +} + +/** + * 拖拽上下文菜单核心逻辑 Hook + */ +export function useDragContextMenu({ + editor, + eventHandlers, + positionOptions +}: UseDragContextMenuOptions): UseDragContextMenuReturn { + // 菜单状态 + const [isMenuVisible, setIsMenuVisible] = useState(false) + const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }) + const [currentNode, setCurrentNode] = useState<{ node: Node; position: number } | null>(null) + + // 引用 + const menuRef = useRef(null) + const timeoutRef = useRef(undefined) + + /** + * 计算菜单位置 + */ + const calculateMenuPosition = useCallback( + (clientPos: { x: number; y: number }) => { + const { offset = { x: 10, y: 0 }, boundary, autoAdjust = true } = positionOptions || {} + + let x = clientPos.x + offset.x + let y = clientPos.y + offset.y + + if (autoAdjust) { + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const menuWidth = 280 // 预估菜单宽度 + const menuHeight = 400 // 预估菜单最大高度 + + // 水平位置调整 + if (x + menuWidth > viewportWidth) { + x = clientPos.x - menuWidth - offset.x + } + + // 垂直位置调整 + if (y + menuHeight > viewportHeight) { + y = Math.max(10, viewportHeight - menuHeight - 10) + } + + // 边界约束 + if (boundary) { + const rect = boundary.getBoundingClientRect() + x = Math.max(rect.left, Math.min(x, rect.right - menuWidth)) + y = Math.max(rect.top, Math.min(y, rect.bottom - menuHeight)) + } + } + + return { x, y } + }, + [positionOptions] + ) + + /** + * 显示菜单 + */ + const showMenu = useCallback( + (node: Node, position: number, clientPos: { x: number; y: number }) => { + try { + logger.debug('Showing context menu', { + nodeType: node.type.name, + position, + clientPos + }) + + // 清除之前的延时隐藏 + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = undefined + } + + const menuPos = calculateMenuPosition(clientPos) + + setCurrentNode({ node, position }) + setMenuPosition(menuPos) + setIsMenuVisible(true) + + // 触发事件 + eventHandlers?.onMenuShow?.(node, position) + } catch (error) { + logger.error('Failed to show menu', error as Error) + eventHandlers?.onError?.(error as Error, 'showMenu') + } + }, + [calculateMenuPosition, eventHandlers] + ) + + /** + * 隐藏菜单 + */ + const hideMenu = useCallback(() => { + try { + logger.debug('Hiding context menu') + + setIsMenuVisible(false) + setCurrentNode(null) + + // 延时清理位置,以便动画完成 + timeoutRef.current = setTimeout(() => { + setMenuPosition({ x: 0, y: 0 }) + }, 200) + + // 触发事件 + eventHandlers?.onMenuHide?.() + } catch (error) { + logger.error('Failed to hide menu', error as Error) + eventHandlers?.onError?.(error as Error, 'hideMenu') + } + }, [eventHandlers]) + + /** + * 执行菜单操作 + */ + const executeAction = useCallback( + async (action: MenuAction): Promise => { + if (!currentNode) { + const error = new Error('No current node available') + logger.error('Cannot execute action without current node', error) + return { success: false, error: error.message } + } + + try { + logger.debug('Executing menu action', { + actionId: action.id, + nodeType: currentNode.node.type.name, + position: currentNode.position + }) + + // 检查操作是否可用 + if (!action.isEnabled(editor, currentNode.node, currentNode.position)) { + const error = 'Action is not enabled for current context' + logger.warn('Action not enabled', { actionId: action.id }) + return { success: false, error } + } + + // 执行操作 + action.execute(editor, currentNode.node, currentNode.position) + + const result: MenuActionResult = { + success: true, + shouldCloseMenu: true // 默认执行后关闭菜单 + } + + // 触发事件 + eventHandlers?.onActionExecute?.(action, result) + + // 自动关闭菜单 + if (result.shouldCloseMenu !== false) { + hideMenu() + } + + logger.debug('Action executed successfully', { actionId: action.id }) + return result + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + logger.error('Failed to execute action', error as Error, { + actionId: action.id, + nodeType: currentNode.node.type.name + }) + + const result: MenuActionResult = { + success: false, + error: errorMessage + } + + eventHandlers?.onActionExecute?.(action, result) + eventHandlers?.onError?.(error as Error, `executeAction:${action.id}`) + + return result + } + }, + [currentNode, editor, eventHandlers, hideMenu] + ) + + /** + * 监听编辑器变化,自动隐藏菜单 + */ + const handleEditorUpdate = useCallback(() => { + if (isMenuVisible) { + // 检查当前节点是否仍然有效 + if (currentNode) { + try { + const doc = editor.state.doc + const pos = currentNode.position + + // 检查位置是否仍然有效 + if (pos >= 0 && pos < doc.content.size) { + const resolvedPos = doc.resolve(pos) + const nodeAtPos = resolvedPos.nodeAfter || resolvedPos.parent + + // 如果节点类型或内容发生变化,隐藏菜单 + if (nodeAtPos?.type.name !== currentNode.node.type.name) { + hideMenu() + } + } else { + // 位置无效,隐藏菜单 + hideMenu() + } + } catch (error) { + logger.warn('Invalid node position, hiding menu', error as Error) + hideMenu() + } + } + } + }, [isMenuVisible, currentNode, editor, hideMenu]) + + // 监听编辑器更新 + React.useEffect(() => { + if (!editor) return + + editor.on('update', handleEditorUpdate) + editor.on('blur', hideMenu) + + return () => { + editor.off('update', handleEditorUpdate) + editor.off('blur', hideMenu) + + // 清理定时器 + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } + }, [editor, handleEditorUpdate, hideMenu]) + + // 监听全局点击事件,点击菜单外部时隐藏 + React.useEffect(() => { + if (!isMenuVisible) return + + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as HTMLElement)) { + hideMenu() + } + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + hideMenu() + } + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleEscape) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleEscape) + } + }, [isMenuVisible, hideMenu]) + + return { + isMenuVisible, + menuPosition, + currentNode, + showMenu, + hideMenu, + executeAction + } +} diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useMenuActionVisibility.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useMenuActionVisibility.ts new file mode 100644 index 0000000000..332c41ebcb --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useMenuActionVisibility.ts @@ -0,0 +1,110 @@ +import { loggerService } from '@logger' +import { useCallback, useMemo } from 'react' + +import type { MenuAction, MenuVisibilityOptions, UseMenuActionVisibilityReturn } from '../types' +import { ActionGroup } from '../types' + +const logger = loggerService.withContext('useMenuActionVisibility') + +/** + * 菜单操作可见性管理 Hook + */ +export function useMenuActionVisibility({ + editor, + node, + position, + customRules +}: MenuVisibilityOptions): UseMenuActionVisibilityReturn { + /** + * 计算可见的操作列表 + */ + const visibleActions = useMemo(() => { + if (!editor || !node) { + return [] + } + + try { + // 获取所有已注册的操作 + const allActions = getRegisteredActions() + + // 过滤可用的操作 + const filtered = allActions.filter((action) => { + try { + // 基础可用性检查 + if (!action.isEnabled(editor, node, position)) { + return false + } + + // 自定义规则检查 + if (customRules) { + const customResult = customRules.every((rule) => rule(editor, node, position)) + if (!customResult) { + return false + } + } + + return true + } catch (error) { + logger.warn('Error checking action visibility', error as Error, { actionId: action.id }) + return false + } + }) + + logger.debug('Filtered visible actions', { + total: allActions.length, + visible: filtered.length, + nodeType: node.type.name, + position + }) + + return filtered + } catch (error) { + logger.error('Failed to calculate visible actions', error as Error) + return [] + } + }, [editor, node, position, customRules]) + + /** + * 按组分类的操作 + */ + const actionsByGroup = useMemo(() => { + const grouped: Record = { + [ActionGroup.TRANSFORM]: [], + [ActionGroup.FORMAT]: [], + [ActionGroup.BLOCK]: [], + [ActionGroup.INSERT]: [], + [ActionGroup.AI]: [] + } + + visibleActions.forEach((action) => { + if (grouped[action.group]) { + grouped[action.group].push(action) + } + }) + + return grouped + }, [visibleActions]) + + /** + * 刷新可见性 - 强制重新计算 + */ + const refreshVisibility = useCallback(() => { + // 这个函数主要用于外部强制刷新,实际的刷新通过依赖项自动处理 + logger.debug('Visibility refresh requested') + }, []) + + return { + visibleActions, + actionsByGroup, + refreshVisibility + } +} + +import { allActions } from '../actions' + +/** + * 获取已注册的操作 + */ +function getRegisteredActions(): MenuAction[] { + return allActions +} diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/index.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/index.ts new file mode 100644 index 0000000000..4dd9e042d6 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/index.ts @@ -0,0 +1,54 @@ +/** + * Drag Context Menu - 拖拽上下文菜单 + * + * 提供类似 Notion 的块级操作体验,包括: + * - 拖拽手柄 + * - 上下文菜单 + * - 节点转换操作 + * - 格式化和块操作 + */ + +// 主要组件 +export { default as DragContextMenu } from './DragContextMenu' +// DragContextMenuWrapper 已被 TipTap 扩展替代 + +// Hooks +export { useDragContextMenu } from './hooks/useDragContextMenu' +export { useMenuActionVisibility } from './hooks/useMenuActionVisibility' + +// 操作定义 +export * from './actions' +export { allActions, defaultEnabledActions, getActionById } from './actions' + +// 类型定义 +export type * from './types' + +// 样式组件 +export * from './styles' + +/** + * 默认配置 + */ +export const defaultDragContextMenuConfig = { + enabled: true, + defaultActions: [ + 'transform-heading-1', + 'transform-heading-2', + 'transform-heading-3', + 'transform-paragraph', + 'transform-bullet-list', + 'transform-ordered-list', + 'transform-blockquote', + 'transform-code-block', + 'block-duplicate', + 'block-copy', + 'block-delete', + 'insert-paragraph-after' + ], + groupOrder: ['transform', 'format', 'insert', 'block', 'ai'], + menuStyles: { + maxWidth: 320, + maxHeight: 400, + showShortcuts: true + } +} diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/styles.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/styles.ts new file mode 100644 index 0000000000..6f6bebba62 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/styles.ts @@ -0,0 +1,280 @@ +import styled, { css, keyframes } from 'styled-components' + +/** + * 拖拽上下文菜单样式组件 + */ + +// 动画定义 +const fadeInUp = keyframes` + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +` + +const fadeOut = keyframes` + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(10px); + } +` + +/** + * 菜单容器 + */ +export const MenuContainer = styled.div<{ $visible: boolean }>` + position: fixed; + z-index: 2000; + background: var(--color-bg-base); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: + 0 6px 16px rgba(0, 0, 0, 0.12), + 0 3px 6px rgba(0, 0, 0, 0.08); + overflow: hidden; + min-width: 280px; + max-width: 320px; + max-height: 400px; + + ${(props) => + props.$visible + ? css` + animation: ${fadeInUp} 0.15s ease-out; + ` + : css` + animation: ${fadeOut} 0.15s ease-out; + pointer-events: none; + `} + + /* 响应式调整 */ + @media (max-width: 480px) { + min-width: 240px; + max-width: 280px; + } +` + +/** + * 菜单组标题 + */ +export const MenuGroupTitle = styled.div` + padding: 8px 16px 4px; + font-size: 12px; + font-weight: 500; + color: var(--color-text-3); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: none; + + &:not(:first-child) { + margin-top: 8px; + padding-top: 12px; + border-top: 1px solid var(--color-border-secondary); + } +` + +/** + * 菜单项容器 + */ +export const MenuGroup = styled.div` + padding: 4px 0; + + &:not(:last-child) { + border-bottom: 1px solid var(--color-border-secondary); + } +` + +/** + * 菜单项 + */ +export const MenuItem = styled.button<{ $danger?: boolean }>` + width: 100%; + display: flex; + align-items: center; + padding: 8px 16px; + border: none; + background: transparent; + color: var(--color-text); + font-size: 14px; + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease; + gap: 12px; + + ${(props) => + props.$danger && + css` + color: var(--color-error); + + &:hover { + background: var(--color-error-bg); + color: var(--color-error); + } + `} + + &:hover { + background: var(--color-hover); + } + + &:focus { + outline: none; + background: var(--color-primary-bg); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + + &:hover { + background: transparent; + } + } +` + +/** + * 菜单项图标 + */ +export const MenuItemIcon = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; +` + +/** + * 菜单项标签 + */ +export const MenuItemLabel = styled.span` + flex: 1; + font-weight: 400; +` + +/** + * 菜单项快捷键 + */ +export const MenuItemShortcut = styled.span` + font-size: 12px; + color: var(--color-text-3); + font-family: var(--font-mono); + margin-left: auto; +` + +/** + * 拖拽手柄容器样式 + */ +export const DragHandleContainer = styled.div<{ $visible: boolean }>` + display: flex; + align-items: center; + gap: 0.25rem; + opacity: ${(props) => (props.$visible ? 1 : 0)}; + transition: opacity 0.15s ease; + position: absolute; + left: -60px; + top: 50%; + transform: translateY(-50%); + z-index: 10; + padding: 2px; +` + +/** + * 手柄按钮基础样式 + */ +const handleButtonBase = css` + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 0.25rem; + border: none; + background: var(--color-background); + color: var(--color-text-3); + cursor: pointer; + transition: background 0.15s ease; + padding: 0; + + &:hover { + background: var(--color-hover); + } + + &:focus { + outline: none; + background: var(--color-primary-bg); + } +` + +/** + * 加号按钮 + */ +export const PlusButton = styled.button` + ${handleButtonBase} +` + +/** + * 拖拽手柄 + */ +export const DragHandleButton = styled.div` + ${handleButtonBase} + cursor: grab; + + &:active { + cursor: grabbing; + } + + &[draggable='true'] { + user-select: none; + } +` + +/** + * 加载状态指示器 + */ +export const LoadingIndicator = styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + color: var(--color-text-3); + font-size: 14px; +` + +/** + * 错误状态显示 + */ +export const ErrorMessage = styled.div` + padding: 12px 16px; + color: var(--color-error); + background: var(--color-error-bg); + border-radius: 4px; + margin: 8px; + font-size: 14px; + text-align: center; +` + +/** + * 空状态显示 + */ +export const EmptyState = styled.div` + padding: 24px 16px; + text-align: center; + color: var(--color-text-3); + font-size: 14px; +` + +/** + * 分隔线 + */ +export const MenuDivider = styled.hr` + border: none; + border-top: 1px solid var(--color-border-secondary); + margin: 4px 0; +` diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/types.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/types.ts new file mode 100644 index 0000000000..32932c7646 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/types.ts @@ -0,0 +1,224 @@ +import type { Editor } from '@tiptap/core' +import type { Node } from '@tiptap/pm/model' +import type { ReactNode } from 'react' + +/** + * 操作组类型 - 用于菜单项分组 + */ +export enum ActionGroup { + TRANSFORM = 'transform', // 节点转换操作 + FORMAT = 'format', // 格式化操作 + BLOCK = 'block', // 块级操作 + INSERT = 'insert', // 插入操作 + AI = 'ai' // AI 相关操作 (预留) +} + +/** + * 菜单操作项接口 + */ +export interface MenuAction { + /** 操作唯一标识 */ + id: string + /** 显示标签 */ + label: string + /** 图标 */ + icon?: ReactNode + /** 操作组 */ + group: ActionGroup + /** 快捷键描述 */ + shortcut?: string + /** 是否为危险操作 */ + danger?: boolean + /** 是否可用 */ + isEnabled: (editor: Editor, node: Node, pos: number) => boolean + /** 执行操作 */ + execute: (editor: Editor, node: Node, pos: number) => void + /** 自定义类名 */ + className?: string +} + +/** + * 节点转换选项 + */ +export interface NodeTransformOptions { + /** 目标节点类型 */ + nodeType: string + /** 节点属性 */ + attrs?: Record + /** 是否保留内容 */ + preserveContent?: boolean +} + +/** + * 拖拽上下文菜单属性 + */ +export interface DragContextMenuProps { + /** 编辑器实例 */ + editor: Editor + /** 当前节点 */ + node: Node + /** 节点在文档中的位置 */ + position: number + /** 菜单显示状态 */ + visible: boolean + /** 菜单位置 */ + menuPosition: { x: number; y: number } + /** 关闭回调 */ + onClose: () => void + /** 自定义操作 */ + customActions?: MenuAction[] + /** 禁用的操作 ID 列表 */ + disabledActions?: string[] +} + +/** + * 拖拽手柄属性 + */ +export interface DragHandleProps { + /** 编辑器实例 */ + editor: Editor + /** 当前节点 */ + node: Node + /** 节点位置 */ + position: number + /** 是否显示 */ + visible: boolean + /** 点击回调 */ + onClick: () => void + /** 拖拽开始回调 */ + onDragStart?: (e: DragEvent) => void + /** 自定义类名 */ + className?: string +} + +/** + * 菜单可见性配置 + */ +export interface MenuVisibilityOptions { + /** 当前编辑器实例 */ + editor: Editor + /** 当前节点 */ + node: Node + /** 节点位置 */ + position: number + /** 自定义可见性规则 */ + customRules?: Array<(editor: Editor, node: Node, pos: number) => boolean> +} + +/** + * 菜单操作结果 + */ +export interface MenuActionResult { + /** 是否成功执行 */ + success: boolean + /** 错误信息 */ + error?: string + /** 是否需要关闭菜单 */ + shouldCloseMenu?: boolean +} + +/** + * 颜色选择器选项 + */ +export interface ColorOption { + /** 颜色值 */ + color: string + /** 显示名称 */ + name: string + /** 是否为默认颜色 */ + isDefault?: boolean +} + +/** + * 节点转换映射 + */ +export interface NodeTransformMap { + [key: string]: { + /** 显示名称 */ + label: string + /** 图标 */ + icon: ReactNode + /** 转换配置 */ + transform: NodeTransformOptions + /** 是否可用的检查函数 */ + isAvailable?: (editor: Editor, currentNode: Node) => boolean + } +} + +/** + * 拖拽上下文菜单配置 + */ +export interface DragContextMenuConfig { + /** 是否启用 */ + enabled: boolean + /** 默认操作列表 */ + defaultActions: string[] + /** 自定义操作 */ + customActions?: MenuAction[] + /** 操作组排序 */ + groupOrder?: ActionGroup[] + /** 颜色选择器配置 */ + colorOptions?: ColorOption[] + /** 节点转换映射 */ + transformMap?: NodeTransformMap + /** 菜单样式配置 */ + menuStyles?: { + maxWidth?: number + maxHeight?: number + showShortcuts?: boolean + } +} + +/** + * 钩子返回值 - useDragContextMenu + */ +export interface UseDragContextMenuReturn { + /** 菜单是否可见 */ + isMenuVisible: boolean + /** 菜单位置 */ + menuPosition: { x: number; y: number } + /** 当前节点信息 */ + currentNode: { node: Node; position: number } | null + /** 显示菜单 */ + showMenu: (node: Node, position: number, clientPos: { x: number; y: number }) => void + /** 隐藏菜单 */ + hideMenu: () => void + /** 执行操作 */ + executeAction: (action: MenuAction) => Promise +} + +/** + * 钩子返回值 - useMenuActionVisibility + */ +export interface UseMenuActionVisibilityReturn { + /** 可见的操作列表 */ + visibleActions: MenuAction[] + /** 按组分类的操作 */ + actionsByGroup: Record + /** 刷新可见性 */ + refreshVisibility: () => void +} + +/** + * 事件处理器类型 + */ +export interface EventHandlers { + onMenuShow?: (node: Node, position: number) => void + onMenuHide?: () => void + onActionExecute?: (action: MenuAction, result: MenuActionResult) => void + onError?: (error: Error, context: string) => void +} + +/** + * 位置计算选项 + */ +export interface PositionOptions { + /** 偏移量 */ + offset?: { x: number; y: number } + /** 边界约束 */ + boundary?: HTMLElement + /** 对齐方式 */ + align?: 'start' | 'center' | 'end' + /** 自动调整位置 */ + autoAdjust?: boolean +} diff --git a/src/renderer/src/components/RichEditor/components/placeholder/ImagePlaceholderNodeView.tsx b/src/renderer/src/components/RichEditor/components/placeholder/ImagePlaceholderNodeView.tsx new file mode 100644 index 0000000000..a838782ece --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/placeholder/ImagePlaceholderNodeView.tsx @@ -0,0 +1,47 @@ +import { Editor } from '@tiptap/core' +import { NodeViewWrapper } from '@tiptap/react' +import { Image as ImageIcon } from 'lucide-react' +import React, { useCallback } from 'react' +import { useTranslation } from 'react-i18next' + +import PlaceholderBlock from './PlaceholderBlock' + +interface ImagePlaceholderNodeViewProps { + node: any + updateAttributes: (attributes: Record) => void + deleteNode: () => void + editor: Editor +} + +const ImagePlaceholderNodeView: React.FC = ({ deleteNode, editor }) => { + const { t } = useTranslation() + + const handleClick = useCallback(() => { + const event = new CustomEvent('openImageUploader', { + detail: { + onImageSelect: (imageUrl: string) => { + if (imageUrl.trim()) { + deleteNode() + editor.chain().focus().setImage({ src: imageUrl }).run() + } else { + deleteNode() + } + }, + onCancel: () => deleteNode() + } + }) + window.dispatchEvent(event) + }, [editor, deleteNode]) + + return ( + + } + message={t('richEditor.image.placeholder')} + onClick={handleClick} + /> + + ) +} + +export default ImagePlaceholderNodeView diff --git a/src/renderer/src/components/RichEditor/components/placeholder/MathPlaceholderNodeView.tsx b/src/renderer/src/components/RichEditor/components/placeholder/MathPlaceholderNodeView.tsx new file mode 100644 index 0000000000..95c1b0f98e --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/placeholder/MathPlaceholderNodeView.tsx @@ -0,0 +1,74 @@ +import { type NodeViewProps, NodeViewWrapper } from '@tiptap/react' +import { Calculator } from 'lucide-react' +import React, { useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' + +import PlaceholderBlock from './PlaceholderBlock' + +const MathPlaceholderNodeView: React.FC = ({ node, deleteNode, editor }) => { + const { t } = useTranslation() + const wrapperRef = useRef(null) + + const handleClick = useCallback(() => { + let hasCreatedMath = false + const mathType = node.attrs.mathType || 'block' + + let position: { x: number; y: number; top: number } | undefined + if (wrapperRef.current) { + const rect = wrapperRef.current.getBoundingClientRect() + position = { + x: rect.left + rect.width / 2, + y: rect.bottom, + top: rect.top + } + } + + const event = new CustomEvent('openMathDialog', { + detail: { + defaultValue: '', + position, + onSubmit: (latex: string) => { + // onFormulaChange has already handled the creation/update + // onSubmit just needs to close the dialog + // Only delete if input is empty + if (!latex.trim()) { + deleteNode() + } + }, + onCancel: () => deleteNode(), + onFormulaChange: (formula: string) => { + if (formula.trim()) { + if (!hasCreatedMath) { + hasCreatedMath = true + deleteNode() + if (mathType === 'block') { + editor.chain().insertBlockMath({ latex: formula }).run() + } else { + editor.chain().insertInlineMath({ latex: formula }).run() + } + } else { + if (mathType === 'block') { + editor.chain().updateBlockMath({ latex: formula }).run() + } else { + editor.chain().updateInlineMath({ latex: formula }).run() + } + } + } + } + } + }) + window.dispatchEvent(event) + }, [node.attrs.mathType, deleteNode, editor]) + + return ( + + } + message={t('richEditor.math.placeholder')} + onClick={handleClick} + /> + + ) +} + +export default MathPlaceholderNodeView diff --git a/src/renderer/src/components/RichEditor/components/placeholder/PlaceholderBlock.tsx b/src/renderer/src/components/RichEditor/components/placeholder/PlaceholderBlock.tsx new file mode 100644 index 0000000000..c33cdb78cd --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/placeholder/PlaceholderBlock.tsx @@ -0,0 +1,62 @@ +import { useTheme } from '@renderer/context/ThemeProvider' +import React from 'react' + +interface PlaceholderBlockProps { + /** Icon element to display */ + icon: React.ReactNode + /** Localised message */ + message: string + /** Click handler */ + onClick: () => void +} + +/** + * Reusable placeholder block for TipTap NodeViews (math / image etc.) + * Handles dark-mode colours and simple hover feedback. + */ +const PlaceholderBlock: React.FC = ({ icon, message, onClick }) => { + const { theme } = useTheme() + const isDark = theme === 'dark' + + const colors = { + border: isDark ? 'var(--color-border, #ffffff19)' : '#d0d7de', + background: isDark ? 'var(--color-background-soft, #222222)' : 'var(--color-canvas-subtle, #f6f8fa)', + hoverBorder: isDark ? 'var(--color-primary, #2f81f7)' : '#0969da', + hoverBackground: isDark ? 'rgba(56, 139, 253, 0.15)' : 'var(--color-accent-subtle, #ddf4ff)' + } + + return ( +
{ + const target = e.currentTarget as HTMLElement + target.style.borderColor = colors.hoverBorder + target.style.backgroundColor = colors.hoverBackground + }} + onMouseLeave={(e) => { + const target = e.currentTarget as HTMLElement + target.style.borderColor = colors.border + target.style.backgroundColor = colors.background + }}> + {icon} + {message} +
+ ) +} + +export default PlaceholderBlock diff --git a/src/renderer/src/components/RichEditor/extensions/code-block-shiki/CodeBlockNodeView.tsx b/src/renderer/src/components/RichEditor/extensions/code-block-shiki/CodeBlockNodeView.tsx new file mode 100644 index 0000000000..75ccf6befb --- /dev/null +++ b/src/renderer/src/components/RichEditor/extensions/code-block-shiki/CodeBlockNodeView.tsx @@ -0,0 +1,87 @@ +import { CopyOutlined } from '@ant-design/icons' +import { DEFAULT_LANGUAGES, getHighlighter, getShiki } from '@renderer/utils/shiki' +import { NodeViewContent, NodeViewWrapper, type ReactNodeViewProps, ReactNodeViewRenderer } from '@tiptap/react' +import { Button, Select, Tooltip } from 'antd' +import { FC, useCallback, useEffect, useState } from 'react' + +const CodeBlockNodeView: FC = (props) => { + const { node, updateAttributes } = props + const [languageOptions, setLanguageOptions] = useState(DEFAULT_LANGUAGES) + + // Detect language from node attrs or fallback + const language = (node.attrs.language as string) || 'text' + + // Build language options with 'text' always available + useEffect(() => { + const loadLanguageOptions = async () => { + try { + const shiki = await getShiki() + const highlighter = await getHighlighter() + + // Get bundled languages from shiki + const bundledLanguages = Object.keys(shiki.bundledLanguages) + + // Combine with loaded languages + const loadedLanguages = highlighter.getLoadedLanguages() + + const allLanguages = Array.from(new Set(['text', ...bundledLanguages, ...loadedLanguages])) + + setLanguageOptions(allLanguages) + } catch { + setLanguageOptions(DEFAULT_LANGUAGES) + } + } + + loadLanguageOptions() + }, []) + + // Handle language change + const handleLanguageChange = useCallback( + (value: string) => { + updateAttributes({ language: value }) + }, + [updateAttributes] + ) + + // Handle copy code block content + const handleCopy = useCallback(async () => { + const codeText = props.node.textContent || '' + try { + await navigator.clipboard.writeText(codeText) + } catch { + // Clipboard may fail (e.g. non-secure context) + } + }, [props.node.textContent]) + + return ( + +
+ setTempPath(e.target.value)} + placeholder={t('notes.settings.data.work_directory_placeholder')} + readOnly + /> + + + + + + + + + {t('notes.settings.data.work_directory_description')} + + + + {/* Editor Settings */} + + {t('notes.settings.editor.title')} + + + {t('notes.settings.editor.view_mode.title')} + updateSettings({ defaultViewMode: value })} + /> + + {t('notes.settings.editor.view_mode.description')} + + + {t('notes.settings.editor.edit_mode.title')} + ) => updateSettings({ defaultEditMode: value })} + /> + + {t('notes.settings.editor.edit_mode.description')} + + + {/* Display Settings */} + + {t('notes.settings.display.title')} + + + {t('notes.settings.display.compress_content')} + updateSettings({ isFullWidth: checked })} /> + + {t('notes.settings.display.compress_content_description')} + + + ) +} + +const WorkDirectorySection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +` + +const PathInputContainer = styled.div` + display: flex; + align-items: center; + width: 100%; +` + +const ActionButtons = styled.div` + display: flex; + gap: 8px; + align-self: flex-start; +` + +export default NotesSettings diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index 1f7f8d7a85..6886d5f035 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -12,6 +12,7 @@ import { HardDrive, Info, MonitorCog, + NotebookPen, Package, PictureInPicture2, Settings2, @@ -30,6 +31,7 @@ import DocProcessSettings from './DocProcessSettings' import GeneralSettings from './GeneralSettings' import MCPSettings from './MCPSettings' import MemorySettings from './MemorySettings' +import NotesSettings from './NotesSettings' import { ProviderList } from './ProviderSettings' import QuickAssistantSettings from './QuickAssistantSettings' import QuickPhraseSettings from './QuickPhraseSettings' @@ -88,6 +90,12 @@ const SettingsPage: FC = () => { {t('settings.mcp.title')} + + + + {t('notes.settings.title')} + + @@ -154,6 +162,7 @@ const SettingsPage: FC = () => { } /> } /> } /> + } /> } /> diff --git a/src/renderer/src/services/FileManager.ts b/src/renderer/src/services/FileManager.ts index 4b780cfa94..ec32535da7 100644 --- a/src/renderer/src/services/FileManager.ts +++ b/src/renderer/src/services/FileManager.ts @@ -10,8 +10,7 @@ const logger = loggerService.withContext('FileManager') class FileManager { static async selectFiles(options?: Electron.OpenDialogOptions): Promise { - const files = await window.api.file.select(options) - return files + return await window.api.file.select(options) } static async addFile(file: FileMetadata): Promise { diff --git a/src/renderer/src/services/NotesService.ts b/src/renderer/src/services/NotesService.ts new file mode 100644 index 0000000000..0b150a25a5 --- /dev/null +++ b/src/renderer/src/services/NotesService.ts @@ -0,0 +1,370 @@ +import { loggerService } from '@logger' +import db from '@renderer/databases' +import { + findNodeInTree, + findParentNode, + getNotesTree, + insertNodeIntoTree, + isParentNode, + moveNodeInTree, + removeNodeFromTree, + renameNodeFromTree +} from '@renderer/services/NotesTreeService' +import { NotesSortType, NotesTreeNode } from '@renderer/types/note' +import { getFileDirectory } from '@renderer/utils' +import { v4 as uuidv4 } from 'uuid' + +const MARKDOWN_EXT = '.md' +const NOTES_TREE_ID = 'notes-tree-structure' + +const logger = loggerService.withContext('NotesService') + +/** + * 初始化/同步笔记树结构 + */ +export async function initWorkSpace(folderPath: string): Promise { + const tree = await window.api.file.getDirectoryStructure(folderPath) + await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) +} + +/** + * 创建新文件夹 + */ +export async function createFolder(name: string, folderPath: string): Promise { + const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, false) + if (exists) { + logger.warn(`Folder already exists: ${safeName}`) + } + + const tree = await getNotesTree() + const folderId = uuidv4() + + const targetPath = await window.api.file.mkdir(`${folderPath}/${safeName}`) + + // 查找父节点ID + const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath) + + const folder: NotesTreeNode = { + id: folderId, + name: safeName, + treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`, + externalPath: targetPath, + type: 'folder', + children: [], + expanded: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + + insertNodeIntoTree(tree, folder, parentNode?.id) + + return folder +} + +/** + * 创建新笔记文件 + */ +export async function createNote(name: string, content: string = '', folderPath: string): Promise { + const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, true) + if (exists) { + logger.warn(`Note already exists: ${safeName}`) + } + + const tree = await getNotesTree() + const noteId = uuidv4() + const notePath = `${folderPath}/${safeName}${MARKDOWN_EXT}` + + await window.api.file.write(notePath, content) + + // 查找父节点ID + const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath) + + const note: NotesTreeNode = { + id: noteId, + name: safeName, + treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`, + externalPath: notePath, + type: 'file', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + + insertNodeIntoTree(tree, note, parentNode?.id) + + return note +} + +/** + * 上传笔记 + */ +export async function uploadNote(file: File, folderPath: string): Promise { + const tree = await getNotesTree() + const fileName = file.name.toLowerCase() + if (!fileName.endsWith(MARKDOWN_EXT)) { + throw new Error('Only markdown files are allowed') + } + + const noteId = uuidv4() + const nameWithoutExt = fileName.replace(MARKDOWN_EXT, '') + + const { safeName, exists } = await window.api.file.checkFileName(folderPath, nameWithoutExt, true) + if (exists) { + logger.warn(`Note already exists: ${safeName}`) + } + + const notePath = `${folderPath}/${safeName}${MARKDOWN_EXT}` + + const note: NotesTreeNode = { + id: noteId, + name: safeName, + treePath: `/${safeName}`, + externalPath: notePath, + type: 'file', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + + const content = await file.text() + await window.api.file.write(notePath, content) + insertNodeIntoTree(tree, note) + + return note +} + +/** + * 删除笔记或文件夹 + */ +export async function deleteNode(nodeId: string): Promise { + const tree = await getNotesTree() + const node = findNodeInTree(tree, nodeId) + if (!node) { + throw new Error('Node not found') + } + if (node.type === 'folder') { + await window.api.file.deleteExternalDir(node.externalPath) + } else if (node.type === 'file') { + await window.api.file.deleteExternalFile(node.externalPath) + } + + removeNodeFromTree(tree, nodeId) +} + +/** + * 重命名笔记或文件夹 + */ +export async function renameNode(nodeId: string, newName: string): Promise { + const tree = await getNotesTree() + const node = findNodeInTree(tree, nodeId) + if (!node) { + throw new Error('Node not found') + } + + const dirPath = getFileDirectory(node.externalPath) + const { safeName, exists } = await window.api.file.checkFileName(dirPath, newName, node.type === 'file') + + if (exists) { + logger.warn(`Target name already exists: ${safeName}`) + throw new Error(`Target name already exists: ${safeName}`) + } + + if (node.type === 'file') { + await window.api.file.rename(node.externalPath, safeName) + } else if (node.type === 'folder') { + await window.api.file.renameDir(node.externalPath, safeName) + } + return renameNodeFromTree(tree, nodeId, safeName) +} + +/** + * 移动节点 + */ +export async function moveNode( + sourceNodeId: string, + targetNodeId: string, + position: 'before' | 'after' | 'inside' +): Promise { + try { + const tree = await getNotesTree() + + // 找到源节点和目标节点 + const sourceNode = findNodeInTree(tree, sourceNodeId) + const targetNode = findNodeInTree(tree, targetNodeId) + + if (!sourceNode || !targetNode) { + logger.error(`Move nodes failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`) + return false + } + + // 不允许文件夹被放入文件中 + if (position === 'inside' && targetNode.type === 'file' && sourceNode.type === 'folder') { + logger.error('Move nodes failed: cannot move a folder inside a file') + return false + } + + // 不允许将节点移动到自身内部 + if (position === 'inside' && isParentNode(tree, sourceNodeId, targetNodeId)) { + logger.error('Move nodes failed: cannot move a node inside itself or its descendants') + return false + } + + let targetPath: string = '' + + if (position === 'inside') { + // 目标是文件夹内部 + if (targetNode.type === 'folder') { + targetPath = targetNode.externalPath + } else { + logger.error('Cannot move node inside a file node') + return false + } + } else { + const targetParent = findParentNode(tree, targetNodeId) + if (targetParent) { + targetPath = targetParent.externalPath + } else { + targetPath = getFileDirectory(targetNode.externalPath!) + } + } + + // 构建新的文件路径 + const sourceName = sourceNode.externalPath!.split('/').pop()! + const sourceNameWithoutExt = sourceName.replace(sourceNode.type === 'file' ? MARKDOWN_EXT : '', '') + + const { safeName } = await window.api.file.checkFileName( + targetPath, + sourceNameWithoutExt, + sourceNode.type === 'file' + ) + + const baseName = safeName + (sourceNode.type === 'file' ? MARKDOWN_EXT : '') + const newPath = `${targetPath}/${baseName}` + + if (sourceNode.externalPath !== newPath) { + try { + if (sourceNode.type === 'folder') { + await window.api.file.moveDir(sourceNode.externalPath, newPath) + } else { + await window.api.file.move(sourceNode.externalPath, newPath) + } + sourceNode.externalPath = newPath + logger.debug(`Moved external ${sourceNode.type} to: ${newPath}`) + } catch (error) { + logger.error(`Failed to move external ${sourceNode.type}:`, error as Error) + return false + } + } + + return await moveNodeInTree(tree, sourceNodeId, targetNodeId, position) + } catch (error) { + logger.error('Move nodes failed:', error as Error) + return false + } +} + +/** + * 对节点数组进行排序 + */ +function sortNodesArray(nodes: NotesTreeNode[], sortType: NotesSortType): void { + // 首先分离文件夹和文件 + const folders: NotesTreeNode[] = nodes.filter((node) => node.type === 'folder') + const files: NotesTreeNode[] = nodes.filter((node) => node.type === 'file') + + // 根据排序类型对文件夹和文件分别进行排序 + const sortFunction = getSortFunction(sortType) + folders.sort(sortFunction) + files.sort(sortFunction) + + // 清空原数组并重新填入排序后的节点 + nodes.length = 0 + nodes.push(...folders, ...files) +} + +/** + * 根据排序类型获取相应的排序函数 + */ +function getSortFunction(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number { + switch (sortType) { + case 'sort_a2z': + return (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'accent' }) + + case 'sort_z2a': + return (a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: 'accent' }) + + case 'sort_updated_desc': + return (a, b) => { + const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0 + const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0 + return timeB - timeA + } + + case 'sort_updated_asc': + return (a, b) => { + const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0 + const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0 + return timeA - timeB + } + + case 'sort_created_desc': + return (a, b) => { + const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0 + const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0 + return timeB - timeA + } + + case 'sort_created_asc': + return (a, b) => { + const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0 + const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0 + return timeA - timeB + } + + default: + return (a, b) => a.name.localeCompare(b.name) + } +} + +/** + * 递归排序笔记树中的所有层级 + */ +export async function sortAllLevels(sortType: NotesSortType): Promise { + try { + const tree = await getNotesTree() + sortNodesArray(tree, sortType) + recursiveSortNodes(tree, sortType) + await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) + logger.info(`Sorted all levels of notes successfully: ${sortType}`) + } catch (error) { + logger.error('Failed to sort all levels of notes:', error as Error) + throw error + } +} + +/** + * 递归对节点中的子节点进行排序 + */ +function recursiveSortNodes(nodes: NotesTreeNode[], sortType: NotesSortType): void { + for (const node of nodes) { + if (node.type === 'folder' && node.children && node.children.length > 0) { + sortNodesArray(node.children, sortType) + recursiveSortNodes(node.children, sortType) + } + } +} + +/** + * 根据外部路径查找节点(递归查找) + */ +function findNodeByExternalPath(nodes: NotesTreeNode[], externalPath: string): NotesTreeNode | null { + for (const node of nodes) { + if (node.externalPath === externalPath) { + return node + } + if (node.children && node.children.length > 0) { + const found = findNodeByExternalPath(node.children, externalPath) + if (found) { + return found + } + } + } + return null +} diff --git a/src/renderer/src/services/NotesTreeService.ts b/src/renderer/src/services/NotesTreeService.ts new file mode 100644 index 0000000000..5ce0bc0d5c --- /dev/null +++ b/src/renderer/src/services/NotesTreeService.ts @@ -0,0 +1,285 @@ +import { loggerService } from '@logger' +import db from '@renderer/databases' +import { NotesTreeNode } from '@renderer/types/note' + +const MARKDOWN_EXT = '.md' +const NOTES_TREE_ID = 'notes-tree-structure' + +const logger = loggerService.withContext('NotesTreeService') + +/** + * 获取树结构 + */ +export const getNotesTree = async (): Promise => { + const record = await db.notes_tree.get(NOTES_TREE_ID) + return record?.tree || [] +} + +/** + * 在树中插入节点 + */ +export async function insertNodeIntoTree( + tree: NotesTreeNode[], + node: NotesTreeNode, + parentId?: string +): Promise { + try { + if (!parentId) { + tree.push(node) + } else { + const parent = findNodeInTree(tree, parentId) + if (parent && parent.type === 'folder') { + if (!parent.children) { + parent.children = [] + } + parent.children.push(node) + } + } + + await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) + return tree + } catch (error) { + logger.error('Failed to insert node into tree:', error as Error) + throw error + } +} + +/** + * 从树中删除节点 + */ +export async function removeNodeFromTree(tree: NotesTreeNode[], nodeId: string): Promise { + const removed = removeNodeFromTreeInMemory(tree, nodeId) + if (removed) { + await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) + } + return removed +} + +/** + * 从树中删除节点(仅在内存中操作,不保存数据库) + */ +function removeNodeFromTreeInMemory(tree: NotesTreeNode[], nodeId: string): boolean { + for (let i = 0; i < tree.length; i++) { + if (tree[i].id === nodeId) { + tree.splice(i, 1) + return true + } + if (tree[i].children) { + const removed = removeNodeFromTreeInMemory(tree[i].children!, nodeId) + if (removed) { + return true + } + } + } + return false +} + +export async function moveNodeInTree( + tree: NotesTreeNode[], + sourceNodeId: string, + targetNodeId: string, + position: 'before' | 'after' | 'inside' +): Promise { + try { + const sourceNode = findNodeInTree(tree, sourceNodeId) + const targetNode = findNodeInTree(tree, targetNodeId) + + if (!sourceNode || !targetNode) { + logger.error(`Move nodes in tree failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`) + return false + } + + // 先保存源节点的副本,以防操作失败需要恢复(暂未实现恢复逻辑) + // const sourceNodeCopy = { ...sourceNode } + + // 从原位置移除节点(不保存数据库,只在内存中操作) + const removed = removeNodeFromTreeInMemory(tree, sourceNodeId) + if (!removed) { + logger.error('Move nodes in tree failed: could not remove source node') + return false + } + + try { + // 根据位置进行放置 + if (position === 'inside' && targetNode.type === 'folder') { + if (!targetNode.children) { + targetNode.children = [] + } + targetNode.children.push(sourceNode) + targetNode.expanded = true + + sourceNode.treePath = `${targetNode.treePath}/${sourceNode.name}` + } else { + const targetParent = findParentNode(tree, targetNodeId) + const targetList = targetParent ? targetParent.children! : tree + const targetIndex = targetList.findIndex((node) => node.id === targetNodeId) + + if (targetIndex === -1) { + logger.error('Move nodes in tree failed: target position not found') + return false + } + + // 根据position确定插入位置 + const insertIndex = position === 'before' ? targetIndex : targetIndex + 1 + targetList.splice(insertIndex, 0, sourceNode) + + // 更新节点路径 + if (targetParent) { + sourceNode.treePath = `${targetParent.treePath}/${sourceNode.name}` + } else { + sourceNode.treePath = `/${sourceNode.name}` + } + } + + // 更新修改时间 + sourceNode.updatedAt = new Date().toISOString() + + // 只有在所有操作成功后才保存到数据库 + await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) + + return true + } catch (error) { + logger.error('Move nodes in tree failed during placement, attempting to restore:', error as Error) + // 如果放置失败,尝试恢复原始节点到原位置 + // 这里需要重新实现恢复逻辑,暂时返回false + return false + } + } catch (error) { + logger.error('Move nodes in tree failed:', error as Error) + return false + } +} + +/** + * 重命名节点 + */ +export async function renameNodeFromTree( + tree: NotesTreeNode[], + nodeId: string, + newName: string +): Promise { + const node = findNodeInTree(tree, nodeId) + + if (!node) { + throw new Error('Node not found') + } + + node.name = newName + + const dirPath = node.treePath.substring(0, node.treePath.lastIndexOf('/') + 1) + node.treePath = dirPath + newName + + const externalDirPath = node.externalPath.substring(0, node.externalPath.lastIndexOf('/') + 1) + node.externalPath = node.type === 'file' ? externalDirPath + newName + MARKDOWN_EXT : externalDirPath + newName + + node.updatedAt = new Date().toISOString() + await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) + return node +} + +/** + * 修改节点键值 + */ +export async function updateNodeInTree( + tree: NotesTreeNode[], + nodeId: string, + updates: Partial +): Promise { + const node = findNodeInTree(tree, nodeId) + if (!node) { + throw new Error('Node not found') + } + + Object.assign(node, updates) + node.updatedAt = new Date().toISOString() + await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) + + return node +} + +/** + * 在树中查找节点 + */ +export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null { + for (const node of tree) { + if (node.id === nodeId) { + return node + } + if (node.children) { + const found = findNodeInTree(node.children, nodeId) + if (found) { + return found + } + } + } + return null +} + +/** + * 根据路径查找节点 + */ +export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNode | null { + for (const node of tree) { + if (node.treePath === path) { + return node + } + if (node.children) { + const found = findNodeByPath(node.children, path) + if (found) { + return found + } + } + } + return null +} + +// --- +// 辅助函数 +// --- + +/** + * 查找节点的父节点 + */ +export function findParentNode(tree: NotesTreeNode[], targetNodeId: string): NotesTreeNode | null { + for (const node of tree) { + if (node.children) { + const isDirectChild = node.children.some((child) => child.id === targetNodeId) + if (isDirectChild) { + return node + } + + const parent = findParentNode(node.children, targetNodeId) + if (parent) { + return parent + } + } + } + return null +} + +/** + * 判断节点是否为另一个节点的父节点 + */ +export function isParentNode(tree: NotesTreeNode[], parentId: string, childId: string): boolean { + const childNode = findNodeInTree(tree, childId) + if (!childNode) { + return false + } + + const parentNode = findNodeInTree(tree, parentId) + if (!parentNode || parentNode.type !== 'folder' || !parentNode.children) { + return false + } + + if (parentNode.children.some((child) => child.id === childId)) { + return true + } + + for (const child of parentNode.children) { + if (isParentNode(tree, child.id, childId)) { + return true + } + } + + return false +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 26b19f60a5..01bb7d217c 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -19,6 +19,7 @@ import messageBlocksReducer from './messageBlock' import migrate from './migrate' import minapps from './minapps' import newMessagesReducer from './newMessage' +import note from './note' import nutstore from './nutstore' import ocr from './ocr' import paintings from './paintings' @@ -57,14 +58,15 @@ const rootReducer = combineReducers({ messageBlocks: messageBlocksReducer, inputTools: inputToolsReducer, translate, - ocr + ocr, + note }) const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 140, + version: 142, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, @@ -83,7 +85,7 @@ const persistedReducer = persistReducer( * Call storeSyncService.subscribe() in the window's entryPoint.tsx */ storeSyncService.setOptions({ - syncList: ['assistants/', 'settings/', 'llm/', 'selectionStore/'] + syncList: ['assistants/', 'settings/', 'llm/', 'selectionStore/', 'note/'] }) const store = configureStore({ diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 005ad75517..05cbd3c9c3 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -40,6 +40,7 @@ import { RootState } from '.' import { DEFAULT_TOOL_ORDER } from './inputTools' import { initialState as llmInitialState, moveProvider } from './llm' import { mcpSlice } from './mcp' +import { initialState as notesInitialState } from './note' import { defaultActionItems } from './selectionStore' import { initialState as settingsInitialState } from './settings' import { initialState as shortcutsInitialState } from './shortcuts' @@ -2287,6 +2288,32 @@ const migrateConfig = { logger.error('migrate 140 error', error as Error) return state } + }, + '141': (state: RootState) => { + try { + if (state.settings && state.settings.sidebarIcons) { + // Check if 'notes' is not already in visible icons + if (!state.settings.sidebarIcons.visible.includes('notes')) { + state.settings.sidebarIcons.visible = [...state.settings.sidebarIcons.visible, 'notes'] + } + } + return state + } catch (error) { + logger.error('migrate 141 error', error as Error) + return state + } + }, + '142': (state: RootState) => { + try { + // Initialize notes settings if not present + if (!state.note) { + state.note = notesInitialState + } + return state + } catch (error) { + logger.error('migrate 142 error', error as Error) + return state + } } } diff --git a/src/renderer/src/store/note.ts b/src/renderer/src/store/note.ts new file mode 100644 index 0000000000..4d481ed391 --- /dev/null +++ b/src/renderer/src/store/note.ts @@ -0,0 +1,59 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { RootState } from '@renderer/store/index' +import { EditorView } from '@renderer/types' + +export interface NotesSettings { + isFullWidth: boolean + fontFamily: 'default' | 'serif' + defaultViewMode: 'edit' | 'read' + defaultEditMode: Omit + showTabStatus: boolean +} + +export interface NoteState { + activeNodeId: string | undefined + activeFilePath: string | undefined // 使用文件路径而不是nodeId + settings: NotesSettings + notesPath: string +} + +export const initialState: NoteState = { + activeNodeId: undefined, + activeFilePath: undefined, + settings: { + isFullWidth: true, + fontFamily: 'default', + defaultViewMode: 'edit', + defaultEditMode: 'preview', + showTabStatus: true + }, + notesPath: '' +} + +const noteSlice = createSlice({ + name: 'note', + initialState, + reducers: { + setActiveNodeId: (state, action: PayloadAction) => { + state.activeNodeId = action.payload + }, + setActiveFilePath: (state, action: PayloadAction) => { + state.activeFilePath = action.payload + }, + updateNotesSettings: (state, action: PayloadAction>) => { + state.settings = { ...state.settings, ...action.payload } + }, + setNotesPath: (state, action: PayloadAction) => { + state.notesPath = action.payload + } + } +}) + +export const { setActiveNodeId, setActiveFilePath, updateNotesSettings, setNotesPath } = noteSlice.actions + +export const selectActiveNodeId = (state: RootState) => state.note.activeNodeId +export const selectActiveFilePath = (state: RootState) => state.note.activeFilePath +export const selectNotesSettings = (state: RootState) => state.note.settings +export const selectNotesPath = (state: RootState) => state.note.notesPath + +export default noteSlice.reducer diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 510d287187..446a69ec7f 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -183,6 +183,7 @@ export interface SettingsState { siyuan: boolean docx: boolean plain_text: boolean + notes: boolean } // OpenAI openAI: { @@ -212,6 +213,8 @@ export interface SettingsState { // API Server apiServer: ApiServerConfig showMessageOutline?: boolean + // Notes Related + showWorkspace: boolean } export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid' @@ -357,7 +360,8 @@ export const initialState: SettingsState = { obsidian: true, siyuan: true, docx: true, - plain_text: true + plain_text: true, + notes: true }, // OpenAI openAI: { @@ -389,6 +393,7 @@ export const initialState: SettingsState = { maxBackups: 0, skipBackupFile: false }, + // Developer mode enableDeveloperMode: false, // UI @@ -400,7 +405,9 @@ export const initialState: SettingsState = { port: 23333, apiKey: `cs-sk-${uuid()}` }, - showMessageOutline: undefined + showMessageOutline: undefined, + // Notes Related + showWorkspace: true } const settingsSlice = createSlice({ @@ -832,6 +839,12 @@ const settingsSlice = createSlice({ }, setShowMessageOutline: (state, action: PayloadAction) => { state.showMessageOutline = action.payload + }, + setShowWorkspace: (state, action: PayloadAction) => { + state.showWorkspace = action.payload + }, + toggleShowWorkspace: (state) => { + state.showWorkspace = !state.showWorkspace } } }) @@ -961,7 +974,9 @@ export const { // API Server actions setApiServerEnabled, setApiServerPort, - setApiServerApiKey + setApiServerApiKey, + setShowWorkspace, + toggleShowWorkspace } = settingsSlice.actions export default settingsSlice.reducer diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index aa96232a07..bbefd622f2 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -5,6 +5,7 @@ import type { CSSProperties } from 'react' import * as z from 'zod/v4' export * from './file' +export * from './note' import type { FileMetadata } from './file' import type { Message } from './newMessage' @@ -734,6 +735,7 @@ export type SidebarIcon = | 'knowledge' | 'files' | 'code_tools' + | 'notes' export type ExternalToolResult = { mcpTools?: MCPTool[] @@ -1183,6 +1185,8 @@ export interface MemoryListOptions extends MemoryEntity { } export interface MemoryDeleteAllOptions extends MemoryEntity {} + +export type EditorView = 'preview' | 'source' | 'read' // 实时,源码,预览 // ======================================================================== /** diff --git a/src/renderer/src/types/note.ts b/src/renderer/src/types/note.ts new file mode 100644 index 0000000000..fda85e63d8 --- /dev/null +++ b/src/renderer/src/types/note.ts @@ -0,0 +1,24 @@ +export type NotesSortType = + | 'sort_a2z' // 文件名(A-Z) + | 'sort_z2a' // 文件名(Z-A) + | 'sort_updated_desc' // 更新时间(从新到旧) + | 'sort_updated_asc' // 更新时间(从旧到新) + | 'sort_created_desc' // 创建时间(从新到旧) + | 'sort_created_asc' // 创建时间(从旧到新) + +/** + * @interface + * @description 笔记树节点接口 + */ +export interface NotesTreeNode { + id: string + name: string // 不包含扩展名 + type: 'folder' | 'file' + treePath: string // 相对路径 + externalPath: string // 绝对路径 + children?: NotesTreeNode[] + isStarred?: boolean + expanded?: boolean + createdAt: string + updatedAt: string +} diff --git a/src/renderer/src/utils/__tests__/markdownConverter.test.ts b/src/renderer/src/utils/__tests__/markdownConverter.test.ts new file mode 100644 index 0000000000..9f42f61830 --- /dev/null +++ b/src/renderer/src/utils/__tests__/markdownConverter.test.ts @@ -0,0 +1,442 @@ +import { describe, expect, it } from 'vitest' + +import { htmlToMarkdown, markdownToHtml, markdownToSafeHtml, sanitizeHtml } from '../markdownConverter' + +describe('markdownConverter', () => { + describe('htmlToMarkdown', () => { + it('should convert HTML to Markdown', () => { + const html = '

Hello World

' + const result = htmlToMarkdown(html) + expect(result).toBe('# Hello World') + }) + + it('should keep
to
', () => { + const html = '

Text with
\nindentation
\nand without indentation

' + const result = htmlToMarkdown(html) + expect(result).toBe('Text with
indentation
and without indentation') + }) + + it('should convert task list HTML back to Markdown', () => { + const html = + '
  • abcd
  • efgh
' + const result = htmlToMarkdown(html) + expect(result).toContain('- [ ] abcd') + expect(result).toContain('- [x] efgh') + }) + + it('should convert task list HTML back to Markdown with label', () => { + const html = + '
' + const result = htmlToMarkdown(html) + expect(result).toBe('- [ ] abcd\n\n- [x] efgh') + }) + + it('should handle empty HTML', () => { + const result = htmlToMarkdown('') + expect(result).toBe('') + }) + + it('should handle null/undefined input', () => { + expect(htmlToMarkdown(null as any)).toBe('') + expect(htmlToMarkdown(undefined as any)).toBe('') + }) + + it('should keep math block containers intact', () => { + const html = '
' + const result = htmlToMarkdown(html) + expect(result).toBe('$$a+b+c$$') + }) + + it('should convert multiple math blocks to Markdown', () => { + const html = + '
' + const result = htmlToMarkdown(html) + expect(result).toBe( + '$$\\begin{array}{c}\n\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} &\n= \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\nabla \\cdot \\vec{\\mathbf{E}} & = 4 \\pi \\rho \\\\\n\n\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} & = \\vec{\\mathbf{0}} \\\\\n\n\\nabla \\cdot \\vec{\\mathbf{B}} & = 0\n\n\\end{array}$$' + ) + }) + + it('should convert math inline syntax to Markdown', () => { + const html = '' + const result = htmlToMarkdown(html) + expect(result).toBe('$a+b+c$') + }) + + it('shoud convert multiple math blocks and inline math to Markdown', () => { + const html = + '

' + const result = htmlToMarkdown(html) + expect(result).toBe('$$a+b+c$$\n\n$d+e+f$') + }) + + it('should convert heading and img to Markdown', () => { + const html = '

Hello

\n

alt text

\n' + const result = htmlToMarkdown(html) + expect(result).toBe('# Hello\n\n![alt text](https://example.com/image.png)') + }) + + it('should convert heading and paragraph to Markdown', () => { + const html = '

Hello

\n

Hello

\n' + const result = htmlToMarkdown(html) + expect(result).toBe('# Hello\n\nHello') + }) + + it('should convert code block to Markdown', () => { + const html = '
console.log("Hello, world!");
' + const result = htmlToMarkdown(html) + expect(result).toBe('```\nconsole.log("Hello, world!");\n```') + }) + + it('should convert code block with language to Markdown', () => { + const html = '
console.log("Hello, world!");
' + const result = htmlToMarkdown(html) + expect(result).toBe('```javascript\nconsole.log("Hello, world!");\n```') + }) + + it('should convert table to Markdown', () => { + const html = + '

f

f

f

' + const result = htmlToMarkdown(html) + expect(result).toBe('| f | | |\n| --- | --- | --- |\n| | f | |\n| | | f |') + }) + }) + + describe('markdownToHtml', () => { + it('should convert
to
', () => { + const markdown = 'Text with
\nindentation
\nand without indentation' + const result = markdownToHtml(markdown) + expect(result).toBe('

Text with
\nindentation
\nand without indentation

\n') + }) + + it('should handle indentation in blockquotes', () => { + const markdown = '> Quote line 1\n> Quote line 2 with indentation' + const result = markdownToHtml(markdown) + // This should preserve indentation within the blockquote + expect(result).toContain('Quote line 1') + expect(result).toContain('Quote line 2 with indentation') + }) + + it('should preserve indentation in nested lists', () => { + const markdown = '- Item 1\n - Nested item\n - Double nested\n with continued line' + const result = markdownToHtml(markdown) + // Should create proper nested list structure + expect(result).toContain('
    ') + expect(result).toContain('
  • ') + }) + + it('should handle poetry or formatted text with indentation', () => { + const markdown = 'Roses are red\n Violets are blue\n Sugar is sweet\n And so are you' + const result = markdownToHtml(markdown) + expect(result).toBe('

    Roses are red\nViolets are blue\nSugar is sweet\nAnd so are you

    \n') + }) + + it('should preserve indentation after line breaks with multiple paragraphs', () => { + const markdown = 'First paragraph\n\n with indentation\n\n Second paragraph\n\nwith different indentation' + const result = markdownToHtml(markdown) + expect(result).toBe( + '

    First paragraph

    \n
    with indentation\n\nSecond paragraph\n

    with different indentation

    \n' + ) + }) + + it('should handle zero-width indentation (just line break)', () => { + const markdown = 'Hello\n\nWorld' + const result = markdownToHtml(markdown) + expect(result).toBe('

    Hello

    \n

    World

    \n') + }) + + it('should preserve indentation in mixed content', () => { + const markdown = + 'Normal text\n Indented continuation\n\n- List item\n List continuation\n\n> Quote\n> Indented quote' + const result = markdownToHtml(markdown) + expect(result).toBe( + '

    Normal text\nIndented continuation

    \n
      \n
    • List item\nList continuation
    • \n
    \n
    \n

    Quote\nIndented quote

    \n
    \n' + ) + }) + + it('should convert Markdown to HTML', () => { + const markdown = '# Hello World' + const result = markdownToHtml(markdown) + expect(result).toContain('

    Hello World

    ') + }) + + it('should convert math block syntax to HTML', () => { + const markdown = '$$a+b+c$$' + const result = markdownToHtml(markdown) + expect(result).toContain('
    ') + }) + + it('should convert math inline syntax to HTML', () => { + const markdown = '$a+b+c$' + const result = markdownToHtml(markdown) + expect(result).toContain('') + }) + + it('should convert multiple math blocks to HTML', () => { + const markdown = `$$\\begin{array}{c} +\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} & += \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\nabla \\cdot \\vec{\\mathbf{E}} & = 4 \\pi \\rho \\\\ + +\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} & = \\vec{\\mathbf{0}} \\\\ + +\\nabla \\cdot \\vec{\\mathbf{B}} & = 0 + +\\end{array}$$` + const result = markdownToHtml(markdown) + expect(result).toContain( + '
    ' + ) + }) + + it('should convert task list syntax to proper HTML', () => { + const markdown = '- [ ] abcd\n\n- [x] efgh\n\n' + const result = markdownToHtml(markdown) + expect(result).toContain('data-type="taskList"') + expect(result).toContain('data-type="taskItem"') + expect(result).toContain('data-checked="false"') + expect(result).toContain('data-checked="true"') + expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain('abcd') + expect(result).toContain('efgh') + }) + + it('should convert mixed task list with checked and unchecked items', () => { + const markdown = '- [ ] First task\n\n- [x] Second task\n\n- [ ] Third task' + const result = markdownToHtml(markdown) + expect(result).toContain('data-type="taskList"') + expect(result).toContain('First task') + expect(result).toContain('Second task') + expect(result).toContain('Third task') + expect(result.match(/data-checked="false"/g)).toHaveLength(2) + expect(result.match(/data-checked="true"/g)).toHaveLength(1) + }) + + it('should NOT convert standalone task syntax to task list', () => { + const markdown = '[x] abcd' + const result = markdownToHtml(markdown) + expect(result).toContain('

    [x] abcd

    ') + expect(result).not.toContain('data-type="taskList"') + }) + + it('should handle regular list items alongside task lists', () => { + const markdown = '- Regular item\n\n- [ ] Task item\n\n- Another regular item' + const result = markdownToHtml(markdown) + expect(result).toContain('data-type="taskList"') + expect(result).toContain('Regular item') + expect(result).toContain('Task item') + expect(result).toContain('Another regular item') + }) + + it('should handle empty Markdown', () => { + const result = markdownToHtml('') + expect(result).toBe('') + }) + + it('should handle null/undefined input', () => { + expect(markdownToHtml(null as any)).toBe('') + expect(markdownToHtml(undefined as any)).toBe('') + }) + + it('should handle heading and img', () => { + const markdown = `# 🌠 Screenshot + +![](https://example.com/image.png)` + const result = markdownToHtml(markdown) + expect(result).toBe('

    🌠 Screenshot

    \n

    \n') + }) + + it('should handle heading and paragraph', () => { + const markdown = '# Hello\n\nHello' + const result = markdownToHtml(markdown) + expect(result).toBe('

    Hello

    \n

    Hello

    \n') + }) + + it('should convert code block to HTML', () => { + const markdown = '```\nconsole.log("Hello, world!");\n```' + const result = markdownToHtml(markdown) + expect(result).toBe('
    console.log("Hello, world!");\n
    ') + }) + + it('should convert code block with language to HTML', () => { + const markdown = '```javascript\nconsole.log("Hello, world!");\n```' + const result = markdownToHtml(markdown) + expect(result).toBe( + '
    console.log("Hello, world!");\n
    ' + ) + }) + + it('should convert table to HTML', () => { + const markdown = '| f | | |\n| --- | --- | --- |\n| | f | |\n| | | f |' + const result = markdownToHtml(markdown) + expect(result).toBe( + '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
    f
    f
    f
    \n' + ) + }) + + it('should escape XML-like tags in code blocks', () => { + const markdown = '```jsx\nconst component = <>
    content
    \n```' + const result = markdownToHtml(markdown) + expect(result).toBe( + '
    const component = <><div>content</div></>\n
    ' + ) + }) + + it('should escape XML-like tags in inline code', () => { + const markdown = 'Use `<>` for fragments' + const result = markdownToHtml(markdown) + expect(result).toBe('

    Use <> for fragments

    \n') + }) + + it('shoud convert XML-like tags in paragraph', () => { + const markdown = '' + const result = markdownToHtml(markdown) + expect(result).toBe('

    \n') + }) + }) + + describe('sanitizeHtml', () => { + it('should sanitize HTML content and remove scripts', () => { + const html = '

    Hello

    ' + const result = sanitizeHtml(html) + expect(result).toContain('

    Hello

    ') + expect(result).not.toContain('