diff --git a/package.json b/package.json index fc22c32..f1ffe71 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,11 @@ "import": "./dist/timi-web.mjs", "require": "./dist/timi-web.umd.js" }, + "./editor": { + "types": "./dist/src/editor/index.d.ts", + "import": "./dist/timi-web.mjs", + "require": "./dist/timi-web.umd.js" + }, "./style.css": "./dist/timi-web.css" }, "engines": { @@ -93,7 +98,6 @@ "vitest": "^4.1.4", "vue": "^3.5.26", "vue-tsc": "^3.2.2", - "vue-eslint-parser": "^10.0.0", - "tdesign-vue-next": "^1.20.6" + "vue-eslint-parser": "^10.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b24b58..7bb64e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,9 +135,6 @@ importers: prettier: specifier: ^3.8.0 version: 3.8.0 - tdesign-vue-next: - specifier: ^1.20.6 - version: 1.20.6(vue@3.5.26(typescript@5.9.3)) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -506,9 +503,6 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@popperjs/core@2.11.8': - resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@rolldown/binding-android-arm64@1.0.0-rc.12': resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1009,12 +1003,6 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/lodash-es@4.17.12': - resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} - - '@types/lodash@4.17.25': - resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} - '@types/marked@6.0.0': resolution: {integrity: sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==} deprecated: This is a stub types definition. marked provides its own type definitions, so you do not need this installed. @@ -1028,15 +1016,6 @@ packages: '@types/prismjs@1.26.5': resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} - '@types/sortablejs@1.15.9': - resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} - - '@types/tinycolor2@1.4.6': - resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==} - - '@types/validator@13.15.10': - resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} - '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} @@ -1471,9 +1450,6 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - dayjs@1.11.23: - resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} - de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -2077,9 +2053,6 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash-es@4.18.1: - resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -2490,9 +2463,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - sortablejs@1.15.7: - resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==} - source-map-js@1.2.0: resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} engines: {node: '>=0.10.0'} @@ -2583,17 +2553,6 @@ packages: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} - tdesign-icons-vue-next@0.4.10: - resolution: {integrity: sha512-TOmy4cP5tXiLk5/rC6ZheRw+qeokqTytl4HLE+7qZYc185kPq659ATyn9a1i+c5ZSU3eHPY79kIaJUEOl7OoyQ==} - peerDependencies: - vue: ^3.0.0 - - tdesign-vue-next@1.20.6: - resolution: {integrity: sha512-k9pKaWyyCPv1RGO8YEqhmOoAkS/WZ+CbS0CLWGMtuIltS+363dCZ4ggKhmu/y7Zvnv6Wdzoy8R5+ZIsuwsdvkw==} - engines: {node: '>= 18'} - peerDependencies: - vue: '>=3.1.0' - terser@5.44.1: resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==} engines: {node: '>=10'} @@ -2602,9 +2561,6 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinycolor2@1.6.0: - resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} - tinyexec@1.1.1: resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} engines: {node: '>=18'} @@ -2703,10 +2659,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - validator@13.15.35: - resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} - engines: {node: '>= 0.10'} - vite-plugin-dts@4.5.4: resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==} peerDependencies: @@ -3319,8 +3271,6 @@ snapshots: '@pkgr/core@0.2.9': {} - '@popperjs/core@2.11.8': {} - '@rolldown/binding-android-arm64@1.0.0-rc.12': optional: true @@ -3727,12 +3677,6 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/lodash-es@4.17.12': - dependencies: - '@types/lodash': 4.17.25 - - '@types/lodash@4.17.25': {} - '@types/marked@6.0.0': dependencies: marked: 17.0.1 @@ -3745,12 +3689,6 @@ snapshots: '@types/prismjs@1.26.5': {} - '@types/sortablejs@1.15.9': {} - - '@types/tinycolor2@1.4.6': {} - - '@types/validator@13.15.10': {} - '@types/whatwg-mimetype@3.0.2': {} '@types/ws@8.18.1': @@ -4303,8 +4241,6 @@ snapshots: csstype@3.2.3: {} - dayjs@1.11.23: {} - de-indent@1.0.2: {} debug@4.3.4: @@ -4838,8 +4774,6 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash-es@4.18.1: {} - lodash.merge@4.6.2: {} lodash@4.17.21: {} @@ -5286,8 +5220,6 @@ snapshots: signal-exit@4.1.0: {} - sortablejs@1.15.7: {} - source-map-js@1.2.0: {} source-map-js@1.2.1: {} @@ -5363,28 +5295,6 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - tdesign-icons-vue-next@0.4.10(vue@3.5.26(typescript@5.9.3)): - dependencies: - '@babel/runtime': 7.29.7 - vue: 3.5.26(typescript@5.9.3) - - tdesign-vue-next@1.20.6(vue@3.5.26(typescript@5.9.3)): - dependencies: - '@babel/runtime': 7.29.7 - '@popperjs/core': 2.11.8 - '@types/lodash-es': 4.17.12 - '@types/sortablejs': 1.15.9 - '@types/tinycolor2': 1.4.6 - '@types/validator': 13.15.10 - dayjs: 1.11.23 - lodash-es: 4.18.1 - mitt: 3.0.1 - sortablejs: 1.15.7 - tdesign-icons-vue-next: 0.4.10(vue@3.5.26(typescript@5.9.3)) - tinycolor2: 1.6.0 - validator: 13.15.35 - vue: 3.5.26(typescript@5.9.3) - terser@5.44.1: dependencies: '@jridgewell/source-map': 0.3.6 @@ -5394,8 +5304,6 @@ snapshots: tinybench@2.9.0: {} - tinycolor2@1.6.0: {} - tinyexec@1.1.1: {} tinyglobby@0.2.15: @@ -5492,8 +5400,6 @@ snapshots: util-deprecate@1.0.2: {} - validator@13.15.35: {} - vite-plugin-dts@4.5.4(@types/node@25.0.8)(rollup@4.55.1)(typescript@5.9.3)(vite@8.0.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.0.8)(less@4.5.1)(terser@5.44.1)): dependencies: '@microsoft/api-extractor': 7.50.1(@types/node@25.0.8) diff --git a/src/components/article-editor/index.ts b/src/components/article-editor/index.ts deleted file mode 100644 index 8147987..0000000 --- a/src/components/article-editor/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import view from "./index.vue"; -import Toolkit from "../../utils/Toolkit"; -import type { Article } from "../../types"; - -export * from "./shared"; -export type ArticleEditorExpose = { - submit: () => Promise
; - getPendingMediaCount: () => number; -}; - -export const ArticleEditor = Toolkit.withInstall(view); -export default ArticleEditor; - diff --git a/src/components/article-editor/index.vue b/src/components/article-editor/index.vue deleted file mode 100644 index 4e47cad..0000000 --- a/src/components/article-editor/index.vue +++ /dev/null @@ -1,289 +0,0 @@ - - - diff --git a/src/components/article-view/index.ts b/src/components/article-view/index.ts deleted file mode 100644 index 3e31ab1..0000000 --- a/src/components/article-view/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import view from "./index.vue"; -import Toolkit from "../../utils/Toolkit"; - -export const ArticleView = Toolkit.withInstall(view); -export default ArticleView; - diff --git a/src/components/article-view/index.vue b/src/components/article-view/index.vue deleted file mode 100644 index ffb6e17..0000000 --- a/src/components/article-view/index.vue +++ /dev/null @@ -1,118 +0,0 @@ - - - - - diff --git a/src/components/code-editor/index.ts b/src/components/code-editor/index.ts deleted file mode 100644 index be39c3d..0000000 --- a/src/components/code-editor/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import view from "./index.vue"; -import Toolkit from "../../utils/Toolkit"; -import "./style.less"; - -export type CodeEditorScrollState = { - top: number; - left: number; -}; - -export type CodeEditorExpose = { - focus: () => void; - blur: () => void; - getScrollState: () => CodeEditorScrollState; - setScrollState: (state: Partial) => void; - findNext: () => boolean; - findPrevious: () => boolean; - replaceCurrent: () => boolean; - replaceAll: () => number; - openSearch: (showReplace: boolean) => void; - closeSearch: () => void; - textareaRef?: HTMLTextAreaElement; -}; - -export const CodeEditor = Toolkit.withInstall(view); -export default CodeEditor; - diff --git a/src/components/code-editor/index.vue b/src/components/code-editor/index.vue deleted file mode 100644 index e3649a9..0000000 --- a/src/components/code-editor/index.vue +++ /dev/null @@ -1,450 +0,0 @@ - - - - - diff --git a/src/components/code-editor/style.less b/src/components/code-editor/style.less deleted file mode 100644 index b9453f4..0000000 --- a/src/components/code-editor/style.less +++ /dev/null @@ -1,202 +0,0 @@ -.tui-code-editor { - width: calc(100% - 2px); - min-width: 0; - border: 1px solid var(--td-component-stroke, #B8BBC9); - display: flex; - overflow: hidden; - background: transparent; - border-radius: var(--td-radius-medium, 0); - - .gutter { - flex: none; - color: var(--td-text-color-placeholder, #999); - overflow: hidden; - text-align: right; - background: #F2F2F2E6; - border-right: 1px solid var(--td-component-stroke, #B8BBC9); - - > span { - width: 3.25rem; - height: 1.5rem; - display: block; - font-size: 1rem; - font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace; - line-height: 1.5rem; - padding-right: .25rem; - } - } - - .body { - flex: 1; - min-width: 0; - min-height: 0; - position: relative; - overflow: hidden; - } - - .search-bar { - top: .5rem; - right: .5rem; - gap: .5rem; - z-index: 3; - display: flex; - padding: .5rem; - position: absolute; - align-items: center; - border-radius: var(--td-radius-default, .375rem); - background: var(--td-bg-color-container, #fff); - border: 1px solid var(--td-component-stroke, #B8BBC9); - box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .12); - } - - .search-input { - width: 10rem; - height: 2rem; - padding: 0 .5rem; - color: var(--td-text-color-primary, #111); - border: 1px solid var(--td-component-stroke, #B8BBC9); - background: var(--td-bg-color-container, #fff); - border-radius: var(--td-radius-default, .375rem); - font: inherit; - } - - .search-actions { - gap: .25rem; - display: flex; - align-items: center; - } - - .search-button { - height: 2rem; - padding: 0 .625rem; - color: var(--td-text-color-primary, #111); - border: 1px solid var(--td-component-stroke, #B8BBC9); - background: var(--td-bg-color-container, #fff); - border-radius: var(--td-radius-default, .375rem); - cursor: pointer; - - &:hover { - background: var(--td-bg-color-container-hover, #f6f6f6); - } - } - - .search-status { - color: var(--td-text-color-secondary, #666); - white-space: nowrap; - font-size: .875rem; - } - - .preview, - .input { - inset: 0; - width: 100%; - height: 100%; - margin: 0; - tab-size: 4; - overflow: auto; - max-width: 100%; - min-width: 0; - line-height: 1.5rem; - font-size: 1rem; - box-sizing: border-box; - font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace; - } - - .preview { - top: 0; - left: 0; - border: none; - position: absolute; - word-break: normal; - background: transparent; - transition: none; - white-space: pre; - border-radius: 0; - pointer-events: none; - - code { - padding: 0; - display: block; - width: max-content; - min-width: 100%; - min-height: 100%; - color: inherit; - background: transparent; - text-shadow: none; - font-size: inherit; - line-height: inherit; - font-family: inherit; - } - } - - .input { - top: 0; - left: 0; - color: transparent; - resize: none; - border: none; - outline: none; - position: absolute; - background: transparent; - white-space: pre; - word-break: normal; - overflow-wrap: normal; - caret-color: var(--td-text-color-primary, #111); - -webkit-text-fill-color: transparent; - } - - .input::placeholder { - color: var(--td-text-color-placeholder, #999); - -webkit-text-fill-color: var(--td-text-color-placeholder, #999); - } - - .input::selection { - background: rgb(0 122 255 / 18%); - } - - &.disabled { - opacity: .65; - } - - &.readonly { - .preview { - pointer-events: auto; - } - } -} - -@media (prefers-color-scheme: dark) { - .tui-code-editor { - border-color: var(--td-component-stroke, #434343); - - .gutter { - border-right-color: var(--td-component-stroke, #434343); - } - - .search-bar { - background: var(--td-bg-color-container, #1f1f1f); - border-color: var(--td-component-stroke, #434343); - box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .28); - } - - .search-input, - .search-button { - color: var(--td-text-color-primary, #F5F5F5); - border-color: var(--td-component-stroke, #434343); - background: var(--td-bg-color-container, #1f1f1f); - } - - .search-button:hover { - background: var(--td-bg-color-container-hover, #2b2b2b); - } - - .input { - caret-color: var(--td-text-color-primary, #F5F5F5); - } - - .input::selection { - background: rgb(64 153 255 / 28%); - } - } -} - diff --git a/src/components/editor-search/index.vue b/src/components/editor-search/index.vue deleted file mode 100644 index fb71e40..0000000 --- a/src/components/editor-search/index.vue +++ /dev/null @@ -1,271 +0,0 @@ - - - - - diff --git a/src/components/index.ts b/src/components/index.ts index a6ef392..e3ceda5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -11,13 +11,6 @@ import BEFlowerFall from "./background-effect/flower-fall"; import MarkdownEditor from "./markdown-editor"; import PassTimeLabel from "./passtime-label"; import ContactNumber from "./contact-number"; -import CodeEditor from "./code-editor"; -import TiptapEditor from "./tiptap-editor"; -import TiptapView from "./tiptap-view"; -import ArticleEditor from "./article-editor"; -import ArticleView from "./article-view"; -import NotifyEditor from "./notify-editor"; -import NotifyView from "./notify-view"; export default [ Icon, @@ -31,14 +24,7 @@ export default [ BEFlowerFall, MarkdownEditor, PassTimeLabel, - ContactNumber, - CodeEditor, - TiptapEditor, - TiptapView, - ArticleEditor, - ArticleView, - NotifyEditor, - NotifyView + ContactNumber ]; export { @@ -53,31 +39,5 @@ export { BEFlowerFall, MarkdownEditor, PassTimeLabel, - ContactNumber, - CodeEditor, - TiptapEditor, - TiptapView, - ArticleEditor, - ArticleView, - NotifyEditor, - NotifyView + ContactNumber }; - -export type { - CodeEditorExpose -} from "./code-editor"; - -export type { - UploadResult, - AlignType, - TiptapEditorExpose, - TiptapEditorInstance -} from "./tiptap-editor"; - -export type { - ArticleEditorExpose -} from "./article-editor"; - -export type { - NotifyEditorExpose -} from "./notify-editor"; diff --git a/src/components/markdown-view/style.less b/src/components/markdown-view/style.less index 76b8199..6d5b8a5 100644 --- a/src/components/markdown-view/style.less +++ b/src/components/markdown-view/style.less @@ -262,7 +262,7 @@ background: transparent; max-height: var(--data-max-height); transition: max-height .5s var(--tui-bezier); - font-family: var(--td-font); + font-family: var(--tui-font); line-height: 1; border-radius: 0; @@ -270,7 +270,7 @@ color: #333; background: transparent; text-shadow: none !important; - font-family: var(--td-font); + font-family: var(--tui-font); .line-numbers-rows { left: 0; diff --git a/src/components/notify-editor/index.ts b/src/components/notify-editor/index.ts deleted file mode 100644 index 0936295..0000000 --- a/src/components/notify-editor/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import view from "./index.vue"; -import Toolkit from "../../utils/Toolkit"; -import type { NotifyDetail } from "../../types"; - -export type NotifyEditorExpose = { - submit: () => Promise; - getPendingMediaCount: () => number; -}; - -export const NotifyEditor = Toolkit.withInstall(view); -export default NotifyEditor; diff --git a/src/components/notify-editor/index.vue b/src/components/notify-editor/index.vue deleted file mode 100644 index 48b7783..0000000 --- a/src/components/notify-editor/index.vue +++ /dev/null @@ -1,77 +0,0 @@ - - - diff --git a/src/components/notify-view/index.ts b/src/components/notify-view/index.ts deleted file mode 100644 index fb18a45..0000000 --- a/src/components/notify-view/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import view from "./index.vue"; -import Toolkit from "../../utils/Toolkit"; - -export const NotifyView = Toolkit.withInstall(view); -export default NotifyView; diff --git a/src/components/notify-view/index.vue b/src/components/notify-view/index.vue deleted file mode 100644 index 01f5a6a..0000000 --- a/src/components/notify-view/index.vue +++ /dev/null @@ -1,32 +0,0 @@ - - - diff --git a/src/components/prism-code-block.less b/src/components/prism-code-block.less index 5c29f81..944793d 100644 --- a/src/components/prism-code-block.less +++ b/src/components/prism-code-block.less @@ -11,16 +11,16 @@ img { height: auto; - border-radius: var(--td-radius-default, .375rem); + border-radius: var(--tui-radius-default, .375rem); } video, iframe.tp-iframe { width: min(100%, 56rem); overflow: hidden; - border-radius: var(--td-radius-default, .375rem); - background: var(--td-bg-color-container, #fff); - border: 1px solid var(--td-component-border, #dcdcdc); + border-radius: var(--tui-radius-default, .375rem); + background: var(--tui-bg-color-container, #fff); + border: 1px solid var(--tui-component-border, #dcdcdc); } iframe.tp-iframe { @@ -38,20 +38,20 @@ td { padding: .5rem .75rem; vertical-align: middle; - border: 1px solid var(--td-component-border, #dcdcdc); + border: 1px solid var(--tui-component-border, #dcdcdc); } th { - background: var(--td-bg-color-container-hover, #f6f6f6); + background: var(--tui-bg-color-container-hover, #f6f6f6); } pre[class*="language-"] { padding: 0; - border: 1px solid var(--td-component-stroke, #B8BBC9); + border: 1px solid var(--tui-component-stroke, #B8BBC9); overflow: auto; background: transparent; transition: max-height .5s ease; - border-radius: var(--td-radius-medium, 0); + border-radius: var(--tui-radius-medium, 0); code { color: #333; @@ -71,11 +71,11 @@ display: block; text-align: right; background: #F2F2F2E6; - border-right: 1px solid var(--td-component-stroke, #B8BBC9); + border-right: 1px solid var(--tui-component-stroke, #B8BBC9); padding-right: .25rem; > span { - color: var(--td-text-color-placeholder, #999); + color: var(--tui-text-color-placeholder, #999); display: block; } @@ -178,4 +178,3 @@ } } - diff --git a/src/components/tiptap-content.less b/src/components/tiptap-content.less deleted file mode 100644 index 73ffa63..0000000 --- a/src/components/tiptap-content.less +++ /dev/null @@ -1,46 +0,0 @@ -.tp-content-theme() { - color: var(--td-text-color-primary); - line-height: 1.6; - - p { - margin: 0 0 .75rem; - } - - p:empty:before { - content: "\00a0"; - } - - ul, - ol { - margin: 0 0 .75rem; - padding-left: 1.25rem; - } - - table { - width: 100%; - margin: .75rem 0; - table-layout: fixed; - border-collapse: collapse; - } - - th, - td { - border: 1px solid var(--td-component-border); - } - - th { - background: var(--td-bg-color-secondarycontainer); - } - - img, - video, - iframe { - margin: 0 auto; - max-width: 100%; - } -} - -.tp-content-body { - .tp-content-theme(); -} - diff --git a/src/components/tiptap-editor/TiptapCodeBlockButton.vue b/src/components/tiptap-editor/TiptapCodeBlockButton.vue deleted file mode 100644 index a72fb61..0000000 --- a/src/components/tiptap-editor/TiptapCodeBlockButton.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - diff --git a/src/components/tiptap-editor/TiptapIframeButton.vue b/src/components/tiptap-editor/TiptapIframeButton.vue deleted file mode 100644 index 9da1636..0000000 --- a/src/components/tiptap-editor/TiptapIframeButton.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - - - diff --git a/src/components/tiptap-editor/TiptapLinkButton.vue b/src/components/tiptap-editor/TiptapLinkButton.vue deleted file mode 100644 index 365e151..0000000 --- a/src/components/tiptap-editor/TiptapLinkButton.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - - - diff --git a/src/components/tiptap-editor/TiptapMediaButton.vue b/src/components/tiptap-editor/TiptapMediaButton.vue deleted file mode 100644 index b96c0db..0000000 --- a/src/components/tiptap-editor/TiptapMediaButton.vue +++ /dev/null @@ -1,184 +0,0 @@ - - - - - diff --git a/src/components/tiptap-editor/TiptapPaddingButton.vue b/src/components/tiptap-editor/TiptapPaddingButton.vue deleted file mode 100644 index de6e7a9..0000000 --- a/src/components/tiptap-editor/TiptapPaddingButton.vue +++ /dev/null @@ -1,99 +0,0 @@ - - - - - diff --git a/src/components/tiptap-editor/TiptapTableButton.vue b/src/components/tiptap-editor/TiptapTableButton.vue deleted file mode 100644 index d2d2e0b..0000000 --- a/src/components/tiptap-editor/TiptapTableButton.vue +++ /dev/null @@ -1,126 +0,0 @@ - - - - - diff --git a/src/components/tiptap-editor/TiptapToolbar.vue b/src/components/tiptap-editor/TiptapToolbar.vue deleted file mode 100644 index 3677e00..0000000 --- a/src/components/tiptap-editor/TiptapToolbar.vue +++ /dev/null @@ -1,431 +0,0 @@ - - - - - - diff --git a/src/components/tiptap-editor/index.ts b/src/components/tiptap-editor/index.ts deleted file mode 100644 index 0f22e77..0000000 --- a/src/components/tiptap-editor/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import view from "./index.vue"; -import Toolkit from "../../utils/Toolkit"; -import type { TiptapEditorInstance } from "./useTiptapEditor"; - -export * from "./shared"; -export type { TiptapEditorInstance } from "./useTiptapEditor"; -export type TiptapEditorExpose = { - submit: () => Promise; - getPendingMediaCount: () => number; - editor?: TiptapEditorInstance; -}; - -export const TiptapEditor = Toolkit.withInstall(view); -export default TiptapEditor; - diff --git a/src/components/tiptap-editor/index.vue b/src/components/tiptap-editor/index.vue deleted file mode 100644 index 0ee8e77..0000000 --- a/src/components/tiptap-editor/index.vue +++ /dev/null @@ -1,617 +0,0 @@ - - - - - - - - diff --git a/src/components/tiptap-view/index.ts b/src/components/tiptap-view/index.ts deleted file mode 100644 index 1c26213..0000000 --- a/src/components/tiptap-view/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import view from "./index.vue"; -import Toolkit from "../../utils/Toolkit"; - -export const TiptapView = Toolkit.withInstall(view); -export default TiptapView; - diff --git a/src/components/tiptap-view/index.vue b/src/components/tiptap-view/index.vue deleted file mode 100644 index 3a64c27..0000000 --- a/src/components/tiptap-view/index.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - diff --git a/src/components/article-editor/shared.ts b/src/editor/article.ts similarity index 77% rename from src/components/article-editor/shared.ts rename to src/editor/article.ts index a5cb84f..cb1f026 100644 --- a/src/components/article-editor/shared.ts +++ b/src/editor/article.ts @@ -1,26 +1,30 @@ -import type { ArticleContentType, Attachment } from "../../types"; -import { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR, isLocalMediaId, normalizeContent } from "../tiptap-editor/shared"; +/** + * 文章编辑器的公共数据处理。 + * 负责统一内容类型、附件标识和存储前的富文本格式,平台层不参与这些规则。 + */ +import type { ArticleContentType, Attachment } from "../types"; +import { ATTACHMENT_ID_ATTR, isLocalMediaId, normalizeContent, TEMP_FILE_ID_ATTR } from "./tiptap/shared"; +/** 文章内容中附件和临时文件的标识前缀。 */ export const ATTACHMENT_ID_PREFIX = "attachment-id:"; export const TEMP_FILE_ID_PREFIX = "temp-file-id:"; export const ARTICLE_IMAGE_ATTACH_TYPE = "CONTENT_IMAGE"; export const ARTICLE_VIDEO_ATTACH_TYPE = "CONTENT_VIDEO"; +/** 支持的文章内容类型和默认类型。 */ export const DEFAULT_ARTICLE_CONTENT_TYPE: ArticleContentType = "TIP_TAP"; export const ARTICLE_CONTENT_TYPE_LIST: ArticleContentType[] = ["TEXT", "MARKDOWN", "TIP_TAP"]; -export { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR, normalizeContent }; - +/** 将后端历史值和未知值归一化为当前支持的内容类型。 */ export function normalizeArticleContentType(contentType?: string): ArticleContentType { - if (contentType === "TIPTAP") { - return "TIP_TAP"; - } return ARTICLE_CONTENT_TYPE_LIST.includes(contentType as ArticleContentType) ? contentType as ArticleContentType : DEFAULT_ARTICLE_CONTENT_TYPE; } +/** 将空内容转换为空字符串,避免编辑器收到 undefined。 */ export function normalizeRawArticleContent(content?: string) { return content || ""; } +/** 从媒体 src 中读取持久化附件 ID。 */ export function getAttachmentIdFromSrc(src?: string) { if (!src?.startsWith(ATTACHMENT_ID_PREFIX)) { return ""; @@ -28,6 +32,7 @@ export function getAttachmentIdFromSrc(src?: string) { return src.slice(ATTACHMENT_ID_PREFIX.length); } +/** 从媒体 src 中读取临时文件 ID。 */ export function getTempFileIdFromSrc(src?: string) { if (!src?.startsWith(TEMP_FILE_ID_PREFIX)) { return ""; @@ -35,14 +40,17 @@ export function getTempFileIdFromSrc(src?: string) { return src.slice(TEMP_FILE_ID_PREFIX.length); } +/** 构造带有附件 ID 的内部媒体地址。 */ export function buildAttachmentIdSrc(attachmentId: string) { return `${ATTACHMENT_ID_PREFIX}${attachmentId}`; } +/** 根据媒体标签判断附件类型。 */ function getMediaAttachType(tagName: string) { return tagName.toLowerCase() === "video" ? ARTICLE_VIDEO_ATTACH_TYPE : ARTICLE_IMAGE_ATTACH_TYPE; } +/** 仅为 blob 预览地址保留预览元数据,服务器地址不重复保存。 */ function buildAttachmentMetadata(src: string) { if (!src.startsWith("blob:")) { return undefined; @@ -52,6 +60,7 @@ function buildAttachmentMetadata(src: string) { }; } +/** 过滤本地临时媒体 ID,避免把前端占位 ID 当成可上传附件。 */ function normalizeAttachmentTempFileId(tempFileId?: string) { if (!tempFileId || isLocalMediaId(tempFileId)) { return undefined; @@ -59,6 +68,7 @@ function normalizeAttachmentTempFileId(tempFileId?: string) { return tempFileId; } +/** 从文章 HTML 中提取图片和视频附件,并按 ID 去重。 */ export function buildAttachmentListFromContent(content?: string) { const rawContent = normalizeContent(content); if (!rawContent) { @@ -89,6 +99,7 @@ export function buildAttachmentListFromContent(content?: string) { return Array.from(attachmentMap.values()); } +/** 将媒体地址统一改写为附件地址,供后端保存文章内容。 */ export function normalizeContentForStore(content?: string) { const rawContent = normalizeContent(content); if (!rawContent) { @@ -112,6 +123,7 @@ export function normalizeContentForStore(content?: string) { return normalizeContent(doc.body.innerHTML); } +/** 比较附件列表的业务字段,不比较对象引用。 */ export function sameAttachmentList(left?: Attachment[], right?: Attachment[]) { const leftList = left || []; const rightList = right || []; diff --git a/src/components/code-editor/input.ts b/src/editor/code.ts similarity index 82% rename from src/components/code-editor/input.ts rename to src/editor/code.ts index e06b97e..f962ee9 100644 --- a/src/components/code-editor/input.ts +++ b/src/editor/code.ts @@ -1,4 +1,11 @@ -type EditorSelection = { +/** 代码编辑器的公共能力,包括键盘输入处理和 Prism 高亮。 */ +import Prism from "prismjs"; +import { resolvePrismLanguage } from "../utils/Prism"; + +export { resolvePrismLanguage } from "../utils/Prism"; + +/** 编辑器文本选区,使用字符串下标表示半开区间。 */ +export type EditorSelection = { start: number; end: number; }; @@ -20,6 +27,7 @@ const WRAP_CHAR_MAP: Record = { }; const CLOSE_CHAR_LIST = new Set(Object.values(WRAP_CHAR_MAP)); +/** 根据键盘事件计算代码编辑器应执行的文本变更。 */ export const resolveCodeEditorKeydown = ( value: string, selection: EditorSelection, @@ -40,7 +48,16 @@ export const resolveCodeEditorKeydown = ( return resolvePairInput(value, selection, event.key); }; -// 处理 Tab / Shift+Tab 缩进 +/** 使用 Prism 高亮代码;未识别语言时返回已转义的纯文本。 */ +export function highlightCode(value: string, language?: string | null) { + const code = value || " "; + const resolvedLanguage = resolvePrismLanguage(language); + if (!resolvedLanguage) { + return escapeHTML(code); + } + return Prism.highlight(code, Prism.languages[resolvedLanguage], resolvedLanguage); +} + const resolveTabInput = ( value: string, selection: EditorSelection, @@ -89,7 +106,6 @@ const resolveTabInput = ( }; }; -// 回车时继承当前行缩进,位于块级起始括号后时额外增加一层缩进 const resolveEnterInput = ( value: string, selection: EditorSelection @@ -129,7 +145,6 @@ const resolveEnterInput = ( }; }; -// 处理成对符号输入,选中文本时直接包裹 const resolvePairInput = ( value: string, selection: EditorSelection, @@ -151,7 +166,6 @@ const resolvePairInput = ( return undefined; }; -// 光标位于成对符号中间时,退格一次同时删除左右字符 const resolvePairBackspace = ( value: string, selection: EditorSelection @@ -174,7 +188,6 @@ const resolvePairBackspace = ( }; }; -// 将当前选区替换为一对包裹字符,并把光标移动到合理位置 const wrapSelection = ( value: string, selection: EditorSelection, @@ -206,3 +219,11 @@ const getLineIndent = (value: string) => { return match?.[0] ?? ""; }; +function escapeHTML(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} diff --git a/src/editor/index.ts b/src/editor/index.ts new file mode 100644 index 0000000..514dcfb --- /dev/null +++ b/src/editor/index.ts @@ -0,0 +1,15 @@ +/** 编辑器公共能力出口。平台组件只依赖这里的无界面逻辑和类型。 */ +export * from "./article"; +export * from "./code"; +export * from "./notify"; +export * from "./search"; +export * from "./tiptap/shared"; +export * from "./tiptap/extensions"; +export * from "./tiptap/useTiptapEditor"; +export * from "./tiptap/useTiptapView"; +export type { Article, ArticleContentType } from "../types/Article"; +export type { Attachment } from "../types/Attachment"; +export type { NotifyDetail } from "../types/Notify"; +export { default as AttachmentAPI } from "../api/AttachmentAPI"; +export { default as PrismCodeBlock } from "./tiptap/PrismCodeBlock"; +export { enhancePrismCodeBlock } from "./tiptap/PrismCodeBlockView"; diff --git a/src/components/notify/shared.ts b/src/editor/notify.ts similarity index 62% rename from src/components/notify/shared.ts rename to src/editor/notify.ts index f93726d..52f7b89 100644 --- a/src/components/notify/shared.ts +++ b/src/editor/notify.ts @@ -1,8 +1,10 @@ -import type { NotifyContentType } from "../../types"; +/** 通知编辑器的公共数据规则。 */ +import type { NotifyContentType } from "../types"; +/** 通知缺少内容类型时默认使用 Tiptap。 */ export const DEFAULT_NOTIFY_CONTENT_TYPE: NotifyContentType = "TIP_TAP"; -/** 规范化通知内容类型,兼容旧通知未返回内容类型的情况 */ +/** 规范化通知内容类型,兼容旧通知未返回内容类型的情况。 */ export function normalizeNotifyContentType(contentType?: string): NotifyContentType { return contentType === "TEXT" ? "TEXT" : DEFAULT_NOTIFY_CONTENT_TYPE; } diff --git a/src/components/editor-search/search.ts b/src/editor/search.ts similarity index 82% rename from src/components/editor-search/search.ts rename to src/editor/search.ts index 2ae43bc..c58faaf 100644 --- a/src/components/editor-search/search.ts +++ b/src/editor/search.ts @@ -1,19 +1,23 @@ +/** 编辑器文本搜索、匹配和替换的无界面实现。 */ export type TextSearchOptions = { caseSensitive: boolean; useRegex: boolean; }; +/** 单个匹配项在原文本中的半开区间。 */ export type TextSearchMatch = { start: number; end: number; text: string; }; +/** 搜索结果;error 为空表示搜索过程正常。 */ export type TextSearchResult = { matches: TextSearchMatch[]; error: string; }; +/** 根据搜索选项构造正则标志。 */ const buildFlags = (options: TextSearchOptions, global = true) => { let flags = global ? "g" : ""; if (!options.caseSensitive) { @@ -22,10 +26,12 @@ const buildFlags = (options: TextSearchOptions, global = true) => { return flags; }; +/** 转义普通文本搜索中的正则特殊字符。 */ export const escapeSearchKeyword = (value: string) => ( value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") ); +/** 构造搜索正则,并拒绝无效或可匹配空文本的表达式。 */ export const buildSearchRegex = ( keyword: string, options: TextSearchOptions, @@ -53,6 +59,7 @@ export const buildSearchRegex = ( } }; +/** 查找全部匹配项并返回文本位置。 */ export const findTextMatches = ( value: string, keyword: string, @@ -87,6 +94,7 @@ export const findTextMatches = ( }; }; +/** 替换单个匹配项;正则模式下使用捕获组替换规则。 */ export const replaceMatchText = ( matchText: string, keyword: string, @@ -103,6 +111,7 @@ export const replaceMatchText = ( return matchText.replace(regex, replaceText); }; +/** 替换全部匹配项,并返回实际替换数量。 */ export const replaceAllText = ( value: string, keyword: string, @@ -131,4 +140,3 @@ export const replaceAllText = ( error: "" }; }; - diff --git a/src/components/tiptap-editor/PrismCodeBlock.ts b/src/editor/tiptap/PrismCodeBlock.ts similarity index 86% rename from src/components/tiptap-editor/PrismCodeBlock.ts rename to src/editor/tiptap/PrismCodeBlock.ts index 571a860..6c497e6 100644 --- a/src/components/tiptap-editor/PrismCodeBlock.ts +++ b/src/editor/tiptap/PrismCodeBlock.ts @@ -1,3 +1,7 @@ +/** + * 带 Prism 装饰的 Tiptap 代码块。 + * ProseMirror 文档只保存纯文本,语法高亮通过 Decoration 叠加,避免污染编辑内容。 + */ import CodeBlock from "@tiptap/extension-code-block"; import { findChildren, mergeAttributes } from "@tiptap/vue-3"; import type { Node as ProsemirrorNode } from "@tiptap/pm/model"; @@ -7,22 +11,26 @@ import { Decoration, DecorationSet } from "@tiptap/pm/view"; import Prism from "prismjs"; import { resolvePrismLanguage } from "../../utils/Prism"; +/** Prism 分词后用于生成 Decoration 的扁平节点。 */ type HighlightNode = { text: string; classes: string[]; }; +/** Prism Token 的递归结构。 */ type PrismToken = { type: string; alias?: string | string[]; content: string | PrismToken | Array; }; +/** 用于判断事务影响范围的最小结构。 */ type StepRange = { from?: number; to?: number; }; +/** 将语言名称转换为代码块 class 名。 */ function buildLanguageClassName(languageClassPrefix: string | null | undefined, language?: string | null) { if (!languageClassPrefix || !language) { return null; @@ -30,11 +38,13 @@ function buildLanguageClassName(languageClassPrefix: string | null | undefined, return `${languageClassPrefix}${language}`; } +/** 生成 Prism token 对应的 class 列表。 */ function buildTokenClassList(type: string, alias?: string | string[]) { const aliasList = Array.isArray(alias) ? alias : alias ? [alias] : []; return ["token", type, ...aliasList]; } +/** 将嵌套 Prism token 展平成文本片段,便于生成行内装饰。 */ function parsePrismTokens(tokens: Array, className: string[] = []): HighlightNode[] { return tokens.flatMap((token) => { if (typeof token === "string") { @@ -58,6 +68,7 @@ function parsePrismTokens(tokens: Array, className: string[ }); } +/** 为文档中的全部代码块生成语法高亮装饰。 */ function getDecorations({ doc, name, @@ -89,6 +100,7 @@ function getDecorations({ return DecorationSet.create(doc, decorations); } +/** 创建只在代码块内容或结构受影响时重新计算高亮的插件。 */ function createPrismPlugin({ name, defaultLanguage @@ -143,6 +155,7 @@ function createPrismPlugin({ return prismPlugin; } +/** 自定义代码块渲染和 Prism 装饰插件。 */ export default CodeBlock.extend({ renderHTML({ node, HTMLAttributes }: { node: ProsemirrorNode; HTMLAttributes: Record }) { const languageClassName = buildLanguageClassName(this.options.languageClassPrefix, node.attrs.language); @@ -171,4 +184,3 @@ export default CodeBlock.extend({ ]; } }); - diff --git a/src/utils/PrismCodeBlock.ts b/src/editor/tiptap/PrismCodeBlockView.ts similarity index 86% rename from src/utils/PrismCodeBlock.ts rename to src/editor/tiptap/PrismCodeBlockView.ts index 32f8ae1..37a83fb 100644 --- a/src/utils/PrismCodeBlock.ts +++ b/src/editor/tiptap/PrismCodeBlockView.ts @@ -1,3 +1,4 @@ +/** 为只读代码块补充行号、折叠和展开交互。 */ export function enhancePrismCodeBlock(root: ParentNode) { const codeList = Array.from(root.querySelectorAll("pre > code[class*='language-'], pre[class*='language-'] > code")); @@ -35,6 +36,7 @@ export function enhancePrismCodeBlock(root: ParentNode) { } } +/** 将代码节点上的语言 class 同步到外层 pre。 */ function syncPreLanguageClass(pre: HTMLElement, code: HTMLElement) { const classList = Array.from(code.classList).filter((item) => item.startsWith("language-")); if (!classList.length) { @@ -47,6 +49,7 @@ function syncPreLanguageClass(pre: HTMLElement, code: HTMLElement) { } } +/** 清除上一次增强生成的行号节点,允许重复增强。 */ function resetCodeBlock(code: HTMLElement) { code.parentElement?.classList.remove("has-line-numbers"); const codes = code.querySelector(":scope > .codes"); @@ -55,6 +58,7 @@ function resetCodeBlock(code: HTMLElement) { } } +/** 去除 Tiptap 代码块首尾的格式化换行。 */ function normalizeCodeContent(code: HTMLElement) { const firstNode = code.firstChild; if (firstNode instanceof Text && firstNode.data.startsWith("\n")) { @@ -73,6 +77,7 @@ function normalizeCodeContent(code: HTMLElement) { } } +/** 从最近的 data-max-height 容器读取代码块最大高度。 */ function resolveCodeBlockMaxHeight(pre: HTMLElement) { const container = pre.closest("[data-max-height]"); if (!(container instanceof HTMLElement)) { @@ -81,6 +86,7 @@ function resolveCodeBlockMaxHeight(pre: HTMLElement) { return container.dataset.maxHeight || ""; } +/** 计算代码块行数,空代码块至少显示一行。 */ function buildLineCount(text: string) { if (!text) { return 1; @@ -88,6 +94,7 @@ function buildLineCount(text: string) { return Math.max(text.split("\n").length, 1); } +/** 创建与代码行数对应的行号 DOM。 */ function buildLineNumberRows(lineCount: number) { const rows = document.createElement("span"); rows.className = "line-numbers-rows diselect"; @@ -99,6 +106,7 @@ function buildLineNumberRows(lineCount: number) { return rows; } +/** 为超过阈值的代码块绑定双击展开和恢复行为。 */ function bindCodeBlockToggle(pre: HTMLElement, code: HTMLElement, lineCount: number, maxHeight: string) { pre.classList.remove("expand"); pre.dataset.lineCount = `${lineCount}`; @@ -120,4 +128,3 @@ function bindCodeBlockToggle(pre: HTMLElement, code: HTMLElement, lineCount: num window.getSelection()?.removeAllRanges(); }; } - diff --git a/src/components/tiptap-editor/extensions.ts b/src/editor/tiptap/extensions.ts similarity index 88% rename from src/components/tiptap-editor/extensions.ts rename to src/editor/tiptap/extensions.ts index 0ebd371..3e19237 100644 --- a/src/components/tiptap-editor/extensions.ts +++ b/src/editor/tiptap/extensions.ts @@ -1,3 +1,7 @@ +/** + * Tiptap 扩展集合。 + * 图片、视频和 iframe 在这里统一定义尺寸及附件属性,保证编辑和只读视图使用同一套文档模型。 + */ import { mergeAttributes, Node } from "@tiptap/vue-3"; import Color from "@tiptap/extension-color"; import Image from "@tiptap/extension-image"; @@ -17,10 +21,12 @@ import StarterKit from "@tiptap/starter-kit"; import PrismCodeBlock from "./PrismCodeBlock"; import { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR } from "./shared"; +/** 拼接非空样式声明。 */ function buildStyleString(styleList: Array) { return styleList.filter(Boolean).join("; "); } +/** 创建图片、视频和 iframe 共用的尺寸属性。 */ function buildNodeSizeAttributes(defaultHeight = "auto") { return { width: { @@ -34,6 +40,7 @@ function buildNodeSizeAttributes(defaultHeight = "auto") { }; } +/** 将节点原有样式与尺寸属性合并为 style 字符串。 */ function buildNodeSizeStyle(attributes: Record) { return buildStyleString([ attributes.style, @@ -42,6 +49,7 @@ function buildNodeSizeStyle(attributes: Record) { ]); } +/** 扩展文字样式,补充背景色属性。 */ const ExtTextStyle = TextStyle.extend({ addGlobalAttributes() { return [ @@ -67,6 +75,7 @@ const ExtTextStyle = TextStyle.extend({ } }); +/** 扩展图片节点,支持 base64、尺寸和附件属性。 */ const ExtImage = Image.configure({ allowBase64: true }).extend({ @@ -111,6 +120,7 @@ const ExtImage = Image.configure({ } }); +/** 扩展视频节点,作为可拖拽的原子块保存。 */ const ExtVideo = Node.create({ name: "video", group: "block", @@ -176,6 +186,7 @@ const ExtVideo = Node.create({ } }); +/** 扩展 iframe 节点,限制为块级原子节点并保存尺寸。 */ const ExtIframe = Node.create({ name: "iframe", group: "block", @@ -221,10 +232,13 @@ const ExtIframe = Node.create({ } }); +/** 创建编辑器和只读视图共用的 Tiptap 扩展。 */ export function buildTiptapExtensions(placeholder?: string): Extensions { return [ StarterKit.configure({ - codeBlock: false + codeBlock: false, + link: false, + underline: false }), PrismCodeBlock.configure({ languageClassPrefix: "language-" @@ -256,4 +270,3 @@ export function buildTiptapExtensions(placeholder?: string): Extensions { }) ]; } - diff --git a/src/components/tiptap-editor/shared.ts b/src/editor/tiptap/shared.ts similarity index 77% rename from src/components/tiptap-editor/shared.ts rename to src/editor/tiptap/shared.ts index 75967b8..f674611 100644 --- a/src/components/tiptap-editor/shared.ts +++ b/src/editor/tiptap/shared.ts @@ -1,11 +1,14 @@ +/** Tiptap 编辑器共享类型、常量和数据归一化方法。 */ import { h } from "vue"; +/** 上传接口返回的媒体信息。 */ export type UploadResult = { id: string; url: string; name?: string; }; +/** 编辑器待上传媒体的本地状态。 */ export type UploadMediaType = "image" | "video"; export type PendingMediaItem = { id: string; @@ -15,13 +18,16 @@ export type PendingMediaItem = { url: string; }; +/** 文本对齐方式。 */ export type AlignType = "left" | "center" | "right" | "justify"; +/** 富文本媒体节点使用的附件属性名。 */ export const ATTACHMENT_ID_ATTR = "data-attachment-id"; export const TEMP_FILE_ID_ATTR = "data-temp-file-id"; export const TABLE_PICKER_SIZE = 7; export const LOCAL_MEDIA_ID_PREFIX = "local-media:"; +/** 工具栏支持的标题选项。 */ export const headingMeta = [ { label: "正文", value: "p", className: "p" }, { label: "标题 1", value: "h1", className: "h1" }, @@ -32,6 +38,7 @@ export const headingMeta = [ { label: "标题 6", value: "h6", className: "h6" } ] as const; +/** 将标题元数据转换为 TDesign 无关的菜单选项结构。 */ export function buildHeadingOptions(currentHeadingValue: string) { return headingMeta.map((item) => { const active = currentHeadingValue === item.value; @@ -47,6 +54,7 @@ export function buildHeadingOptions(currentHeadingValue: string) { }); } +/** 清理空段落并统一 HTML 外层空白。 */ export function normalizeContent(value?: string) { const text = (value || "").trim(); if (!text || text === "

") { @@ -58,6 +66,7 @@ export function normalizeContent(value?: string) { return normalized === "

" ? "" : normalized; } +/** 将内边距转换为 0 到 16 rem 范围内的数值。 */ export function normalizePaddingValue(value: unknown, fallback: number) { const nextValue = typeof value === "number" ? value : Number(value); if (!Number.isFinite(nextValue)) { @@ -66,16 +75,18 @@ export function normalizePaddingValue(value: unknown, fallback: number) { return Math.min(Math.max(nextValue, 0), 16); } +/** 兼容滑块组件返回单值或数组值。 */ export function normalizeSliderValue(value: number | number[], fallback: number) { const nextValue = Array.isArray(value) ? value[0] : value; return normalizePaddingValue(nextValue, fallback); } +/** 将内边距数值格式化为 CSS rem 值。 */ export function formatPaddingValue(value: number) { return `${Number(value.toFixed(2))}rem`; } +/** 判断媒体 ID 是否为尚未提交到服务器的本地 ID。 */ export function isLocalMediaId(id?: string | null) { return !!id && id.startsWith(LOCAL_MEDIA_ID_PREFIX); } - diff --git a/src/components/tiptap-editor/useTiptapEditor.ts b/src/editor/tiptap/useTiptapEditor.ts similarity index 50% rename from src/components/tiptap-editor/useTiptapEditor.ts rename to src/editor/tiptap/useTiptapEditor.ts index d98c7c6..93d2832 100644 --- a/src/components/tiptap-editor/useTiptapEditor.ts +++ b/src/editor/tiptap/useTiptapEditor.ts @@ -1,8 +1,45 @@ -import type { Editor } from "@tiptap/vue-3"; +/** Tiptap 编辑器状态管理,封装内容同步、内边距同步和生命周期清理。 */ import { EditorContent, useEditor } from "@tiptap/vue-3"; +import type { EditorView } from "@tiptap/pm/view"; +import { TextSelection } from "@tiptap/pm/state"; +import type { Component, ShallowRef } from "vue"; +import { resolveCodeEditorKeydown } from "../code"; import { buildTiptapExtensions } from "./extensions"; import { normalizeContent, normalizePaddingValue } from "./shared"; +/** Tiptap 编辑器实例对外暴露的最小结构,避免平台声明文件依赖 ProseMirror 内部类型。 */ +type TiptapEditorTextNode = { + isText: boolean; + text?: string; +}; + +type TiptapEditorState = { + selection: { + from: number; + to: number; + }; + doc: { + textBetween: (...args: any[]) => string; + descendants: (callback: (node: TiptapEditorTextNode, pos: number) => boolean | void) => void; + }; + tr: { + insertText: (...args: any[]) => any; + }; +}; + +export type TiptapEditorInstance = { + readonly isFocused: boolean; + readonly state: TiptapEditorState; + readonly view: any; + readonly commands: any; + destroy: () => void; + chain: (...args: any[]) => any; + can: (...args: any[]) => any; + isActive: (...args: any[]) => boolean; + getAttributes: (...args: any[]) => Record; + getHTML: (...args: any[]) => string; +}; + type TiptapEditorProps = { modelValue: string; paddingTB?: number; @@ -10,14 +47,74 @@ type TiptapEditorProps = { placeholder?: string; }; -type UpdateModelValue = { +type TiptapContentEmit = { (event: "update:modelValue", value: string): void; (event: "change", value: string): void; - (event: "update:paddingTB", value: number): void; - (event: "update:paddingLR", value: number): void; }; -export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue) { +/** 处理代码块中的代码输入行为。 */ +function handleCodeBlockKeyDown(view: EditorView, event: KeyboardEvent) { + const { $from, $to } = view.state.selection; + if (!$from.sameParent($to) || $from.parent.type.name !== "codeBlock") { + return false; + } + + const code = $from.parent.textContent; + const blockStart = $from.start($from.depth); + const result = resolveCodeEditorKeydown(code, { + start: view.state.selection.from - blockStart, + end: view.state.selection.to - blockStart + }, event); + if (!result) { + return false; + } + + event.preventDefault(); + const transaction = view.state.tr.insertText( + result.value, + blockStart, + blockStart + code.length + ); + transaction.setSelection(TextSelection.create( + transaction.doc, + blockStart + result.start, + blockStart + result.end + )); + view.dispatch(transaction); + return true; +} + +/** 处理 Tiptap 编辑器中的 Tab,避免焦点离开并插入制表符。 */ +function handleTiptapKeyDown(view: EditorView, event: KeyboardEvent) { + if (handleCodeBlockKeyDown(view, event)) { + return true; + } + if (event.key !== "Tab" || event.isComposing || event.ctrlKey || event.altKey || event.metaKey) { + return false; + } + if (!view.state.selection.$from.parent.isTextblock) { + return true; + } + + event.preventDefault(); + view.dispatch(view.state.tr.insertText("\t")); + return true; +} + +type TiptapPaddingUpdate = ( + event: "update:paddingTB" | "update:paddingLR", + value: number +) => void; + +/** + * 创建可编辑的 Tiptap 实例。 + * 外部内容只在编辑器未聚焦时回灌,代码块输入期间延迟同步,避免光标跳动。 + */ +export function useTiptapEditor( + props: TiptapEditorProps, + emit: TiptapContentEmit, + onPaddingUpdate?: TiptapPaddingUpdate +) { const articlePadding = reactive({ paddingTB: normalizePaddingValue(props.paddingTB, 1), paddingLR: normalizePaddingValue(props.paddingLR, 1.25) @@ -25,7 +122,7 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue const lastEmittedContent = ref(""); const pendingEmitContent = ref(null); - // 代码块输入期间只在编辑器内部累积,离开代码块或失焦后再统一同步 + // 代码块输入期间只在编辑器内部累积,离开代码块或失焦后再统一同步。 function flushPendingContent() { if (pendingEmitContent.value === null) { return; @@ -59,7 +156,8 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue editorProps: { attributes: { class: "editor-body tp-content-body tp-editor-body" - } + }, + handleKeyDown: handleTiptapKeyDown }, onBlur: () => { flushPendingContent(); @@ -81,7 +179,7 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue emit("change", nextContent); emit("update:modelValue", nextContent); } - }); + }) as unknown as ShallowRef; watch(() => props.modelValue, (value) => { if (!editor.value) { @@ -96,7 +194,7 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue if (nextContent === lastEmittedContent.value) { return; } - // 聚焦编辑时禁止外部回灌,避免重建选区后把光标拉回开头 + // 聚焦编辑时禁止外部回灌,避免重建选区后把光标拉回开头。 if (editor.value.isFocused) { return; } @@ -112,11 +210,11 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue }, { immediate: true }); watch(() => articlePadding.paddingTB, (value) => { - emit("update:paddingTB", value); + onPaddingUpdate?.("update:paddingTB", value); }); watch(() => articlePadding.paddingLR, (value) => { - emit("update:paddingLR", value); + onPaddingUpdate?.("update:paddingLR", value); }); onBeforeUnmount(() => { @@ -124,12 +222,9 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue }); return { - EditorContent, + EditorContent: EditorContent as unknown as Component, articlePadding, editor, editorBodyStyle }; } - -export type TiptapEditorInstance = Editor; - diff --git a/src/editor/tiptap/useTiptapView.ts b/src/editor/tiptap/useTiptapView.ts new file mode 100644 index 0000000..6d369f5 --- /dev/null +++ b/src/editor/tiptap/useTiptapView.ts @@ -0,0 +1,61 @@ +/** 创建只读 Tiptap 视图,并在内容变化后重新增强代码块。 */ +import { EditorContent, useEditor } from "@tiptap/vue-3"; +import type { Component, ShallowRef } from "vue"; +import { buildTiptapExtensions } from "./extensions"; +import { normalizeContent } from "./shared"; +import { enhancePrismCodeBlock } from "./PrismCodeBlockView"; +import type { TiptapEditorInstance } from "./useTiptapEditor"; + +type StringRef = { + readonly value: string; +}; + +/** + * 初始化只读编辑器。 + * 编辑器负责解析文档,DOM 增强负责行号和折叠,两者分开以便重复同步。 + */ +export function useTiptapView(content: StringRef, maxHeight: StringRef) { + const contentRef = ref(); + const editor = useEditor({ + editable: false, + extensions: buildTiptapExtensions(), + content: content.value || "

", + editorProps: { + attributes: { + class: "editor-body tp-content-body" + } + } + }) as unknown as ShallowRef; + + async function syncCodeBlock() { + await nextTick(); + if (contentRef.value) { + enhancePrismCodeBlock(contentRef.value); + } + } + + watch(() => [content.value, maxHeight.value] as const, ([value]) => { + if (!editor.value) { + return; + } + editor.value.commands.setContent(value || "

", { + emitUpdate: false + }); + void syncCodeBlock(); + }, { immediate: true }); + + watch(editor, () => { + void syncCodeBlock(); + }, { flush: "post" }); + + onBeforeUnmount(() => { + editor.value?.destroy(); + }); + + return { + EditorContent: EditorContent as unknown as Component, + contentRef, + editor, + normalizeContent + }; +} diff --git a/src/index.ts b/src/index.ts index 52b805d..3d16382 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,12 +6,12 @@ import "./assets/style/variable.less"; import "./assets/style/timi-web.less"; import "prismjs/themes/prism.css"; import "./components/prism-code-block.less"; -import "./components/tiptap-content.less"; import { Network } from "./utils"; import VPopup from "./utils/directives/Popup"; export * from "./api"; export * from "./components"; +export * from "./editor"; export * from "./store"; export * from "./types"; export * from "./utils"; diff --git a/src/utils/Prism.ts b/src/utils/Prism.ts index d14aa94..5985c8c 100644 --- a/src/utils/Prism.ts +++ b/src/utils/Prism.ts @@ -1,5 +1,23 @@ import Prism from "prismjs"; +// 平台组件会直接编译 src/editor,不能依赖 timi-web 构建阶段的 Prism 插件注入语言。 +import "prismjs/components/prism-apacheconf"; +import "prismjs/components/prism-bash"; +import "prismjs/components/prism-css"; +import "prismjs/components/prism-ini"; +import "prismjs/components/prism-java"; +import "prismjs/components/prism-javascript"; +import "prismjs/components/prism-json"; +import "prismjs/components/prism-less"; +import "prismjs/components/prism-markup"; +import "prismjs/components/prism-markup-templating"; +import "prismjs/components/prism-nginx"; +import "prismjs/components/prism-php"; +import "prismjs/components/prism-properties"; +import "prismjs/components/prism-sql"; +import "prismjs/components/prism-typescript"; +import "prismjs/components/prism-yaml"; + const prismLanguageAliasMap: Record = { html: "markup", xml: "markup", @@ -16,4 +34,3 @@ export function resolvePrismLanguage(language?: string | null) { const nextLanguage = prismLanguageAliasMap[language] || language; return Prism.languages[nextLanguage] ? nextLanguage : ""; } - diff --git a/src/utils/TDesignMessage.ts b/src/utils/TDesignMessage.ts deleted file mode 100644 index 11bc996..0000000 --- a/src/utils/TDesignMessage.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { AppContext } from "vue"; - -type MessageType = "info" | "success" | "warning" | "error"; - -type MessageHandler = (content: string) => unknown | Promise; - -type MessageApi = { - [type in MessageType]?: MessageHandler; -}; - -export const showTDesignMessage = async ( - appContext: AppContext | undefined, - type: MessageType, - content: string -) => { - const handler = (appContext?.config.globalProperties.$message as MessageApi | undefined)?.[type]; - if (handler) { - await handler(content); - return; - } - if ("error" === type) { - console.error(content); - return; - } - console.warn(content); -}; - diff --git a/vite.config.ts b/vite.config.ts index 5535ce5..fad9817 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -35,23 +35,14 @@ export default defineConfig({ }, rollupOptions: { external: [ - "vue", - "tdesign-vue-next" + "vue" ], output: { globals: { - vue: "Vue", - "tdesign-vue-next": "TDesign" + vue: "Vue" } } }, - commonjsOptions: { - esmExternals: [ - "vue", - "tdesign-vue-next" - ], - requireReturnsDefault: false - }, minify: "terser" }, plugins: [