refactor editor

This commit is contained in:
Timi
2026-08-25 17:35:19 +08:00
parent 0c5a4d4fd9
commit 6fa75b3045
44 changed files with 334 additions and 3608 deletions
+6 -2
View File
@@ -33,6 +33,11 @@
"import": "./dist/timi-web.mjs", "import": "./dist/timi-web.mjs",
"require": "./dist/timi-web.umd.js" "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" "./style.css": "./dist/timi-web.css"
}, },
"engines": { "engines": {
@@ -93,7 +98,6 @@
"vitest": "^4.1.4", "vitest": "^4.1.4",
"vue": "^3.5.26", "vue": "^3.5.26",
"vue-tsc": "^3.2.2", "vue-tsc": "^3.2.2",
"vue-eslint-parser": "^10.0.0", "vue-eslint-parser": "^10.0.0"
"tdesign-vue-next": "^1.20.6"
} }
} }
-94
View File
@@ -135,9 +135,6 @@ importers:
prettier: prettier:
specifier: ^3.8.0 specifier: ^3.8.0
version: 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: typescript:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
@@ -506,9 +503,6 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} 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': '@rolldown/binding-android-arm64@1.0.0-rc.12':
resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1009,12 +1003,6 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} 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': '@types/marked@6.0.0':
resolution: {integrity: sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==} 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. 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': '@types/prismjs@1.26.5':
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} 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': '@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
@@ -1471,9 +1450,6 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
dayjs@1.11.23:
resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==}
de-indent@1.0.2: de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
@@ -2077,9 +2053,6 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -2490,9 +2463,6 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
sortablejs@1.15.7:
resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==}
source-map-js@1.2.0: source-map-js@1.2.0:
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2583,17 +2553,6 @@ packages:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0} 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: terser@5.44.1:
resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==} resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2602,9 +2561,6 @@ packages:
tinybench@2.9.0: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinycolor2@1.6.0:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
tinyexec@1.1.1: tinyexec@1.1.1:
resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2703,10 +2659,6 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
validator@13.15.35:
resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==}
engines: {node: '>= 0.10'}
vite-plugin-dts@4.5.4: vite-plugin-dts@4.5.4:
resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==} resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==}
peerDependencies: peerDependencies:
@@ -3319,8 +3271,6 @@ snapshots:
'@pkgr/core@0.2.9': {} '@pkgr/core@0.2.9': {}
'@popperjs/core@2.11.8': {}
'@rolldown/binding-android-arm64@1.0.0-rc.12': '@rolldown/binding-android-arm64@1.0.0-rc.12':
optional: true optional: true
@@ -3727,12 +3677,6 @@ snapshots:
'@types/json-schema@7.0.15': {} '@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': '@types/marked@6.0.0':
dependencies: dependencies:
marked: 17.0.1 marked: 17.0.1
@@ -3745,12 +3689,6 @@ snapshots:
'@types/prismjs@1.26.5': {} '@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/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1': '@types/ws@8.18.1':
@@ -4303,8 +4241,6 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
dayjs@1.11.23: {}
de-indent@1.0.2: {} de-indent@1.0.2: {}
debug@4.3.4: debug@4.3.4:
@@ -4838,8 +4774,6 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash-es@4.18.1: {}
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash@4.17.21: {} lodash@4.17.21: {}
@@ -5286,8 +5220,6 @@ snapshots:
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
sortablejs@1.15.7: {}
source-map-js@1.2.0: {} source-map-js@1.2.0: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
@@ -5363,28 +5295,6 @@ snapshots:
dependencies: dependencies:
'@pkgr/core': 0.2.9 '@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: terser@5.44.1:
dependencies: dependencies:
'@jridgewell/source-map': 0.3.6 '@jridgewell/source-map': 0.3.6
@@ -5394,8 +5304,6 @@ snapshots:
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinycolor2@1.6.0: {}
tinyexec@1.1.1: {} tinyexec@1.1.1: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
@@ -5492,8 +5400,6 @@ snapshots:
util-deprecate@1.0.2: {} 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)): 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: dependencies:
'@microsoft/api-extractor': 7.50.1(@types/node@25.0.8) '@microsoft/api-extractor': 7.50.1(@types/node@25.0.8)
-13
View File
@@ -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<Article>;
getPendingMediaCount: () => number;
};
export const ArticleEditor = Toolkit.withInstall(view);
export default ArticleEditor;
-289
View File
@@ -1,289 +0,0 @@
<template>
<t-textarea
v-if="isTextContent"
:model-value="editorContent"
:autosize="textareaAutosize"
:placeholder="placeholder"
@update:model-value="onSimpleEditorCommit"
/>
<markdown-editor
v-else-if="isMarkdownContent"
:data="editorContent"
@update:data="onSimpleEditorCommit"
/>
<tiptap-editor
v-else
ref="tiptapEditorRef"
:model-value="editorContent"
:placeholder="placeholder"
:upload-image="handleUploadImage"
:upload-video="handleUploadVideo"
@change="onEditorChange"
@update:model-value="onEditorCommit"
/>
</template>
<script lang="ts" setup>
import type { Article, ArticleContentType, Attachment } from "../../types";
import AttachmentAPI from "../../api/AttachmentAPI";
import { MarkdownEditor } from "../markdown-editor";
import type { TiptapEditorExpose, UploadResult } from "../tiptap-editor";
import { TiptapEditor } from "../tiptap-editor";
import {
ATTACHMENT_ID_ATTR,
TEMP_FILE_ID_ATTR,
buildAttachmentListFromContent,
getAttachmentIdFromSrc,
getTempFileIdFromSrc,
normalizeArticleContentType,
normalizeContent,
normalizeContentForStore,
normalizeRawArticleContent,
sameAttachmentList
} from "./shared";
defineOptions({
name: "ArticleEditor"
});
const props = withDefaults(defineProps<{
modelValue?: Article;
placeholder?: string;
uploadImage?: (file: File) => Promise<UploadResult>;
uploadVideo?: (file: File) => Promise<UploadResult>;
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
resolveTempUrl?: (tempFileId: string) => string | Promise<string>;
}>(), {
modelValue: () => ({}),
placeholder: "请输入文章正文内容"
});
const emit = defineEmits<{
"change": [value: Article];
"update:modelValue": [value: Article];
}>();
const textareaAutosize = {
minRows: 8,
maxRows: 32
};
const editorContent = ref("");
const tiptapEditorRef = ref<TiptapEditorExpose>();
const lastOutgoingContent = ref("");
const lastOutgoingAttachmentList = ref<Attachment[]>([]);
const contentType = computed(() => {
return normalizeArticleContentType(props.modelValue?.contentType);
});
const isTextContent = computed(() => contentType.value === "TEXT");
const isMarkdownContent = computed(() => contentType.value === "MARKDOWN");
const attachmentMap = computed(() => {
return new Map((props.modelValue?.attachmentList || []).map(item => [item.id, item]));
});
async function defaultUploadImage(file: File) {
return defaultUploadMedia(file);
}
async function defaultUploadVideo(file: File) {
return defaultUploadMedia(file);
}
async function defaultUploadMedia(file: File) {
const tempFileList = await AttachmentAPI.upload([file]);
const tempFileId = tempFileList[0]?.id || "";
if (!tempFileId) {
throw new Error("上传临时文件失败");
}
return {
id: tempFileId,
url: AttachmentAPI.getTempReadURL(tempFileId),
name: file.name
};
}
async function handleUploadImage(file: File) {
if (props.uploadImage) {
return props.uploadImage(file);
}
return defaultUploadImage(file);
}
async function handleUploadVideo(file: File) {
if (props.uploadVideo) {
return props.uploadVideo(file);
}
return defaultUploadVideo(file);
}
async function resolveAttachmentPreviewUrl(attachmentId: string) {
const attachment = attachmentMap.value.get(attachmentId);
if (props.resolveAttachmentUrl) {
const customUrl = await props.resolveAttachmentUrl(attachmentId, attachment);
if (customUrl) {
return customUrl;
}
}
const previewUrl = `${attachment?.metadata?.previewUrl || ""}`;
if (previewUrl) {
return previewUrl;
}
return attachmentId ? AttachmentAPI.getReadURL(attachmentId) : "";
}
async function resolveTempPreviewUrl(tempFileId: string) {
if (props.resolveTempUrl) {
return props.resolveTempUrl(tempFileId);
}
return AttachmentAPI.getTempReadURL(tempFileId);
}
async function convertContentForEditor(value?: string) {
if (contentType.value !== "TIP_TAP") {
return normalizeRawArticleContent(value);
}
const rawContent = normalizeContent(value);
if (!rawContent) {
return "";
}
const parser = new DOMParser();
const doc = parser.parseFromString(rawContent, "text/html");
const mediaList = Array.from(doc.querySelectorAll("img, video"));
for (const media of mediaList) {
const src = media.getAttribute("src") || "";
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src);
if (tempFileId) {
media.setAttribute(TEMP_FILE_ID_ATTR, tempFileId);
media.setAttribute(ATTACHMENT_ID_ATTR, attachmentId || tempFileId);
media.setAttribute("src", await resolveTempPreviewUrl(tempFileId));
continue;
}
if (!attachmentId) {
continue;
}
media.setAttribute(ATTACHMENT_ID_ATTR, attachmentId);
const previewUrl = await resolveAttachmentPreviewUrl(attachmentId);
if (previewUrl) {
media.setAttribute("src", previewUrl);
}
}
return normalizeContent(doc.body.innerHTML);
}
function normalizeIncomingArticleContent(content: string | undefined, currentContentType: ArticleContentType) {
if (currentContentType === "TIP_TAP") {
return normalizeContent(content);
}
return normalizeRawArticleContent(content);
}
function buildStoredContent(content: string, currentContentType: ArticleContentType) {
if (currentContentType === "TIP_TAP") {
return normalizeContentForStore(content);
}
return normalizeRawArticleContent(content);
}
function buildArticleAttachmentList(content: string, currentContentType: ArticleContentType) {
if (currentContentType === "TIP_TAP") {
return buildAttachmentListFromContent(content);
}
return props.modelValue?.attachmentList || [];
}
function buildArticleValue(content: string) {
const nextContentType = contentType.value;
return {
...(props.modelValue || {}),
contentType: nextContentType,
content: buildStoredContent(content, nextContentType),
attachmentList: buildArticleAttachmentList(content, nextContentType)
};
}
function syncLastOutgoingArticle(value: Article) {
const nextContentType = normalizeArticleContentType(value.contentType);
lastOutgoingContent.value = normalizeIncomingArticleContent(value.content, nextContentType);
lastOutgoingAttachmentList.value = value.attachmentList || [];
}
// 实时变更只给预览态使用,不能反向驱动编辑器提交态
function onEditorChange(value: string) {
const nextValue = buildArticleValue(value);
syncLastOutgoingArticle(nextValue);
emit("change", {
...nextValue
});
}
// 提交态只在离开代码块后的同步时机更新
function onEditorCommit(value: string) {
editorContent.value = value;
const nextValue = buildArticleValue(value);
syncLastOutgoingArticle(nextValue);
emit("update:modelValue", {
...nextValue
});
}
function onSimpleEditorCommit(value: string) {
editorContent.value = value;
const nextValue = buildArticleValue(value);
syncLastOutgoingArticle(nextValue);
emit("change", {
...nextValue
});
emit("update:modelValue", {
...nextValue
});
}
watch(() => [props.modelValue?.content, contentType.value] as const, async ([value, nextContentType]) => {
const nextValue = normalizeIncomingArticleContent(value, nextContentType);
if (nextValue === lastOutgoingContent.value) {
return;
}
if (buildStoredContent(editorContent.value, nextContentType) === nextValue) {
return;
}
const nextContent = await convertContentForEditor(nextValue);
if (nextContent === editorContent.value) {
return;
}
editorContent.value = nextContent;
}, { immediate: true });
watch(() => props.modelValue?.attachmentList, async (value) => {
if (contentType.value !== "TIP_TAP") {
return;
}
if (sameAttachmentList(lastOutgoingAttachmentList.value, value)) {
return;
}
if (sameAttachmentList(buildAttachmentListFromContent(editorContent.value), value)) {
return;
}
const nextContent = await convertContentForEditor(props.modelValue?.content);
if (nextContent === editorContent.value) {
return;
}
editorContent.value = nextContent;
}, { deep: true });
async function submit() {
const nextEditorContent = contentType.value === "TIP_TAP" && tiptapEditorRef.value ? await tiptapEditorRef.value.submit() : editorContent.value;
const nextValue = buildArticleValue(nextEditorContent);
editorContent.value = nextEditorContent;
syncLastOutgoingArticle(nextValue);
emit("update:modelValue", nextValue);
return nextValue;
}
defineExpose({
submit,
getPendingMediaCount: () => tiptapEditorRef.value?.getPendingMediaCount() || 0
});
</script>
-6
View File
@@ -1,6 +0,0 @@
import view from "./index.vue";
import Toolkit from "../../utils/Toolkit";
export const ArticleView = Toolkit.withInstall(view);
export default ArticleView;
-118
View File
@@ -1,118 +0,0 @@
<template>
<t-textarea
v-if="isTextContent && hasRawContent"
class="text-view"
:model-value="rawContent"
:autosize="textareaAutosize"
readonly
/>
<t-empty v-else-if="isTextContent" :description="emptyText" />
<markdown-view v-else-if="isMarkdownContent && hasRawContent" :content="rawContent" />
<t-empty v-else-if="isMarkdownContent" :description="emptyText" />
<tiptap-view v-else :content="renderContent" :empty-text="emptyText" />
</template>
<script lang="ts" setup>
import type { Article, Attachment } from "../../types";
import AttachmentAPI from "../../api/AttachmentAPI";
import { MarkdownView } from "../markdown-view";
import { TiptapView } from "../tiptap-view";
import {
ATTACHMENT_ID_ATTR,
getAttachmentIdFromSrc,
normalizeArticleContentType,
normalizeContent,
normalizeRawArticleContent
} from "../article-editor/shared";
defineOptions({
name: "ArticleView"
});
const props = withDefaults(defineProps<{
article?: Article;
emptyText?: string;
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
}>(), {
article: () => ({}),
emptyText: "暂无正文内容"
});
const textareaAutosize = {
minRows: 8,
maxRows: 32
};
const renderContent = ref("");
const contentType = computed(() => {
return normalizeArticleContentType(props.article?.contentType);
});
const isTextContent = computed(() => contentType.value === "TEXT");
const isMarkdownContent = computed(() => contentType.value === "MARKDOWN");
const rawContent = computed(() => {
return normalizeRawArticleContent(props.article?.content);
});
const hasRawContent = computed(() => {
return 0 < rawContent.value.trim().length;
});
const attachmentMap = computed(() => {
return new Map((props.article?.attachmentList || []).map(item => [item.id, item]));
});
async function buildAttachmentPreviewUrl(attachmentId: string) {
const attachment = attachmentMap.value.get(attachmentId);
if (props.resolveAttachmentUrl) {
const customUrl = await props.resolveAttachmentUrl(attachmentId, attachment);
if (customUrl) {
return customUrl;
}
}
const previewUrl = `${attachment?.metadata?.previewUrl || ""}`;
if (previewUrl) {
return previewUrl;
}
return attachmentId ? AttachmentAPI.getReadURL(attachmentId) : "";
}
async function buildRenderContent(content?: string) {
const rawContent = normalizeContent(content);
if (!rawContent) {
return "";
}
const parser = new DOMParser();
const doc = parser.parseFromString(rawContent, "text/html");
const nodes = Array.from(doc.querySelectorAll("img, video, source"));
for (const node of nodes) {
const src = node.getAttribute("src") || "";
const attachmentId = node.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
if (!attachmentId) {
continue;
}
const previewUrl = await buildAttachmentPreviewUrl(attachmentId);
if (previewUrl) {
node.setAttribute("src", previewUrl);
}
}
return normalizeContent(doc.body.innerHTML);
}
watch(
() => [props.article?.content, props.article?.attachmentList, contentType.value] as const,
async ([, , nextContentType]) => {
if (nextContentType !== "TIP_TAP") {
renderContent.value = "";
return;
}
renderContent.value = await buildRenderContent(props.article?.content);
},
{ immediate: true, deep: true }
);
</script>
<style lang="less" scoped>
.text-view {
width: 100%;
}
</style>
-26
View File
@@ -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<CodeEditorScrollState>) => 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;
-450
View File
@@ -1,450 +0,0 @@
<template>
<div
ref="rootRef"
class="tui-code-editor"
:class="{
readonly,
disabled,
'with-line-numbers': lineNumbers
}"
>
<div v-if="lineNumbers" ref="gutterRef" class="gutter diselect" aria-hidden="true">
<span v-for="line in lineCount" :key="line" v-text="line" />
</div>
<div class="body">
<editor-search-bar
ref="searchBarRef"
:visible="searchVisible"
:expanded="replaceExpanded"
:keyword="searchKeyword"
:replace-value="replaceKeyword"
:status-error="searchError"
:match-index="searchMatchIndex"
:match-count="searchMatchList.length"
:case-sensitive="searchOptions.caseSensitive"
:use-regex="searchOptions.useRegex"
@update:expanded="replaceExpanded = $event"
@update:keyword="searchKeyword = $event"
@update:replace-value="replaceKeyword = $event"
@update:case-sensitive="searchOptions.caseSensitive = $event"
@update:use-regex="searchOptions.useRegex = $event"
@previous="findPrevious"
@next="onSearchNext"
@replace="replaceCurrent"
@replace-all="replaceAll"
@close="closeSearch"
/>
<pre
ref="preRef"
class="preview cur-text"
:class="languageClass"
@scroll="onPreviewScroll"
><code :class="languageClass" v-html="highlightedHTML"></code></pre>
<textarea
v-if="!readonly"
ref="textareaRef"
v-model="currentValue"
class="input"
:placeholder="placeholder"
:disabled="disabled"
wrap="off"
:spellcheck="false"
@input="onInput"
@scroll="onScroll"
@keydown="onKeydown"
></textarea>
</div>
</div>
</template>
<script lang="ts" setup>
import Prism from "prismjs";
import EditorSearchBar from "../editor-search/index.vue";
import {
findTextMatches,
replaceAllText,
replaceMatchText,
type TextSearchMatch,
type TextSearchOptions
} from "../editor-search/search";
import { resolveCodeEditorKeydown } from "./input";
import { resolvePrismLanguage } from "../../utils/Prism";
defineOptions({
name: "CodeEditor"
});
type ScrollState = {
top: number;
left: number;
};
type EditorSearchBarExpose = {
focusKeyword: (selectAll?: boolean) => void;
focusReplace: (selectAll?: boolean) => void;
};
const props = withDefaults(defineProps<{
modelValue?: string;
value?: string;
language?: string;
placeholder?: string;
readonly?: boolean;
disabled?: boolean;
lineNumbers?: boolean;
}>(), {
modelValue: "",
language: "typescript",
placeholder: "",
readonly: false,
disabled: false,
lineNumbers: true
});
const emit = defineEmits<{
(event: "update:modelValue", value: string): void;
(event: "change", value: string): void;
}>();
const bindValue = computed(() => props.value ?? props.modelValue);
const currentValue = ref(bindValue.value);
const rootRef = ref<HTMLDivElement>();
const gutterRef = ref<HTMLDivElement>();
const preRef = ref<HTMLPreElement>();
const textareaRef = ref<HTMLTextAreaElement>();
const searchBarRef = ref<EditorSearchBarExpose>();
const searchVisible = ref(false);
const replaceExpanded = ref(false);
const searchKeyword = ref("");
const replaceKeyword = ref("");
const searchError = ref("");
const searchMatchIndex = ref(-1);
const searchMatchList = ref<TextSearchMatch[]>([]);
const scrollState = reactive<ScrollState>({
top: 0,
left: 0
});
const searchOptions = reactive<TextSearchOptions>({
caseSensitive: false,
useRegex: false
});
const resolvedLanguage = computed(() => resolvePrismLanguage(props.language));
const languageClass = computed(() => resolvedLanguage.value ? `language-${resolvedLanguage.value}` : "");
const lineCount = computed(() => Math.max(currentValue.value.split("\n").length, 1));
const highlightedHTML = computed(() => {
const value = currentValue.value || " ";
if (!resolvedLanguage.value) {
return escapeHTML(value);
}
return Prism.highlight(value, Prism.languages[resolvedLanguage.value], resolvedLanguage.value);
});
watch(bindValue, (value) => {
if (value === currentValue.value) {
return;
}
currentValue.value = value;
refreshSearchMatchState();
nextTick(syncScrollPosition);
});
watch([searchKeyword, () => searchOptions.caseSensitive, () => searchOptions.useRegex], () => {
refreshSearchMatchState();
});
const onInput = () => {
emit("update:modelValue", currentValue.value);
emit("change", currentValue.value);
refreshSearchMatchState();
};
const onScroll = () => {
syncScrollPosition();
};
const onPreviewScroll = () => {
if (!props.readonly || !preRef.value || !gutterRef.value) {
return;
}
scrollState.top = preRef.value.scrollTop;
scrollState.left = preRef.value.scrollLeft;
gutterRef.value.scrollTop = preRef.value.scrollTop;
};
const onKeydown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && !event.altKey) {
const key = event.key.toLowerCase();
if ("f" === key) {
event.preventDefault();
openSearch(false);
return;
}
if ("h" === key && !props.readonly) {
event.preventDefault();
openSearch(true);
return;
}
}
if (props.readonly || props.disabled || !textareaRef.value) {
return;
}
const textarea = textareaRef.value;
const nextState = resolveCodeEditorKeydown(currentValue.value, {
start: textarea.selectionStart,
end: textarea.selectionEnd
}, event);
if (!nextState) {
return;
}
event.preventDefault();
currentValue.value = nextState.value;
emit("update:modelValue", currentValue.value);
emit("change", currentValue.value);
refreshSearchMatchState();
nextTick(() => {
textarea.selectionStart = nextState.start;
textarea.selectionEnd = nextState.end;
syncScrollPosition();
});
};
const getScrollState = (): ScrollState => ({
top: scrollState.top,
left: scrollState.left
});
const setScrollState = (nextState: Partial<ScrollState>) => {
scrollState.top = Math.max(0, nextState.top || 0);
scrollState.left = Math.max(0, nextState.left || 0);
applyScrollState();
};
const syncScrollPosition = () => {
if (textareaRef.value) {
scrollState.top = textareaRef.value.scrollTop;
scrollState.left = textareaRef.value.scrollLeft;
applyScrollState();
return;
}
if (props.readonly && preRef.value) {
scrollState.top = preRef.value.scrollTop;
scrollState.left = preRef.value.scrollLeft;
applyScrollState();
}
};
const applyScrollState = () => {
if (textareaRef.value) {
textareaRef.value.scrollTop = scrollState.top;
textareaRef.value.scrollLeft = scrollState.left;
}
if (preRef.value) {
preRef.value.scrollTop = scrollState.top;
preRef.value.scrollLeft = scrollState.left;
}
if (gutterRef.value) {
gutterRef.value.scrollTop = scrollState.top;
}
};
const getSelectionKeyword = () => {
if (!textareaRef.value) {
return "";
}
const { selectionStart, selectionEnd } = textareaRef.value;
if (selectionStart === selectionEnd) {
return "";
}
return currentValue.value.slice(selectionStart, selectionEnd);
};
const refreshSearchMatchState = () => {
const result = findTextMatches(currentValue.value, searchKeyword.value, searchOptions);
searchError.value = result.error;
searchMatchList.value = result.matches;
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
searchMatchIndex.value = -1;
return;
}
const { selectionStart, selectionEnd } = textareaRef.value;
searchMatchIndex.value = searchMatchList.value.findIndex((match) => (
match.start === selectionStart && match.end === selectionEnd
));
if (0 > searchMatchIndex.value) {
searchMatchIndex.value = 0;
}
};
const openSearch = (showReplace: boolean) => {
searchVisible.value = true;
replaceExpanded.value = showReplace;
const selectedText = getSelectionKeyword();
if (selectedText) {
searchKeyword.value = selectedText;
}
refreshSearchMatchState();
nextTick(() => {
searchBarRef.value?.focusKeyword(true);
});
};
const closeSearch = () => {
searchVisible.value = false;
replaceExpanded.value = false;
focus();
};
const selectMatch = (match: TextSearchMatch, matchIndex: number) => {
if (!textareaRef.value) {
return false;
}
textareaRef.value.focus();
textareaRef.value.selectionStart = match.start;
textareaRef.value.selectionEnd = match.end;
textareaRef.value.setSelectionRange(match.start, match.end);
textareaRef.value.scrollIntoView({ block: "nearest", inline: "nearest" });
scrollState.top = textareaRef.value.scrollTop;
scrollState.left = textareaRef.value.scrollLeft;
applyScrollState();
searchMatchIndex.value = matchIndex;
return true;
};
const findNext = () => {
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
return false;
}
const { selectionEnd } = textareaRef.value;
const nextIndex = searchMatchList.value.findIndex((match) => match.start >= selectionEnd);
const targetIndex = 0 <= nextIndex ? nextIndex : 0;
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
};
const findPrevious = () => {
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
return false;
}
const { selectionStart } = textareaRef.value;
let targetIndex = searchMatchList.value.length - 1;
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
if (searchMatchList.value[index].start < selectionStart) {
targetIndex = index;
break;
}
}
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
};
const onSearchNext = (shiftKey: boolean) => {
if (shiftKey) {
findPrevious();
return;
}
findNext();
};
const replaceSelection = (match: TextSearchMatch) => {
const nextText = replaceMatchText(match.text, searchKeyword.value, replaceKeyword.value, searchOptions);
currentValue.value = currentValue.value.slice(0, match.start) + nextText + currentValue.value.slice(match.end);
emit("update:modelValue", currentValue.value);
emit("change", currentValue.value);
};
const replaceCurrent = () => {
if (props.readonly || !textareaRef.value || !searchKeyword.value) {
return false;
}
const { selectionStart, selectionEnd } = textareaRef.value;
let currentMatchIndex = searchMatchList.value.findIndex((match) => (
match.start === selectionStart && match.end === selectionEnd
));
if (0 > currentMatchIndex) {
if (!findNext()) {
return false;
}
currentMatchIndex = searchMatchIndex.value;
}
const currentMatch = searchMatchList.value[currentMatchIndex];
if (!currentMatch) {
return false;
}
replaceSelection(currentMatch);
refreshSearchMatchState();
nextTick(() => {
const nextMatch = searchMatchList.value[currentMatchIndex] || searchMatchList.value[currentMatchIndex - 1];
if (!nextMatch) {
focus();
return;
}
selectMatch(nextMatch, Math.min(currentMatchIndex, searchMatchList.value.length - 1));
});
return true;
};
const replaceAll = () => {
if (props.readonly || !searchKeyword.value) {
return 0;
}
const result = replaceAllText(currentValue.value, searchKeyword.value, replaceKeyword.value, searchOptions);
searchError.value = result.error;
if (result.error || 1 > result.count) {
refreshSearchMatchState();
return 0;
}
currentValue.value = result.value;
emit("update:modelValue", currentValue.value);
emit("change", currentValue.value);
refreshSearchMatchState();
nextTick(() => {
if (searchMatchList.value[0]) {
selectMatch(searchMatchList.value[0], 0);
return;
}
focus();
});
return result.count;
};
const focus = () => {
textareaRef.value?.focus();
};
const blur = () => {
textareaRef.value?.blur();
};
onActivated(() => {
nextTick(() => {
applyScrollState();
});
});
defineExpose({
rootRef,
textareaRef,
focus,
blur,
getScrollState,
setScrollState,
findNext,
findPrevious,
replaceCurrent,
replaceAll,
openSearch,
closeSearch
});
function escapeHTML(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
</script>
-202
View File
@@ -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%);
}
}
}
-271
View File
@@ -1,271 +0,0 @@
<template>
<div v-if="visible" class="tui-editor-search" @mousedown.stop>
<div class="row">
<t-button
class="toggle-btn"
shape="square"
size="small"
type="button"
variant="outline"
:title="expanded ? '收起替换' : '展开替换'"
@click="emit('update:expanded', !expanded)"
>
<t-icon :name="expanded ? 'chevron-down-s' : 'chevron-right-s'" />
</t-button>
<t-input
ref="keywordInputRef"
:value="keyword"
class="text-input"
placeholder="查找"
size="small"
@change="onKeywordChange"
@enter="onKeywordEnter"
@keydown="onKeywordKeydown"
>
<template #suffix-icon>
<t-button
shape="square"
size="small"
type="button"
variant="text"
:theme="caseSensitive ? 'primary' : 'default'"
title="大小写匹配"
@click="emit('update:case-sensitive', !caseSensitive)"
>
Aa
</t-button>
<t-button
shape="square"
size="small"
type="button"
variant="text"
:theme="useRegex ? 'primary' : 'default'"
title="正则搜索"
@click="emit('update:use-regex', !useRegex)"
>
.*
</t-button>
</template>
</t-input>
<span class="status" v-text="resolvedStatusText" />
<t-button shape="square" size="small" type="button" variant="text" title="上一个" @click="emit('previous')">
<t-icon name="arrow-up" />
</t-button>
<t-button shape="square" size="small" type="button" variant="text" title="下一个" @click="emit('next', false)">
<t-icon name="arrow-down" />
</t-button>
<t-button shape="square" size="small" type="button" variant="text" title="关闭" @click="emit('close')">
<t-icon name="close" />
</t-button>
</div>
<div v-if="expanded" class="row replace-row">
<div class="toggle-holder" />
<t-input
ref="replaceInputRef"
:value="replaceValue"
class="text-input"
placeholder="替换"
size="small"
@change="onReplaceChange"
@enter="emit('replace')"
@keydown="onReplaceKeydown"
/>
<div class="replace-actions">
<t-button size="small" type="button" theme="primary" @click="emit('replace')">替换</t-button>
<t-button size="small" type="button" variant="outline" @click="emit('replace-all')">全部替换</t-button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { ComponentPublicInstance } from "vue";
defineOptions({
name: "EditorSearchBar"
});
const props = withDefaults(defineProps<{
visible?: boolean;
expanded?: boolean;
keyword?: string;
replaceValue?: string;
statusText?: string;
statusError?: string;
matchIndex?: number;
matchCount?: number;
caseSensitive?: boolean;
useRegex?: boolean;
}>(), {
visible: false,
expanded: false,
keyword: "",
replaceValue: "",
statusText: undefined,
statusError: "",
matchIndex: -1,
matchCount: 0,
caseSensitive: false,
useRegex: false
});
const emit = defineEmits<{
(event: "update:expanded", value: boolean): void;
(event: "update:keyword", value: string): void;
(event: "update:replace-value", value: string): void;
(event: "update:case-sensitive", value: boolean): void;
(event: "update:use-regex", value: boolean): void;
(event: "previous"): void;
(event: "next", shiftKey: boolean): void;
(event: "replace"): void;
(event: "replace-all"): void;
(event: "close"): void;
}>();
type InputInstance = ComponentPublicInstance & {
$el: HTMLElement;
};
type InputKeyboardContext = {
e: KeyboardEvent;
};
const keywordInputRef = ref<InputInstance>();
const replaceInputRef = ref<InputInstance>();
const resolvedStatusText = computed(() => {
if (undefined !== props.statusText) {
return props.statusText;
}
if (props.statusError) {
return props.statusError;
}
if (!props.keyword) {
return "无结果";
}
if (1 > props.matchCount) {
return "未找到";
}
return `${props.matchIndex + 1} / ${props.matchCount}`;
});
const onKeywordChange = (value: string | number) => {
emit("update:keyword", `${value}`);
};
const onReplaceChange = (value: string | number) => {
emit("update:replace-value", `${value}`);
};
const onKeywordEnter = (_value: string | number, context: InputKeyboardContext) => {
emit("next", context.e.shiftKey);
};
const onKeywordKeydown = (_value: string | number, context: InputKeyboardContext) => {
if ("Escape" !== context.e.key) {
return;
}
context.e.preventDefault();
emit("close");
};
const onReplaceKeydown = (_value: string | number, context: InputKeyboardContext) => {
if ("Escape" !== context.e.key) {
return;
}
context.e.preventDefault();
emit("close");
};
const getNativeInput = (instance?: InputInstance) => instance?.$el.querySelector("input") || undefined;
const focusKeyword = (selectAll = false) => {
const input = getNativeInput(keywordInputRef.value);
input?.focus();
if (selectAll && input) {
input.selectionStart = 0;
input.selectionEnd = input.value.length;
}
};
const focusReplace = (selectAll = false) => {
const input = getNativeInput(replaceInputRef.value);
input?.focus();
if (selectAll && input) {
input.selectionStart = 0;
input.selectionEnd = input.value.length;
}
};
defineExpose({
focusKeyword,
focusReplace,
keywordInputRef,
replaceInputRef
});
</script>
<style lang="less" scoped>
.tui-editor-search {
top: .5rem;
right: .5rem;
gap: .375rem;
z-index: 3;
display: flex;
padding: .5rem;
position: absolute;
min-width: 20rem;
max-width: calc(100% - 1rem);
box-sizing: border-box;
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);
flex-direction: column;
.row {
gap: .375rem;
display: flex;
min-width: 0;
align-items: center;
.toggle-holder {
flex: none;
width: 24px;
}
.toggle-btn {
flex: none;
}
.text-input {
width: 12rem;
:deep(.t-input) {
padding-right: 0;
}
}
.status {
width: 3rem;
color: var(--td-text-color-secondary, #666);
font-size: .875rem;
white-space: nowrap;
}
}
.replace-actions {
gap: .375rem;
display: flex;
align-items: center;
}
}
@media (prefers-color-scheme: dark) {
.tui-editor-search {
background: var(--td-bg-color-container, #1f1f1f);
border-color: var(--td-component-stroke, #434343);
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .28);
}
}
</style>
+2 -42
View File
@@ -11,13 +11,6 @@ import BEFlowerFall from "./background-effect/flower-fall";
import MarkdownEditor from "./markdown-editor"; import MarkdownEditor from "./markdown-editor";
import PassTimeLabel from "./passtime-label"; import PassTimeLabel from "./passtime-label";
import ContactNumber from "./contact-number"; 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 [ export default [
Icon, Icon,
@@ -31,14 +24,7 @@ export default [
BEFlowerFall, BEFlowerFall,
MarkdownEditor, MarkdownEditor,
PassTimeLabel, PassTimeLabel,
ContactNumber, ContactNumber
CodeEditor,
TiptapEditor,
TiptapView,
ArticleEditor,
ArticleView,
NotifyEditor,
NotifyView
]; ];
export { export {
@@ -53,31 +39,5 @@ export {
BEFlowerFall, BEFlowerFall,
MarkdownEditor, MarkdownEditor,
PassTimeLabel, PassTimeLabel,
ContactNumber, ContactNumber
CodeEditor,
TiptapEditor,
TiptapView,
ArticleEditor,
ArticleView,
NotifyEditor,
NotifyView
}; };
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";
+2 -2
View File
@@ -262,7 +262,7 @@
background: transparent; background: transparent;
max-height: var(--data-max-height); max-height: var(--data-max-height);
transition: max-height .5s var(--tui-bezier); transition: max-height .5s var(--tui-bezier);
font-family: var(--td-font); font-family: var(--tui-font);
line-height: 1; line-height: 1;
border-radius: 0; border-radius: 0;
@@ -270,7 +270,7 @@
color: #333; color: #333;
background: transparent; background: transparent;
text-shadow: none !important; text-shadow: none !important;
font-family: var(--td-font); font-family: var(--tui-font);
.line-numbers-rows { .line-numbers-rows {
left: 0; left: 0;
-11
View File
@@ -1,11 +0,0 @@
import view from "./index.vue";
import Toolkit from "../../utils/Toolkit";
import type { NotifyDetail } from "../../types";
export type NotifyEditorExpose = {
submit: () => Promise<NotifyDetail>;
getPendingMediaCount: () => number;
};
export const NotifyEditor = Toolkit.withInstall(view);
export default NotifyEditor;
-77
View File
@@ -1,77 +0,0 @@
<template>
<article-editor
ref="articleEditorRef"
:model-value="articleValue"
:placeholder="placeholder"
:upload-image="uploadImage"
:upload-video="uploadVideo"
:resolve-attachment-url="resolveAttachmentUrl"
:resolve-temp-url="resolveTempUrl"
@change="onArticleChange"
@update:model-value="onArticleCommit"
/>
</template>
<script lang="ts" setup>
import type { Article, ArticleContentType, Attachment, NotifyDetail } from "../../types";
import type { ArticleEditorExpose } from "../article-editor";
import { ArticleEditor } from "../article-editor";
import type { UploadResult } from "../tiptap-editor";
import { normalizeNotifyContentType } from "../notify/shared";
defineOptions({
name: "NotifyEditor"
});
const props = withDefaults(defineProps<{
modelValue?: NotifyDetail;
placeholder?: string;
uploadImage?: (file: File) => Promise<UploadResult>;
uploadVideo?: (file: File) => Promise<UploadResult>;
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
resolveTempUrl?: (tempFileId: string) => string | Promise<string>;
}>(), {
modelValue: () => ({}),
placeholder: "请输入通知内容"
});
const emit = defineEmits<{
change: [value: NotifyDetail];
"update:modelValue": [value: NotifyDetail];
}>();
const articleEditorRef = ref<ArticleEditorExpose>();
const articleValue = computed<Article>(() => ({
contentType: normalizeNotifyContentType(props.modelValue?.contentType) as ArticleContentType,
content: props.modelValue?.content || "",
attachmentList: props.modelValue?.attachmentList
}));
function buildNotifyValue(value: Article): NotifyDetail {
return {
...(props.modelValue || {}),
contentType: normalizeNotifyContentType(value.contentType),
content: value.content || "",
attachmentList: value.attachmentList || []
};
}
function onArticleChange(value: Article) {
emit("change", buildNotifyValue(value));
}
function onArticleCommit(value: Article) {
emit("update:modelValue", buildNotifyValue(value));
}
async function submit() {
const value = await articleEditorRef.value?.submit();
return buildNotifyValue(value || articleValue.value);
}
defineExpose({
submit,
getPendingMediaCount: () => articleEditorRef.value?.getPendingMediaCount() || 0
});
</script>
-5
View File
@@ -1,5 +0,0 @@
import view from "./index.vue";
import Toolkit from "../../utils/Toolkit";
export const NotifyView = Toolkit.withInstall(view);
export default NotifyView;
-32
View File
@@ -1,32 +0,0 @@
<template>
<article-view
:article="articleValue"
:empty-text="emptyText"
:resolve-attachment-url="resolveAttachmentUrl"
/>
</template>
<script lang="ts" setup>
import type { Article, ArticleContentType, Attachment, NotifyDetail } from "../../types";
import { ArticleView } from "../article-view";
import { normalizeNotifyContentType } from "../notify/shared";
defineOptions({
name: "NotifyView"
});
const props = withDefaults(defineProps<{
detail?: NotifyDetail;
emptyText?: string;
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
}>(), {
detail: () => ({}),
emptyText: "暂无通知内容"
});
const articleValue = computed<Article>(() => ({
contentType: normalizeNotifyContentType(props.detail?.contentType) as ArticleContentType,
content: props.detail?.content || "",
attachmentList: props.detail?.attachmentList
}));
</script>
+10 -11
View File
@@ -11,16 +11,16 @@
img { img {
height: auto; height: auto;
border-radius: var(--td-radius-default, .375rem); border-radius: var(--tui-radius-default, .375rem);
} }
video, video,
iframe.tp-iframe { iframe.tp-iframe {
width: min(100%, 56rem); width: min(100%, 56rem);
overflow: hidden; overflow: hidden;
border-radius: var(--td-radius-default, .375rem); border-radius: var(--tui-radius-default, .375rem);
background: var(--td-bg-color-container, #fff); background: var(--tui-bg-color-container, #fff);
border: 1px solid var(--td-component-border, #dcdcdc); border: 1px solid var(--tui-component-border, #dcdcdc);
} }
iframe.tp-iframe { iframe.tp-iframe {
@@ -38,20 +38,20 @@
td { td {
padding: .5rem .75rem; padding: .5rem .75rem;
vertical-align: middle; vertical-align: middle;
border: 1px solid var(--td-component-border, #dcdcdc); border: 1px solid var(--tui-component-border, #dcdcdc);
} }
th { th {
background: var(--td-bg-color-container-hover, #f6f6f6); background: var(--tui-bg-color-container-hover, #f6f6f6);
} }
pre[class*="language-"] { pre[class*="language-"] {
padding: 0; padding: 0;
border: 1px solid var(--td-component-stroke, #B8BBC9); border: 1px solid var(--tui-component-stroke, #B8BBC9);
overflow: auto; overflow: auto;
background: transparent; background: transparent;
transition: max-height .5s ease; transition: max-height .5s ease;
border-radius: var(--td-radius-medium, 0); border-radius: var(--tui-radius-medium, 0);
code { code {
color: #333; color: #333;
@@ -71,11 +71,11 @@
display: block; display: block;
text-align: right; text-align: right;
background: #F2F2F2E6; background: #F2F2F2E6;
border-right: 1px solid var(--td-component-stroke, #B8BBC9); border-right: 1px solid var(--tui-component-stroke, #B8BBC9);
padding-right: .25rem; padding-right: .25rem;
> span { > span {
color: var(--td-text-color-placeholder, #999); color: var(--tui-text-color-placeholder, #999);
display: block; display: block;
} }
@@ -178,4 +178,3 @@
} }
} }
-46
View File
@@ -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();
}
@@ -1,56 +0,0 @@
<template>
<t-popconfirm
v-model:visible="visible"
trigger="click"
:confirm-btn="{ content: '插入', theme: 'primary' }"
:cancel-btn="{ content: '取消', theme: 'default' }"
placement="bottom"
@visible-change="onVisibleChange"
@confirm="onConfirm"
>
<template #icon><span></span></template>
<template #content>
<t-input v-model="codeLanguage" placeholder="代码语言,可留空" />
</template>
<t-button v-popup="'代码块'" variant="outline" :theme="active ? 'primary' : 'default'">
<t-icon name="code" />
</t-button>
</t-popconfirm>
</template>
<script lang="ts" setup>
import type { TiptapEditorInstance } from "./useTiptapEditor";
defineOptions({
name: "TiptapCodeBlockButton"
});
const props = defineProps<{
editor?: TiptapEditorInstance;
active: boolean;
}>();
const visible = ref(false);
const codeLanguage = ref("");
function onVisibleChange(nextVisible: boolean) {
if (!nextVisible || !props.editor) {
return;
}
codeLanguage.value = "";
}
function onConfirm() {
if (!props.editor) {
return;
}
const language = codeLanguage.value.trim();
if (!language) {
props.editor.chain().focus().setCodeBlock().run();
visible.value = false;
return;
}
props.editor.chain().focus().setCodeBlock({ language }).run();
visible.value = false;
}
</script>
@@ -1,113 +0,0 @@
<template>
<t-popconfirm
class="tiptap-iframe-button"
v-model:visible="visible"
trigger="click"
:confirm-btn="{ content: '插入', theme: 'primary' }"
:cancel-btn="{ content: '取消', theme: 'default' }"
placement="bottom"
@visible-change="onVisibleChange"
@confirm="onConfirm"
>
<template #icon><span></span></template>
<template #content>
<t-form class="popconfirm-content" label-align="left" label-width="auto">
<t-form-item label="网页地址">
<t-input v-model="iframeForm.src" placeholder="请输入网页地址" />
</t-form-item>
<t-form-item label="网页标题">
<t-input v-model="iframeForm.title" placeholder="请输入网页标题,可留空" />
</t-form-item>
<t-form-item label="宽度">
<t-input v-model="iframeForm.width" placeholder="请输入宽度,默认 100%" />
</t-form-item>
<t-form-item label="高度">
<t-input v-model="iframeForm.height" placeholder="请输入高度,默认 28rem" />
</t-form-item>
</t-form>
</template>
<t-button v-popup="'插入内嵌网页'" variant="outline" :theme="active ? 'primary' : 'default'">
<t-icon name="web" />
</t-button>
</t-popconfirm>
</template>
<script lang="ts" setup>
import { showTDesignMessage } from "../../utils/TDesignMessage";
import type { TiptapEditorInstance } from "./useTiptapEditor";
defineOptions({
name: "TiptapIframeButton"
});
const props = defineProps<{
editor?: TiptapEditorInstance;
active: boolean;
}>();
const appContext = getCurrentInstance()?.appContext;
const visible = ref(false);
const iframeForm = reactive({
src: "",
title: "",
width: "100%",
height: "28rem"
});
function onVisibleChange(nextVisible: boolean) {
if (!nextVisible || !props.editor) {
return;
}
const attributes = props.editor.getAttributes("iframe");
iframeForm.src = (attributes.src as string) || "https://";
iframeForm.title = (attributes.title as string) || "";
iframeForm.width = (attributes.width as string) || "100%";
iframeForm.height = (attributes.height as string) || "28rem";
}
function isValidHttpUrl(value: string) {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
async function onConfirm() {
if (!props.editor) {
return;
}
const src = iframeForm.src.trim();
if (!src) {
visible.value = false;
return;
}
if (!isValidHttpUrl(src)) {
await showTDesignMessage(appContext, "warning", "请输入有效的 http 或 https 地址");
return;
}
props.editor
.chain()
.focus()
.insertContent({
type: "iframe",
attrs: {
src,
title: iframeForm.title.trim() || "内嵌网页",
width: iframeForm.width.trim() || "100%",
height: iframeForm.height.trim() || "28rem"
}
})
.run();
visible.value = false;
}
</script>
<style lang="less" scoped>
.popconfirm-content {
--td-comp-margin-xxl: .5rem;
padding: 0 .5rem;
}
</style>
@@ -1,95 +0,0 @@
<template>
<t-popconfirm
v-model:visible="visible"
trigger="click"
:confirm-btn="{ content: '插入', theme: 'primary' }"
:cancel-btn="{ content: '取消', theme: 'default' }"
placement="bottom"
@visible-change="onVisibleChange"
@confirm="onConfirm"
>
<template #icon><span></span></template>
<template #content>
<div class="link-form">
<t-input v-model="linkForm.href" placeholder="请输入链接地址" />
<t-input v-model="linkForm.text" placeholder="请输入显示文本,可留空" />
<t-checkbox v-model="linkForm.syncToHref">显示文本同步为链接</t-checkbox>
</div>
</template>
<t-button v-popup="'插入链接'" variant="outline" :theme="active ? 'primary' : 'default'">
<t-icon name="link-1" />
</t-button>
</t-popconfirm>
</template>
<script lang="ts" setup>
import type { TiptapEditorInstance } from "./useTiptapEditor";
defineOptions({
name: "TiptapLinkButton"
});
const props = defineProps<{
editor?: TiptapEditorInstance;
active: boolean;
}>();
const visible = ref(false);
const linkForm = reactive({
href: "",
text: "",
syncToHref: false
});
function onVisibleChange(nextVisible: boolean) {
if (!nextVisible || !props.editor) {
return;
}
const previousUrl = (props.editor.getAttributes("link").href as string) || "";
const selectedText = props.editor.state.doc.textBetween(
props.editor.state.selection.from,
props.editor.state.selection.to,
""
);
linkForm.href = previousUrl || "https://";
linkForm.text = selectedText || "";
linkForm.syncToHref = false;
}
function onConfirm() {
if (!props.editor) {
return;
}
const href = linkForm.href.trim();
if (!href) {
props.editor.chain().focus().unsetLink().run();
visible.value = false;
return;
}
const text = linkForm.syncToHref ? href : linkForm.text.trim();
if (text) {
props.editor
.chain()
.focus()
.insertContent({
type: "text",
text,
marks: [{ type: "link", attrs: { href } }]
})
.run();
visible.value = false;
return;
}
props.editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
visible.value = false;
}
</script>
<style lang="less" scoped>
.link-form {
gap: .75rem;
width: 20rem;
display: flex;
flex-direction: column;
}
</style>
@@ -1,184 +0,0 @@
<template>
<t-popconfirm
class="tiptap-media-button"
v-model:visible="visible"
trigger="click"
:confirm-btn="{ content: '插入', theme: 'primary' }"
:cancel-btn="{ content: '取消', theme: 'default' }"
placement="bottom"
@visible-change="onVisibleChange"
@confirm="onConfirm"
>
<template #icon><span></span></template>
<template #content>
<t-form class="popconfirm-content" label-align="left" label-width="auto">
<t-form-item label="文件">
<t-button size="small" variant="outline" @click="pickFile">
<span
class="file-name keep-text"
v-text="selectedFile ? '重新选择' : `选择${typeLabel}`"
/>
</t-button>
<span class="word-space gray" v-text="selectedFile?.name || `未选择${typeLabel}文件`" />
</t-form-item>
<t-form-item label="标题">
<t-input v-model="mediaForm.title" :placeholder="`请输入${typeLabel}标题,可留空`" />
</t-form-item>
<t-form-item label="宽度">
<t-input v-model="mediaForm.width" placeholder="请输入宽度,默认 100%" />
</t-form-item>
<t-form-item label="高度">
<t-input v-model="mediaForm.height" placeholder="请输入高度,默认 auto" />
</t-form-item>
</t-form>
</template>
<t-button variant="outline" :theme="active ? 'primary' : 'default'" :loading="uploading">
<t-icon :name="iconName" />
</t-button>
</t-popconfirm>
<input ref="fileInputRef" class="file-input" type="file" :accept="accept" @change="onFileChange" />
</template>
<script lang="ts" setup>
import { showTDesignMessage } from "../../utils/TDesignMessage";
import type { TiptapEditorInstance } from "./useTiptapEditor";
import type { UploadResult } from "./shared";
defineOptions({
name: "TiptapMediaButton"
});
const props = defineProps<{
editor?: TiptapEditorInstance;
active: boolean;
type: "image" | "video";
upload?: (file: File) => Promise<UploadResult>;
}>();
const appContext = getCurrentInstance()?.appContext;
const visible = ref(false);
const uploading = ref(false);
const selectedFile = ref<File>();
const fileInputRef = ref<HTMLInputElement>();
const mediaForm = reactive({
title: "",
width: "100%",
height: ""
});
const typeLabel = computed(() => {
return props.type === "image" ? "图片" : "视频";
});
const iconName = computed(() => {
return props.type === "image" ? "image-add" : "video";
});
const accept = computed(() => {
return props.type === "image" ? "image/*" : "video/*";
});
function buildDefaultHeight() {
return "auto";
}
function pickFile(event?: MouseEvent) {
event?.stopPropagation();
fileInputRef.value?.click();
}
function onVisibleChange(nextVisible: boolean) {
if (!nextVisible || !props.editor) {
return;
}
selectedFile.value = undefined;
const attributes = props.editor.getAttributes(props.type);
mediaForm.title = (attributes.title as string) || "";
mediaForm.width = (attributes.width as string) || "100%";
mediaForm.height = (attributes.height as string) || buildDefaultHeight();
}
function onFileChange(event: Event) {
const target = event.target as HTMLInputElement;
selectedFile.value = target.files?.[0];
target.value = "";
if (!selectedFile.value) {
return;
}
if (!mediaForm.title.trim()) {
mediaForm.title = selectedFile.value.name;
}
}
async function onConfirm() {
if (!props.editor) {
return;
}
if (!props.upload) {
await showTDesignMessage(appContext, "warning", `${typeLabel.value}上传功能未启用`);
return;
}
if (!selectedFile.value) {
await showTDesignMessage(appContext, "warning", `请选择${typeLabel.value}文件`);
return;
}
uploading.value = true;
try {
const file = selectedFile.value;
const result = await props.upload(file);
const title = mediaForm.title.trim() || result.name || file.name;
const attrs = {
src: result.url,
title,
width: mediaForm.width.trim() || "100%",
height: mediaForm.height.trim() || buildDefaultHeight(),
attachmentId: result.id,
tempFileId: result.id
};
if (props.type === "image") {
props.editor
.chain()
.focus()
.setImage({
src: attrs.src,
alt: title,
title
})
.updateAttributes("image", {
attachmentId: attrs.attachmentId,
tempFileId: attrs.tempFileId,
width: attrs.width,
height: attrs.height
})
.run();
} else {
props.editor
.chain()
.focus()
.insertContent({
type: "video",
attrs
})
.run();
}
visible.value = false;
} catch (error) {
console.error(`上传${typeLabel.value}失败:`, error);
await showTDesignMessage(appContext, "error", `上传${typeLabel.value}失败`);
} finally {
uploading.value = false;
}
}
</script>
<style lang="less" scoped>
.popconfirm-content {
--td-comp-margin-xxl: .5rem;
padding: 0 .5rem;
}
.file-input {
display: none;
}
</style>
@@ -1,99 +0,0 @@
<template>
<t-popconfirm
v-model:visible="visible"
trigger="click"
placement="bottom-right"
:show-arrow="false"
:confirm-btn="null"
:cancel-btn="null"
:popup-props="{ overlayClassName: 'tp-padding-pop' }"
@visible-change="onVisibleChange"
>
<template #icon><span></span></template>
<template #content>
<div class="padding-form">
<div class="item">
<div class="head">
<span class="label">上下内边距</span>
<span class="value" v-text="formatPaddingValue(paddingTB)" />
</div>
<t-slider :min="0" :max="6" :step=".25" :value="paddingTB" @change="onPaddingTBChange" />
</div>
<div class="item">
<div class="head">
<span class="label">左右内边距</span>
<span class="value" v-text="formatPaddingValue(paddingLR)" />
</div>
<t-slider :min="0" :max="16" :step=".25" :value="paddingLR" @change="onPaddingLRChange" />
</div>
</div>
</template>
<t-button v-popup="'内边距'" variant="outline">
<t-icon name="fullscreen-exit-1" />
</t-button>
</t-popconfirm>
</template>
<script lang="ts" setup>
import { formatPaddingValue, normalizeSliderValue } from "./shared";
defineOptions({
name: "TiptapPaddingButton"
});
const props = defineProps<{
paddingTB: number;
paddingLR: number;
}>();
const emit = defineEmits<{
"update:paddingTB": [value: number];
"update:paddingLR": [value: number];
}>();
const visible = ref(false);
function onVisibleChange(nextVisible: boolean) {
if (nextVisible) {
return;
}
}
function onPaddingTBChange(value: number | number[]) {
emit("update:paddingTB", normalizeSliderValue(value, props.paddingTB));
}
function onPaddingLRChange(value: number | number[]) {
emit("update:paddingLR", normalizeSliderValue(value, props.paddingLR));
}
</script>
<style lang="less" scoped>
.padding-form {
gap: .75rem;
width: 18rem;
display: flex;
flex-direction: column;
.item {
gap: .5rem;
display: flex;
flex-direction: column;
.head {
display: flex;
align-items: center;
justify-content: space-between;
.label {
color: var(--td-text-color-primary);
}
.value {
color: var(--td-text-color-secondary);
font-size: .75rem;
}
}
}
}
</style>
@@ -1,126 +0,0 @@
<template>
<t-popconfirm
v-model:visible="visible"
trigger="click"
placement="bottom"
:show-arrow="false"
:popup-props="{ overlayClassName: 'tp-table-pop' }"
@visible-change="onVisibleChange"
>
<template #icon><span></span></template>
<template #content>
<div class="table-picker" @mouseleave="onReset">
<div class="tip" v-text="`${hoverRow} × ${hoverCol}`" />
<div class="grid">
<button
v-for="item in tablePickerItems"
:key="item.key"
class="cell"
:class="{ active: item.active }"
type="button"
@mousemove="onHover(item.row, item.col)"
@click="onSelect(item.row, item.col)"
/>
</div>
</div>
</template>
<t-button v-popup="'插入表格'" variant="outline" :theme="active ? 'primary' : 'default'">
<t-icon name="table" />
</t-button>
</t-popconfirm>
</template>
<script lang="ts" setup>
import { TABLE_PICKER_SIZE } from "./shared";
import type { TiptapEditorInstance } from "./useTiptapEditor";
defineOptions({
name: "TiptapTableButton"
});
const props = defineProps<{
editor?: TiptapEditorInstance;
active: boolean;
}>();
const visible = ref(false);
const hoverRow = ref(1);
const hoverCol = ref(1);
const tablePickerItems = computed(() => {
const items: { key: string; row: number; col: number; active: boolean }[] = [];
for (let row = 1; row < TABLE_PICKER_SIZE + 1; row += 1) {
for (let col = 1; col < TABLE_PICKER_SIZE + 1; col += 1) {
items.push({
key: `${row}-${col}`,
row,
col,
active: row < hoverRow.value + 1 && col < hoverCol.value + 1
});
}
}
return items;
});
function onVisibleChange(nextVisible: boolean) {
if (!nextVisible) {
return;
}
hoverRow.value = 1;
hoverCol.value = 1;
}
function onReset() {
hoverRow.value = 1;
hoverCol.value = 1;
}
function onHover(row: number, col: number) {
hoverRow.value = row;
hoverCol.value = col;
}
function onSelect(row: number, col: number) {
if (!props.editor) {
return;
}
hoverRow.value = row;
hoverCol.value = col;
props.editor.chain().focus().insertTable({ rows: row, cols: col, withHeaderRow: true }).run();
visible.value = false;
}
</script>
<style lang="less" scoped>
.table-picker {
width: 10.5rem;
.tip {
height: 1.5rem;
color: var(--td-text-color-secondary);
font-size: .75rem;
line-height: 1.5rem;
}
.grid {
gap: .125rem;
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.cell {
width: 1.25rem;
height: 1.25rem;
padding: 0;
border: 1px solid var(--td-component-border);
cursor: pointer;
background: var(--td-bg-color-container);
transition: background .15s ease, border-color .15s ease;
&.active {
border-color: var(--td-brand-color);
background: var(--td-brand-color-light);
}
}
}
</style>
@@ -1,431 +0,0 @@
<template>
<div class="toolbar">
<div class="left" :class="{ 'toolbar-disabled': sourceCodeMode }">
<div class="fixed">
<t-button v-popup="'撤销'" variant="outline" :disabled="!canExec('undo')" @click="run('undo')">
<t-icon name="rollback" />
</t-button>
<t-button v-popup="'重做'" variant="outline" :disabled="!canExec('redo')" @click="run('redo')">
<t-icon name="rollfront" />
</t-button>
<t-button v-popup="'清除格式'" variant="outline" @click="run('clear')">
<t-icon name="clear-formatting" />
</t-button>
</div>
<div class="scroll">
<t-dropdown trigger="click" :options="headingOptions" :min-column-width="120" @click="onHeadingSelect">
<t-button v-popup="'标题'" variant="outline" :theme="isHeadingActive ? 'primary' : 'default'">
<t-icon name="text" />
</t-button>
</t-dropdown>
<t-button v-popup="'加粗'" variant="outline" :theme="isActive('bold') ? 'primary' : 'default'" @click="run('bold')">
<t-icon name="textformat-bold" />
</t-button>
<t-button v-popup="'斜体'" variant="outline" :theme="isActive('italic') ? 'primary' : 'default'" @click="run('italic')">
<t-icon name="textformat-italic" />
</t-button>
<t-button v-popup="'下划线'" variant="outline" :theme="isActive('underline') ? 'primary' : 'default'" @click="run('underline')">
<t-icon name="textformat-underline" />
</t-button>
<t-button v-popup="'删除线'" variant="outline" :theme="isActive('strike') ? 'primary' : 'default'" @click="run('strike')">
<t-icon name="textformat-strikethrough" />
</t-button>
<t-button v-popup="'前景色'" variant="outline" @click="pickColor('text')">
<t-icon name="textformat-color" />
</t-button>
<t-button v-popup="'背景色'" variant="outline" @click="pickColor('bg')">
<t-icon name="fill-color-1" />
</t-button>
<t-button v-popup="'有序列表'" variant="outline" :theme="isActive('orderedList') ? 'primary' : 'default'" @click="run('orderedList')">
<t-icon name="order-list" />
</t-button>
<tiptap-table-button :editor="editor" :active="isActive('table')" />
<t-button v-popup="'无序列表'" variant="outline" :theme="isActive('bulletList') ? 'primary' : 'default'" @click="run('bulletList')">
<t-icon name="bulletpoint" />
</t-button>
<t-button v-popup="'行内代码'" variant="outline" :theme="isActive('code') ? 'primary' : 'default'" @click="run('code')">
<t-icon name="highlighted-block" />
</t-button>
<tiptap-code-block-button :editor="editor" :active="isActive('codeBlock')" />
<t-button v-popup="'下标'" variant="outline" :theme="isActive('subscript') ? 'primary' : 'default'" @click="run('subscript')">
<t-icon name="subscript" />
</t-button>
<t-button v-popup="'上标'" variant="outline" :theme="isActive('superscript') ? 'primary' : 'default'" @click="run('superscript')">
<t-icon name="superscript" />
</t-button>
<t-button v-popup="'左对齐'" variant="outline" :theme="isAlignActive('left') ? 'primary' : 'default'" @click="setAlign('left')">
<t-icon name="format-vertical-align-left" />
</t-button>
<t-button v-popup="'居中对齐'" variant="outline" :theme="isAlignActive('center') ? 'primary' : 'default'" @click="setAlign('center')">
<t-icon name="format-vertical-align-center" />
</t-button>
<t-button v-popup="'右对齐'" variant="outline" :theme="isAlignActive('right') ? 'primary' : 'default'" @click="setAlign('right')">
<t-icon name="format-vertical-align-right" />
</t-button>
<t-button v-popup="'两端对齐'" variant="outline" :theme="isAlignActive('justify') ? 'primary' : 'default'" @click="setAlign('justify')">
<t-icon name="expand-horizontal" />
</t-button>
<t-button v-popup="'分割线'" variant="outline" @click="run('horizontalRule')">
<t-icon name="divider-1" />
</t-button>
<tiptap-link-button :editor="editor" :active="isActive('link')" />
<tiptap-media-button :editor="editor" :active="isActive('image')" type="image" :upload="uploadImage" />
<tiptap-media-button :editor="editor" :active="isActive('video')" type="video" :upload="uploadVideo" />
<tiptap-iframe-button :editor="editor" :active="isActive('iframe')" />
</div>
</div>
<div class="right">
<div v-if="showPaddingTool" class="right__group" :class="{ 'toolbar-disabled': sourceCodeMode }">
<tiptap-padding-button
:padding-t-b="paddingTB"
:padding-l-r="paddingLR"
@update:padding-t-b="emit('update:paddingTB', $event)"
@update:padding-l-r="emit('update:paddingLR', $event)"
/>
</div>
<t-button
v-popup="sourceCodeMode ? '富文本' : '源码'"
class="source-btn"
variant="outline"
:theme="sourceCodeMode ? 'primary' : 'default'"
@click="emit('toggle-source-code')"
>
<t-icon v-if="sourceCodeMode" name="edit-1" />
<t-icon v-else name="braces" />
</t-button>
</div>
<input ref="textColorRef" class="hidden" type="color" :value="textColor" @input="onTextColorChange" />
<input ref="bgColorRef" class="hidden" type="color" :value="bgColor" @input="onBgColorChange" />
</div>
</template>
<script lang="ts" setup>
import type { AlignType, UploadResult } from "./shared";
import { buildHeadingOptions } from "./shared";
import type { TiptapEditorInstance } from "./useTiptapEditor";
import TiptapCodeBlockButton from "./TiptapCodeBlockButton.vue";
import TiptapIframeButton from "./TiptapIframeButton.vue";
import TiptapLinkButton from "./TiptapLinkButton.vue";
import TiptapMediaButton from "./TiptapMediaButton.vue";
import TiptapPaddingButton from "./TiptapPaddingButton.vue";
import TiptapTableButton from "./TiptapTableButton.vue";
defineOptions({
name: "TiptapToolbar"
});
const props = withDefaults(defineProps<{
editor?: TiptapEditorInstance;
uploadImage?: (file: File) => Promise<UploadResult>;
uploadVideo?: (file: File) => Promise<UploadResult>;
paddingTB: number;
paddingLR: number;
showPaddingTool?: boolean;
sourceCodeMode?: boolean;
}>(), {
showPaddingTool: true,
sourceCodeMode: false
});
const emit = defineEmits<{
"update:paddingTB": [value: number];
"update:paddingLR": [value: number];
"toggle-source-code": [];
}>();
type RunType =
| "undo"
| "redo"
| "bold"
| "italic"
| "underline"
| "strike"
| "orderedList"
| "bulletList"
| "code"
| "subscript"
| "superscript"
| "horizontalRule"
| "clear";
const textColorRef = ref<HTMLInputElement>();
const bgColorRef = ref<HTMLInputElement>();
const textColor = ref("#000000");
const bgColor = ref("#ffff00");
const currentHeadingValue = computed(() => {
if (!props.editor) {
return "p";
}
for (let level = 1; level < 7; level += 1) {
if (props.editor.isActive("heading", { level })) {
return `h${level}`;
}
}
return "p";
});
const headingOptions = computed(() => {
return buildHeadingOptions(currentHeadingValue.value);
});
const isHeadingActive = computed(() => {
return currentHeadingValue.value !== "p";
});
function canExec(type: "undo" | "redo") {
if (!props.editor) {
return false;
}
switch (type) {
case "undo":
return props.editor.can().undo();
case "redo":
return props.editor.can().redo();
default:
return false;
}
}
function isActive(type: string) {
if (!props.editor) {
return false;
}
return props.editor.isActive(type);
}
function isAlignActive(align: AlignType) {
if (!props.editor) {
return false;
}
return props.editor.isActive({ textAlign: align });
}
function run(type: RunType) {
if (!props.editor) {
return;
}
const chain = props.editor.chain().focus();
switch (type) {
case "undo":
chain.undo().run();
return;
case "redo":
chain.redo().run();
return;
case "bold":
chain.toggleBold().run();
return;
case "italic":
chain.toggleItalic().run();
return;
case "underline":
chain.toggleUnderline().run();
return;
case "strike":
chain.toggleStrike().run();
return;
case "orderedList":
chain.toggleOrderedList().run();
return;
case "bulletList":
chain.toggleBulletList().run();
return;
case "code":
chain.toggleCode().run();
return;
case "subscript":
chain.toggleSubscript().run();
return;
case "superscript":
chain.toggleSuperscript().run();
return;
case "horizontalRule":
chain.setHorizontalRule().run();
return;
case "clear":
chain.clearNodes().unsetAllMarks().run();
return;
default:
}
}
function setAlign(align: AlignType) {
props.editor?.chain().focus().setTextAlign(align).run();
}
function onHeadingSelect(option: { value?: string | number | Record<string, unknown> }) {
if (!props.editor) {
return;
}
const value = `${option?.value || "p"}`;
const chain = props.editor.chain().focus();
if (value === "p") {
chain.setParagraph().run();
return;
}
const level = Number(value.replace("h", ""));
if (0 < level && level < 7) {
chain.setHeading({ level: level as 1 | 2 | 3 | 4 | 5 | 6 }).run();
}
}
function pickColor(type: "text" | "bg") {
if (type === "text") {
textColorRef.value?.click();
return;
}
bgColorRef.value?.click();
}
function onTextColorChange(event: Event) {
if (!props.editor) {
return;
}
const target = event.target as HTMLInputElement;
textColor.value = target.value;
props.editor.chain().focus().setColor(target.value).run();
}
function onBgColorChange(event: Event) {
if (!props.editor) {
return;
}
const target = event.target as HTMLInputElement;
bgColor.value = target.value;
props.editor.chain().focus().setMark("textStyle", { backgroundColor: target.value }).run();
}
</script>
<style lang="less" scoped>
.toolbar {
display: flex;
position: relative;
margin-top: -1px;
justify-content: space-between;
&:after {
content: "";
width: 100%;
height: 1px;
bottom: 0;
position: absolute;
background: var(--td-component-border);
}
.hidden {
display: none;
}
.left,
.right {
gap: 0;
display: flex;
flex-wrap: nowrap;
:deep(.t-button) {
z-index: 1;
position: relative;
margin-left: -1px;
border-radius: 0;
--td-comp-paddingLR-l: 8px;
&:hover,
&:focus-visible,
&.t-is-active,
&.t-button--theme-primary {
z-index: 2;
}
}
}
.left {
flex: 1;
min-width: 0;
.fixed,
.scroll {
gap: 0;
display: flex;
flex-wrap: nowrap;
}
.fixed {
flex: none;
}
.scroll {
flex: 1;
min-width: 0;
> * {
flex: none;
}
:deep(.t-button:last-child) {
margin-right: 3rem;
}
}
}
.right {
flex: none;
margin-right: -1px;
.right__group {
display: flex;
}
}
.toolbar-disabled {
opacity: .55;
pointer-events: none;
}
.source-btn {
margin-left: -1px;
border-radius: 0;
}
}
@media screen and (max-width: 48rem) {
.toolbar {
justify-content: flex-start;
.left {
.scroll {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
}
}
:global(.tp-heading-option) {
display: block;
line-height: 1.2;
}
.heading-size(@name, @size, @weight) {
:global(.tp-heading-option.@{name}) {
font-size: @size;
font-weight: @weight;
}
}
.heading-size(p, 1rem, 400);
.heading-size(h1, 2rem, 700);
.heading-size(h2, 1.5rem, 700);
.heading-size(h3, 1.17rem, 700);
.heading-size(h4, 1rem, 700);
.heading-size(h5, .83rem, 700);
.heading-size(h6, .67rem, 700);
</style>
-15
View File
@@ -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<string>;
getPendingMediaCount: () => number;
editor?: TiptapEditorInstance;
};
export const TiptapEditor = Toolkit.withInstall(view);
export default TiptapEditor;
-617
View File
@@ -1,617 +0,0 @@
<template>
<div class="tp-editor">
<tiptap-toolbar
:editor="editor || undefined"
:padding-t-b="articlePadding.paddingTB"
:padding-l-r="articlePadding.paddingLR"
:show-padding-tool="showPaddingTool"
:source-code-mode="sourceCodeMode"
:upload-image="handleInsertImage"
:upload-video="handleInsertVideo"
@update:padding-t-b="articlePadding.paddingTB = $event"
@update:padding-l-r="articlePadding.paddingLR = $event"
@toggle-source-code="toggleSourceCodeMode"
/>
<div class="content">
<code-editor
v-if="sourceCodeMode"
ref="sourceEditorRef"
v-model="sourceCode"
class="source-editor"
language="html"
placeholder="请输入 HTML 源码"
/>
<div v-else-if="editor" class="value-wrap">
<editor-search-bar
ref="searchBarRef"
:visible="searchVisible"
:expanded="replaceExpanded"
:keyword="searchKeyword"
:replace-value="replaceKeyword"
:status-error="searchError"
:match-index="searchMatchIndex"
:match-count="searchMatchList.length"
:case-sensitive="searchOptions.caseSensitive"
:use-regex="searchOptions.useRegex"
@update:expanded="replaceExpanded = $event"
@update:keyword="searchKeyword = $event"
@update:replace-value="replaceKeyword = $event"
@update:case-sensitive="searchOptions.caseSensitive = $event"
@update:use-regex="searchOptions.useRegex = $event"
@previous="findPrevious"
@next="onSearchNext"
@replace="replaceCurrent"
@replace-all="replaceAll"
@close="closeSearch"
/>
<editor-content
class="value"
:style="editorBodyStyle"
:editor="editor"
:spellcheck="false"
@keydown.capture="onRichEditorKeydown"
/>
</div>
<t-empty v-else description="编辑器初始化中" />
</div>
</div>
</template>
<script lang="ts" setup>
import CodeEditor from "../code-editor/index.vue";
import EditorSearchBar from "../editor-search/index.vue";
import {
buildSearchRegex,
replaceMatchText,
type TextSearchOptions
} from "../editor-search/search";
import Toolkit from "../../utils/Toolkit";
import type { PendingMediaItem, UploadMediaType, UploadResult } from "./shared";
import TiptapToolbar from "./TiptapToolbar.vue";
import { useTiptapEditor } from "./useTiptapEditor";
import {
ATTACHMENT_ID_ATTR,
LOCAL_MEDIA_ID_PREFIX,
TEMP_FILE_ID_ATTR,
isLocalMediaId,
normalizeContent
} from "./shared";
defineOptions({
name: "TiptapEditor"
});
type EditorSearchBarExpose = {
focusKeyword: (selectAll?: boolean) => void;
focusReplace: (selectAll?: boolean) => void;
};
type CodeEditorExpose = {
openSearch: (showReplace: boolean) => void;
};
type RichTextMatch = {
from: number;
to: number;
text: string;
};
const props = withDefaults(defineProps<{
modelValue: string;
placeholder?: string;
paddingTB?: number;
paddingLR?: number;
showPaddingTool?: boolean;
uploadImage?: (file: File) => Promise<UploadResult>;
uploadVideo?: (file: File) => Promise<UploadResult>;
}>(), {
modelValue: "",
placeholder: "请输入内容",
paddingTB: 1,
paddingLR: 1.25,
showPaddingTool: true
});
const emit = defineEmits<{
"update:modelValue": [value: string];
"change": [value: string];
"update:paddingTB": [value: number];
"update:paddingLR": [value: number];
}>();
const { EditorContent, articlePadding, editor, editorBodyStyle } = useTiptapEditor(props, emit);
const pendingMediaMap = reactive(new Map<string, PendingMediaItem>());
const sourceCodeMode = ref(false);
const sourceCode = ref("");
const sourceEditorRef = ref<CodeEditorExpose>();
const searchBarRef = ref<EditorSearchBarExpose>();
const searchVisible = ref(false);
const replaceExpanded = ref(false);
const searchKeyword = ref("");
const replaceKeyword = ref("");
const searchError = ref("");
const searchMatchIndex = ref(-1);
const searchMatchList = ref<RichTextMatch[]>([]);
const searchOptions = reactive<TextSearchOptions>({
caseSensitive: false,
useRegex: false
});
function buildLocalMediaId() {
return `${LOCAL_MEDIA_ID_PREFIX}${Toolkit.uuid()}`;
}
function revokeMediaUrl(url?: string) {
if (url?.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
function removePendingMedia(id: string) {
const item = pendingMediaMap.get(id);
if (!item) {
return;
}
revokeMediaUrl(item.url);
pendingMediaMap.delete(id);
}
function clearPendingMedia() {
Array.from(pendingMediaMap.keys()).forEach(removePendingMedia);
}
function collectMediaIds(content?: string) {
const rawContent = normalizeContent(content);
if (!rawContent) {
return new Set<string>();
}
const parser = new DOMParser();
const doc = parser.parseFromString(rawContent, "text/html");
const mediaList = Array.from(doc.querySelectorAll("img, video"));
return new Set(
mediaList
.map(item => item.getAttribute(ATTACHMENT_ID_ATTR) || item.getAttribute(TEMP_FILE_ID_ATTR) || "")
.filter(Boolean)
);
}
function syncPendingMedia(content?: string) {
const mediaIdSet = collectMediaIds(content);
Array.from(pendingMediaMap.keys()).forEach((id) => {
if (!mediaIdSet.has(id)) {
removePendingMedia(id);
}
});
}
async function handleInsertMedia(file: File, type: UploadMediaType): Promise<UploadResult> {
const id = buildLocalMediaId();
const url = URL.createObjectURL(file);
pendingMediaMap.set(id, {
id,
type,
name: file.name,
file,
url
});
return {
id,
url,
name: file.name
};
}
async function handleInsertImage(file: File) {
return handleInsertMedia(file, "image");
}
async function handleInsertVideo(file: File) {
return handleInsertMedia(file, "video");
}
async function submit() {
const instance = editor.value;
if (!instance) {
return normalizeContent(props.modelValue);
}
const rawContent = normalizeContent(instance.getHTML());
if (!rawContent) {
clearPendingMedia();
return "";
}
const parser = new DOMParser();
const doc = parser.parseFromString(rawContent, "text/html");
const mediaList = Array.from(doc.querySelectorAll("img, video"));
const uploadedMediaIdList: string[] = [];
for (const media of mediaList) {
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || media.getAttribute(TEMP_FILE_ID_ATTR) || "";
if (!isLocalMediaId(attachmentId)) {
continue;
}
const pendingMedia = pendingMediaMap.get(attachmentId);
if (!pendingMedia) {
continue;
}
const uploadFn = pendingMedia.type === "image" ? props.uploadImage : props.uploadVideo;
if (!uploadFn) {
throw new Error(`未配置${pendingMedia.type === "image" ? "图片" : "视频"}上传方法`);
}
const result = await uploadFn(pendingMedia.file);
media.setAttribute("src", result.url);
media.setAttribute(ATTACHMENT_ID_ATTR, result.id);
media.setAttribute(TEMP_FILE_ID_ATTR, result.id);
if (media.tagName.toLowerCase() === "img") {
media.setAttribute("alt", result.name || pendingMedia.name);
media.setAttribute("title", result.name || pendingMedia.name);
} else {
media.setAttribute("title", result.name || pendingMedia.name);
}
uploadedMediaIdList.push(attachmentId);
}
const nextContent = normalizeContent(doc.body.innerHTML);
if (nextContent !== rawContent) {
instance.commands.setContent(nextContent || "<p></p>");
}
uploadedMediaIdList.forEach(removePendingMedia);
syncPendingMedia(nextContent);
return nextContent;
}
function syncSourceCode(value?: string) {
sourceCode.value = value || "";
}
function onSourceCodeChange(value: string) {
sourceCode.value = value;
emit("change", value);
emit("update:modelValue", value);
}
function getSelectedEditorText() {
const instance = editor.value;
if (!instance) {
return "";
}
const { from, to } = instance.state.selection;
if (from === to) {
return "";
}
return instance.state.doc.textBetween(from, to, "\n", "\n");
}
function refreshSearchMatchState() {
const instance = editor.value;
if (!instance || sourceCodeMode.value) {
searchError.value = "";
searchMatchList.value = [];
searchMatchIndex.value = -1;
return;
}
const { regex, error } = buildSearchRegex(searchKeyword.value, searchOptions, true);
searchError.value = error;
if (!regex || error || !searchKeyword.value) {
searchMatchList.value = [];
searchMatchIndex.value = -1;
return;
}
const matchList: RichTextMatch[] = [];
instance.state.doc.descendants((node, pos) => {
if (!node.isText || !node.text) {
return;
}
regex.lastIndex = 0;
let match = regex.exec(node.text);
while (match) {
if (!match[0]) {
searchError.value = "正则不能匹配空文本";
searchMatchList.value = [];
searchMatchIndex.value = -1;
return false;
}
matchList.push({
from: pos + match.index,
to: pos + match.index + match[0].length,
text: match[0]
});
match = regex.exec(node.text);
}
});
searchMatchList.value = matchList;
const { from, to } = instance.state.selection;
searchMatchIndex.value = matchList.findIndex((match) => match.from === from && match.to === to);
if (0 > searchMatchIndex.value && 0 < matchList.length) {
searchMatchIndex.value = 0;
}
}
function openSearch(showReplace: boolean) {
if (sourceCodeMode.value) {
sourceEditorRef.value?.openSearch(showReplace);
return;
}
searchVisible.value = true;
replaceExpanded.value = showReplace;
const selectedText = getSelectedEditorText();
if (selectedText) {
searchKeyword.value = selectedText;
}
refreshSearchMatchState();
nextTick(() => {
searchBarRef.value?.focusKeyword(true);
});
}
function closeSearch() {
searchVisible.value = false;
replaceExpanded.value = false;
editor.value?.commands.focus();
}
function selectMatch(match: RichTextMatch, matchIndex: number) {
const instance = editor.value;
if (!instance) {
return false;
}
instance.chain().focus().setTextSelection({ from: match.from, to: match.to }).scrollIntoView().run();
searchMatchIndex.value = matchIndex;
return true;
}
function findNext() {
const instance = editor.value;
if (!instance || searchError.value || 1 > searchMatchList.value.length) {
return false;
}
const currentTo = instance.state.selection.to;
const nextIndex = searchMatchList.value.findIndex((match) => match.from >= currentTo);
const targetIndex = 0 <= nextIndex ? nextIndex : 0;
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
}
function findPrevious() {
const instance = editor.value;
if (!instance || searchError.value || 1 > searchMatchList.value.length) {
return false;
}
const currentFrom = instance.state.selection.from;
let targetIndex = searchMatchList.value.length - 1;
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
if (searchMatchList.value[index].from < currentFrom) {
targetIndex = index;
break;
}
}
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
}
function replaceCurrent() {
const instance = editor.value;
if (!instance || !searchKeyword.value) {
return false;
}
let currentMatchIndex = searchMatchList.value.findIndex((match) => (
match.from === instance.state.selection.from && match.to === instance.state.selection.to
));
if (0 > currentMatchIndex) {
if (!findNext()) {
return false;
}
currentMatchIndex = searchMatchIndex.value;
}
const currentMatch = searchMatchList.value[currentMatchIndex];
if (!currentMatch) {
return false;
}
const nextText = replaceMatchText(currentMatch.text, searchKeyword.value, replaceKeyword.value, searchOptions);
const transaction = instance.state.tr.insertText(nextText, currentMatch.from, currentMatch.to);
instance.view.dispatch(transaction);
refreshSearchMatchState();
nextTick(() => {
const nextMatch = searchMatchList.value[currentMatchIndex] || searchMatchList.value[currentMatchIndex - 1];
if (!nextMatch) {
instance.commands.focus();
return;
}
selectMatch(nextMatch, Math.min(currentMatchIndex, searchMatchList.value.length - 1));
});
return true;
}
function replaceAll() {
const instance = editor.value;
if (!instance || !searchKeyword.value) {
return 0;
}
if (searchError.value || 1 > searchMatchList.value.length) {
refreshSearchMatchState();
return 0;
}
const replaceCount = searchMatchList.value.length;
const transaction = instance.state.tr;
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
const match = searchMatchList.value[index];
const nextText = replaceMatchText(match.text, searchKeyword.value, replaceKeyword.value, searchOptions);
transaction.insertText(nextText, match.from, match.to);
}
instance.view.dispatch(transaction);
refreshSearchMatchState();
nextTick(() => {
if (searchMatchList.value[0]) {
selectMatch(searchMatchList.value[0], 0);
return;
}
instance.commands.focus();
});
return replaceCount;
}
function onSearchNext(shiftKey: boolean) {
if (shiftKey) {
findPrevious();
return;
}
findNext();
}
function onRichEditorKeydown(event: KeyboardEvent) {
if (sourceCodeMode.value || event.altKey || !(event.ctrlKey || event.metaKey)) {
return;
}
const key = event.key.toLowerCase();
if ("f" === key) {
event.preventDefault();
openSearch(false);
return;
}
if ("h" === key) {
event.preventDefault();
openSearch(true);
}
}
function toggleSourceCodeMode() {
const nextMode = !sourceCodeMode.value;
closeSearch();
if (nextMode) {
syncSourceCode(normalizeContent(editor.value?.getHTML()) || props.modelValue);
sourceCodeMode.value = true;
return;
}
sourceCodeMode.value = false;
const nextContent = normalizeContent(sourceCode.value);
if (editor.value) {
editor.value.commands.setContent(nextContent || "<p></p>");
}
}
watch(() => props.modelValue, (value) => {
syncPendingMedia(value);
syncSourceCode(value);
}, { immediate: true });
watch(sourceCode, (value) => {
if (!sourceCodeMode.value) {
return;
}
onSourceCodeChange(value);
});
watch([searchKeyword, () => searchOptions.caseSensitive, () => searchOptions.useRegex, sourceCodeMode], () => {
refreshSearchMatchState();
});
watch(() => editor.value?.state.selection.from, () => {
if (!searchVisible.value || sourceCodeMode.value) {
return;
}
refreshSearchMatchState();
});
onBeforeUnmount(() => {
clearPendingMedia();
});
defineExpose({
editor,
submit,
getPendingMediaCount: () => pendingMediaMap.size,
openSearch,
closeSearch,
findNext,
findPrevious,
replaceCurrent,
replaceAll
});
</script>
<style lang="less" scoped>
.tp-editor {
width: 100%;
height: 100%;
border: 1px solid var(--td-component-border);
display: flex;
flex-direction: column;
.content {
flex: 1;
width: 100%;
height: 100%;
cursor: text;
padding: 2rem 10rem;
display: flex;
overflow: auto;
box-sizing: border-box;
background: var(--td-bg-color-page);
.value-wrap {
flex: 1;
width: 100%;
height: 100%;
min-width: 0;
min-height: 100%;
position: relative;
}
.value {
flex: 1;
width: 100%;
height: 100%;
min-width: 0;
min-height: 100%;
overflow: auto;
background: var(--td-bg-color-container);
box-shadow: var(--td-shadow-1);
border-radius: var(--td-radius-default);
:deep(.editor-body) {
outline: none;
padding: var(--tp-editor-padding-tb) var(--tp-editor-padding-lr);
min-height: 100%;
box-sizing: border-box;
pre[class*='language-'] > code {
padding: .25rem .5rem;
}
p.is-editor-empty:first-child:before {
height: 0;
float: left;
color: var(--td-text-color-placeholder);
content: attr(data-placeholder);
pointer-events: none;
}
iframe.tp-iframe {
margin-right: auto;
margin-left: auto;
}
}
}
.source-editor {
flex: 1;
width: 100%;
height: 100%;
min-width: 0;
min-height: 100%;
background: var(--td-bg-color-container);
border-radius: var(--td-radius-default);
box-shadow: var(--td-shadow-1);
}
}
}
@media screen and (max-width: 48rem) {
.tp-editor {
.content {
padding: 0;
}
}
}
</style>
-6
View File
@@ -1,6 +0,0 @@
import view from "./index.vue";
import Toolkit from "../../utils/Toolkit";
export const TiptapView = Toolkit.withInstall(view);
export default TiptapView;
-93
View File
@@ -1,93 +0,0 @@
<template>
<div class="tp-view selectable">
<div v-if="renderContent && editor" ref="contentRef" class="content-wrap" :data-max-height="maxHeight">
<editor-content class="content" :editor="editor" />
</div>
<div v-else class="empty">
<span v-text="emptyText" />
</div>
</div>
</template>
<script lang="ts" setup>
import {EditorContent, useEditor} from "@tiptap/vue-3";
import {buildTiptapExtensions} from "../tiptap-editor/extensions";
import {normalizeContent} from "../tiptap-editor/shared";
import {enhancePrismCodeBlock} from "../../utils/PrismCodeBlock";
defineOptions({
name: "TiptapView"
});
const props = withDefaults(defineProps<{
content?: string;
emptyText?: string;
maxHeight?: string;
}>(), {
content: "",
emptyText: "暂无内容",
maxHeight: "400px"
});
const renderContent = computed(() => {
return normalizeContent(props.content);
});
const contentRef = ref<HTMLElement>();
async function syncCodeBlock() {
await nextTick();
if (contentRef.value) {
enhancePrismCodeBlock(contentRef.value);
}
}
const editor = useEditor({
editable: false,
extensions: buildTiptapExtensions(),
content: renderContent.value || "<p></p>",
editorProps: {
attributes: {
class: "editor-body tp-content-body"
}
}
});
watch(() => [renderContent.value, props.maxHeight] as const, ([value]) => {
if (!editor.value) {
return;
}
editor.value.commands.setContent(value || "<p></p>", {
emitUpdate: false
});
void syncCodeBlock();
}, { immediate: true });
watch(editor, () => {
void syncCodeBlock();
}, { flush: "post" });
onBeforeUnmount(() => {
editor.value?.destroy();
});
</script>
<style lang="less" scoped>
.tp-view {
width: 100%;
.empty {
padding: 1rem;
border-radius: .375rem;
color: var(--td-text-color-placeholder);
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
}
.content {
border: 1px solid var(--td-component-border);
padding: 1rem;
min-height: 12rem;
background: var(--td-bg-color-container);
}
}
</style>
@@ -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 ATTACHMENT_ID_PREFIX = "attachment-id:";
export const TEMP_FILE_ID_PREFIX = "temp-file-id:"; export const TEMP_FILE_ID_PREFIX = "temp-file-id:";
export const ARTICLE_IMAGE_ATTACH_TYPE = "CONTENT_IMAGE"; export const ARTICLE_IMAGE_ATTACH_TYPE = "CONTENT_IMAGE";
export const ARTICLE_VIDEO_ATTACH_TYPE = "CONTENT_VIDEO"; export const ARTICLE_VIDEO_ATTACH_TYPE = "CONTENT_VIDEO";
/** 支持的文章内容类型和默认类型。 */
export const DEFAULT_ARTICLE_CONTENT_TYPE: ArticleContentType = "TIP_TAP"; export const DEFAULT_ARTICLE_CONTENT_TYPE: ArticleContentType = "TIP_TAP";
export const ARTICLE_CONTENT_TYPE_LIST: ArticleContentType[] = ["TEXT", "MARKDOWN", "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 { 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; return ARTICLE_CONTENT_TYPE_LIST.includes(contentType as ArticleContentType) ? contentType as ArticleContentType : DEFAULT_ARTICLE_CONTENT_TYPE;
} }
/** 将空内容转换为空字符串,避免编辑器收到 undefined。 */
export function normalizeRawArticleContent(content?: string) { export function normalizeRawArticleContent(content?: string) {
return content || ""; return content || "";
} }
/** 从媒体 src 中读取持久化附件 ID。 */
export function getAttachmentIdFromSrc(src?: string) { export function getAttachmentIdFromSrc(src?: string) {
if (!src?.startsWith(ATTACHMENT_ID_PREFIX)) { if (!src?.startsWith(ATTACHMENT_ID_PREFIX)) {
return ""; return "";
@@ -28,6 +32,7 @@ export function getAttachmentIdFromSrc(src?: string) {
return src.slice(ATTACHMENT_ID_PREFIX.length); return src.slice(ATTACHMENT_ID_PREFIX.length);
} }
/** 从媒体 src 中读取临时文件 ID。 */
export function getTempFileIdFromSrc(src?: string) { export function getTempFileIdFromSrc(src?: string) {
if (!src?.startsWith(TEMP_FILE_ID_PREFIX)) { if (!src?.startsWith(TEMP_FILE_ID_PREFIX)) {
return ""; return "";
@@ -35,14 +40,17 @@ export function getTempFileIdFromSrc(src?: string) {
return src.slice(TEMP_FILE_ID_PREFIX.length); return src.slice(TEMP_FILE_ID_PREFIX.length);
} }
/** 构造带有附件 ID 的内部媒体地址。 */
export function buildAttachmentIdSrc(attachmentId: string) { export function buildAttachmentIdSrc(attachmentId: string) {
return `${ATTACHMENT_ID_PREFIX}${attachmentId}`; return `${ATTACHMENT_ID_PREFIX}${attachmentId}`;
} }
/** 根据媒体标签判断附件类型。 */
function getMediaAttachType(tagName: string) { function getMediaAttachType(tagName: string) {
return tagName.toLowerCase() === "video" ? ARTICLE_VIDEO_ATTACH_TYPE : ARTICLE_IMAGE_ATTACH_TYPE; return tagName.toLowerCase() === "video" ? ARTICLE_VIDEO_ATTACH_TYPE : ARTICLE_IMAGE_ATTACH_TYPE;
} }
/** 仅为 blob 预览地址保留预览元数据,服务器地址不重复保存。 */
function buildAttachmentMetadata(src: string) { function buildAttachmentMetadata(src: string) {
if (!src.startsWith("blob:")) { if (!src.startsWith("blob:")) {
return undefined; return undefined;
@@ -52,6 +60,7 @@ function buildAttachmentMetadata(src: string) {
}; };
} }
/** 过滤本地临时媒体 ID,避免把前端占位 ID 当成可上传附件。 */
function normalizeAttachmentTempFileId(tempFileId?: string) { function normalizeAttachmentTempFileId(tempFileId?: string) {
if (!tempFileId || isLocalMediaId(tempFileId)) { if (!tempFileId || isLocalMediaId(tempFileId)) {
return undefined; return undefined;
@@ -59,6 +68,7 @@ function normalizeAttachmentTempFileId(tempFileId?: string) {
return tempFileId; return tempFileId;
} }
/** 从文章 HTML 中提取图片和视频附件,并按 ID 去重。 */
export function buildAttachmentListFromContent(content?: string) { export function buildAttachmentListFromContent(content?: string) {
const rawContent = normalizeContent(content); const rawContent = normalizeContent(content);
if (!rawContent) { if (!rawContent) {
@@ -89,6 +99,7 @@ export function buildAttachmentListFromContent(content?: string) {
return Array.from(attachmentMap.values()); return Array.from(attachmentMap.values());
} }
/** 将媒体地址统一改写为附件地址,供后端保存文章内容。 */
export function normalizeContentForStore(content?: string) { export function normalizeContentForStore(content?: string) {
const rawContent = normalizeContent(content); const rawContent = normalizeContent(content);
if (!rawContent) { if (!rawContent) {
@@ -112,6 +123,7 @@ export function normalizeContentForStore(content?: string) {
return normalizeContent(doc.body.innerHTML); return normalizeContent(doc.body.innerHTML);
} }
/** 比较附件列表的业务字段,不比较对象引用。 */
export function sameAttachmentList(left?: Attachment[], right?: Attachment[]) { export function sameAttachmentList(left?: Attachment[], right?: Attachment[]) {
const leftList = left || []; const leftList = left || [];
const rightList = right || []; const rightList = right || [];
@@ -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; start: number;
end: number; end: number;
}; };
@@ -20,6 +27,7 @@ const WRAP_CHAR_MAP: Record<string, string> = {
}; };
const CLOSE_CHAR_LIST = new Set(Object.values(WRAP_CHAR_MAP)); const CLOSE_CHAR_LIST = new Set(Object.values(WRAP_CHAR_MAP));
/** 根据键盘事件计算代码编辑器应执行的文本变更。 */
export const resolveCodeEditorKeydown = ( export const resolveCodeEditorKeydown = (
value: string, value: string,
selection: EditorSelection, selection: EditorSelection,
@@ -40,7 +48,16 @@ export const resolveCodeEditorKeydown = (
return resolvePairInput(value, selection, event.key); 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 = ( const resolveTabInput = (
value: string, value: string,
selection: EditorSelection, selection: EditorSelection,
@@ -89,7 +106,6 @@ const resolveTabInput = (
}; };
}; };
// 回车时继承当前行缩进,位于块级起始括号后时额外增加一层缩进
const resolveEnterInput = ( const resolveEnterInput = (
value: string, value: string,
selection: EditorSelection selection: EditorSelection
@@ -129,7 +145,6 @@ const resolveEnterInput = (
}; };
}; };
// 处理成对符号输入,选中文本时直接包裹
const resolvePairInput = ( const resolvePairInput = (
value: string, value: string,
selection: EditorSelection, selection: EditorSelection,
@@ -151,7 +166,6 @@ const resolvePairInput = (
return undefined; return undefined;
}; };
// 光标位于成对符号中间时,退格一次同时删除左右字符
const resolvePairBackspace = ( const resolvePairBackspace = (
value: string, value: string,
selection: EditorSelection selection: EditorSelection
@@ -174,7 +188,6 @@ const resolvePairBackspace = (
}; };
}; };
// 将当前选区替换为一对包裹字符,并把光标移动到合理位置
const wrapSelection = ( const wrapSelection = (
value: string, value: string,
selection: EditorSelection, selection: EditorSelection,
@@ -206,3 +219,11 @@ const getLineIndent = (value: string) => {
return match?.[0] ?? ""; return match?.[0] ?? "";
}; };
function escapeHTML(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
+15
View File
@@ -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";
@@ -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 const DEFAULT_NOTIFY_CONTENT_TYPE: NotifyContentType = "TIP_TAP";
/** 规范化通知内容类型,兼容旧通知未返回内容类型的情况 */ /** 规范化通知内容类型,兼容旧通知未返回内容类型的情况。 */
export function normalizeNotifyContentType(contentType?: string): NotifyContentType { export function normalizeNotifyContentType(contentType?: string): NotifyContentType {
return contentType === "TEXT" ? "TEXT" : DEFAULT_NOTIFY_CONTENT_TYPE; return contentType === "TEXT" ? "TEXT" : DEFAULT_NOTIFY_CONTENT_TYPE;
} }
@@ -1,19 +1,23 @@
/** 编辑器文本搜索、匹配和替换的无界面实现。 */
export type TextSearchOptions = { export type TextSearchOptions = {
caseSensitive: boolean; caseSensitive: boolean;
useRegex: boolean; useRegex: boolean;
}; };
/** 单个匹配项在原文本中的半开区间。 */
export type TextSearchMatch = { export type TextSearchMatch = {
start: number; start: number;
end: number; end: number;
text: string; text: string;
}; };
/** 搜索结果;error 为空表示搜索过程正常。 */
export type TextSearchResult = { export type TextSearchResult = {
matches: TextSearchMatch[]; matches: TextSearchMatch[];
error: string; error: string;
}; };
/** 根据搜索选项构造正则标志。 */
const buildFlags = (options: TextSearchOptions, global = true) => { const buildFlags = (options: TextSearchOptions, global = true) => {
let flags = global ? "g" : ""; let flags = global ? "g" : "";
if (!options.caseSensitive) { if (!options.caseSensitive) {
@@ -22,10 +26,12 @@ const buildFlags = (options: TextSearchOptions, global = true) => {
return flags; return flags;
}; };
/** 转义普通文本搜索中的正则特殊字符。 */
export const escapeSearchKeyword = (value: string) => ( export const escapeSearchKeyword = (value: string) => (
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
); );
/** 构造搜索正则,并拒绝无效或可匹配空文本的表达式。 */
export const buildSearchRegex = ( export const buildSearchRegex = (
keyword: string, keyword: string,
options: TextSearchOptions, options: TextSearchOptions,
@@ -53,6 +59,7 @@ export const buildSearchRegex = (
} }
}; };
/** 查找全部匹配项并返回文本位置。 */
export const findTextMatches = ( export const findTextMatches = (
value: string, value: string,
keyword: string, keyword: string,
@@ -87,6 +94,7 @@ export const findTextMatches = (
}; };
}; };
/** 替换单个匹配项;正则模式下使用捕获组替换规则。 */
export const replaceMatchText = ( export const replaceMatchText = (
matchText: string, matchText: string,
keyword: string, keyword: string,
@@ -103,6 +111,7 @@ export const replaceMatchText = (
return matchText.replace(regex, replaceText); return matchText.replace(regex, replaceText);
}; };
/** 替换全部匹配项,并返回实际替换数量。 */
export const replaceAllText = ( export const replaceAllText = (
value: string, value: string,
keyword: string, keyword: string,
@@ -131,4 +140,3 @@ export const replaceAllText = (
error: "" error: ""
}; };
}; };
@@ -1,3 +1,7 @@
/**
* 带 Prism 装饰的 Tiptap 代码块。
* ProseMirror 文档只保存纯文本,语法高亮通过 Decoration 叠加,避免污染编辑内容。
*/
import CodeBlock from "@tiptap/extension-code-block"; import CodeBlock from "@tiptap/extension-code-block";
import { findChildren, mergeAttributes } from "@tiptap/vue-3"; import { findChildren, mergeAttributes } from "@tiptap/vue-3";
import type { Node as ProsemirrorNode } from "@tiptap/pm/model"; 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 Prism from "prismjs";
import { resolvePrismLanguage } from "../../utils/Prism"; import { resolvePrismLanguage } from "../../utils/Prism";
/** Prism 分词后用于生成 Decoration 的扁平节点。 */
type HighlightNode = { type HighlightNode = {
text: string; text: string;
classes: string[]; classes: string[];
}; };
/** Prism Token 的递归结构。 */
type PrismToken = { type PrismToken = {
type: string; type: string;
alias?: string | string[]; alias?: string | string[];
content: string | PrismToken | Array<string | PrismToken>; content: string | PrismToken | Array<string | PrismToken>;
}; };
/** 用于判断事务影响范围的最小结构。 */
type StepRange = { type StepRange = {
from?: number; from?: number;
to?: number; to?: number;
}; };
/** 将语言名称转换为代码块 class 名。 */
function buildLanguageClassName(languageClassPrefix: string | null | undefined, language?: string | null) { function buildLanguageClassName(languageClassPrefix: string | null | undefined, language?: string | null) {
if (!languageClassPrefix || !language) { if (!languageClassPrefix || !language) {
return null; return null;
@@ -30,11 +38,13 @@ function buildLanguageClassName(languageClassPrefix: string | null | undefined,
return `${languageClassPrefix}${language}`; return `${languageClassPrefix}${language}`;
} }
/** 生成 Prism token 对应的 class 列表。 */
function buildTokenClassList(type: string, alias?: string | string[]) { function buildTokenClassList(type: string, alias?: string | string[]) {
const aliasList = Array.isArray(alias) ? alias : alias ? [alias] : []; const aliasList = Array.isArray(alias) ? alias : alias ? [alias] : [];
return ["token", type, ...aliasList]; return ["token", type, ...aliasList];
} }
/** 将嵌套 Prism token 展平成文本片段,便于生成行内装饰。 */
function parsePrismTokens(tokens: Array<string | PrismToken>, className: string[] = []): HighlightNode[] { function parsePrismTokens(tokens: Array<string | PrismToken>, className: string[] = []): HighlightNode[] {
return tokens.flatMap((token) => { return tokens.flatMap((token) => {
if (typeof token === "string") { if (typeof token === "string") {
@@ -58,6 +68,7 @@ function parsePrismTokens(tokens: Array<string | PrismToken>, className: string[
}); });
} }
/** 为文档中的全部代码块生成语法高亮装饰。 */
function getDecorations({ function getDecorations({
doc, doc,
name, name,
@@ -89,6 +100,7 @@ function getDecorations({
return DecorationSet.create(doc, decorations); return DecorationSet.create(doc, decorations);
} }
/** 创建只在代码块内容或结构受影响时重新计算高亮的插件。 */
function createPrismPlugin({ function createPrismPlugin({
name, name,
defaultLanguage defaultLanguage
@@ -143,6 +155,7 @@ function createPrismPlugin({
return prismPlugin; return prismPlugin;
} }
/** 自定义代码块渲染和 Prism 装饰插件。 */
export default CodeBlock.extend({ export default CodeBlock.extend({
renderHTML({ node, HTMLAttributes }: { node: ProsemirrorNode; HTMLAttributes: Record<string, unknown> }) { renderHTML({ node, HTMLAttributes }: { node: ProsemirrorNode; HTMLAttributes: Record<string, unknown> }) {
const languageClassName = buildLanguageClassName(this.options.languageClassPrefix, node.attrs.language); const languageClassName = buildLanguageClassName(this.options.languageClassPrefix, node.attrs.language);
@@ -171,4 +184,3 @@ export default CodeBlock.extend({
]; ];
} }
}); });
@@ -1,3 +1,4 @@
/** 为只读代码块补充行号、折叠和展开交互。 */
export function enhancePrismCodeBlock(root: ParentNode) { export function enhancePrismCodeBlock(root: ParentNode) {
const codeList = Array.from(root.querySelectorAll("pre > code[class*='language-'], pre[class*='language-'] > code")); 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) { function syncPreLanguageClass(pre: HTMLElement, code: HTMLElement) {
const classList = Array.from(code.classList).filter((item) => item.startsWith("language-")); const classList = Array.from(code.classList).filter((item) => item.startsWith("language-"));
if (!classList.length) { if (!classList.length) {
@@ -47,6 +49,7 @@ function syncPreLanguageClass(pre: HTMLElement, code: HTMLElement) {
} }
} }
/** 清除上一次增强生成的行号节点,允许重复增强。 */
function resetCodeBlock(code: HTMLElement) { function resetCodeBlock(code: HTMLElement) {
code.parentElement?.classList.remove("has-line-numbers"); code.parentElement?.classList.remove("has-line-numbers");
const codes = code.querySelector(":scope > .codes"); const codes = code.querySelector(":scope > .codes");
@@ -55,6 +58,7 @@ function resetCodeBlock(code: HTMLElement) {
} }
} }
/** 去除 Tiptap 代码块首尾的格式化换行。 */
function normalizeCodeContent(code: HTMLElement) { function normalizeCodeContent(code: HTMLElement) {
const firstNode = code.firstChild; const firstNode = code.firstChild;
if (firstNode instanceof Text && firstNode.data.startsWith("\n")) { if (firstNode instanceof Text && firstNode.data.startsWith("\n")) {
@@ -73,6 +77,7 @@ function normalizeCodeContent(code: HTMLElement) {
} }
} }
/** 从最近的 data-max-height 容器读取代码块最大高度。 */
function resolveCodeBlockMaxHeight(pre: HTMLElement) { function resolveCodeBlockMaxHeight(pre: HTMLElement) {
const container = pre.closest("[data-max-height]"); const container = pre.closest("[data-max-height]");
if (!(container instanceof HTMLElement)) { if (!(container instanceof HTMLElement)) {
@@ -81,6 +86,7 @@ function resolveCodeBlockMaxHeight(pre: HTMLElement) {
return container.dataset.maxHeight || ""; return container.dataset.maxHeight || "";
} }
/** 计算代码块行数,空代码块至少显示一行。 */
function buildLineCount(text: string) { function buildLineCount(text: string) {
if (!text) { if (!text) {
return 1; return 1;
@@ -88,6 +94,7 @@ function buildLineCount(text: string) {
return Math.max(text.split("\n").length, 1); return Math.max(text.split("\n").length, 1);
} }
/** 创建与代码行数对应的行号 DOM。 */
function buildLineNumberRows(lineCount: number) { function buildLineNumberRows(lineCount: number) {
const rows = document.createElement("span"); const rows = document.createElement("span");
rows.className = "line-numbers-rows diselect"; rows.className = "line-numbers-rows diselect";
@@ -99,6 +106,7 @@ function buildLineNumberRows(lineCount: number) {
return rows; return rows;
} }
/** 为超过阈值的代码块绑定双击展开和恢复行为。 */
function bindCodeBlockToggle(pre: HTMLElement, code: HTMLElement, lineCount: number, maxHeight: string) { function bindCodeBlockToggle(pre: HTMLElement, code: HTMLElement, lineCount: number, maxHeight: string) {
pre.classList.remove("expand"); pre.classList.remove("expand");
pre.dataset.lineCount = `${lineCount}`; pre.dataset.lineCount = `${lineCount}`;
@@ -120,4 +128,3 @@ function bindCodeBlockToggle(pre: HTMLElement, code: HTMLElement, lineCount: num
window.getSelection()?.removeAllRanges(); window.getSelection()?.removeAllRanges();
}; };
} }
@@ -1,3 +1,7 @@
/**
* Tiptap 扩展集合。
* 图片、视频和 iframe 在这里统一定义尺寸及附件属性,保证编辑和只读视图使用同一套文档模型。
*/
import { mergeAttributes, Node } from "@tiptap/vue-3"; import { mergeAttributes, Node } from "@tiptap/vue-3";
import Color from "@tiptap/extension-color"; import Color from "@tiptap/extension-color";
import Image from "@tiptap/extension-image"; import Image from "@tiptap/extension-image";
@@ -17,10 +21,12 @@ import StarterKit from "@tiptap/starter-kit";
import PrismCodeBlock from "./PrismCodeBlock"; import PrismCodeBlock from "./PrismCodeBlock";
import { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR } from "./shared"; import { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR } from "./shared";
/** 拼接非空样式声明。 */
function buildStyleString(styleList: Array<string | null | undefined>) { function buildStyleString(styleList: Array<string | null | undefined>) {
return styleList.filter(Boolean).join("; "); return styleList.filter(Boolean).join("; ");
} }
/** 创建图片、视频和 iframe 共用的尺寸属性。 */
function buildNodeSizeAttributes(defaultHeight = "auto") { function buildNodeSizeAttributes(defaultHeight = "auto") {
return { return {
width: { width: {
@@ -34,6 +40,7 @@ function buildNodeSizeAttributes(defaultHeight = "auto") {
}; };
} }
/** 将节点原有样式与尺寸属性合并为 style 字符串。 */
function buildNodeSizeStyle(attributes: Record<string, string>) { function buildNodeSizeStyle(attributes: Record<string, string>) {
return buildStyleString([ return buildStyleString([
attributes.style, attributes.style,
@@ -42,6 +49,7 @@ function buildNodeSizeStyle(attributes: Record<string, string>) {
]); ]);
} }
/** 扩展文字样式,补充背景色属性。 */
const ExtTextStyle = TextStyle.extend({ const ExtTextStyle = TextStyle.extend({
addGlobalAttributes() { addGlobalAttributes() {
return [ return [
@@ -67,6 +75,7 @@ const ExtTextStyle = TextStyle.extend({
} }
}); });
/** 扩展图片节点,支持 base64、尺寸和附件属性。 */
const ExtImage = Image.configure({ const ExtImage = Image.configure({
allowBase64: true allowBase64: true
}).extend({ }).extend({
@@ -111,6 +120,7 @@ const ExtImage = Image.configure({
} }
}); });
/** 扩展视频节点,作为可拖拽的原子块保存。 */
const ExtVideo = Node.create({ const ExtVideo = Node.create({
name: "video", name: "video",
group: "block", group: "block",
@@ -176,6 +186,7 @@ const ExtVideo = Node.create({
} }
}); });
/** 扩展 iframe 节点,限制为块级原子节点并保存尺寸。 */
const ExtIframe = Node.create({ const ExtIframe = Node.create({
name: "iframe", name: "iframe",
group: "block", group: "block",
@@ -221,10 +232,13 @@ const ExtIframe = Node.create({
} }
}); });
/** 创建编辑器和只读视图共用的 Tiptap 扩展。 */
export function buildTiptapExtensions(placeholder?: string): Extensions { export function buildTiptapExtensions(placeholder?: string): Extensions {
return [ return [
StarterKit.configure({ StarterKit.configure({
codeBlock: false codeBlock: false,
link: false,
underline: false
}), }),
PrismCodeBlock.configure({ PrismCodeBlock.configure({
languageClassPrefix: "language-" languageClassPrefix: "language-"
@@ -256,4 +270,3 @@ export function buildTiptapExtensions(placeholder?: string): Extensions {
}) })
]; ];
} }
@@ -1,11 +1,14 @@
/** Tiptap 编辑器共享类型、常量和数据归一化方法。 */
import { h } from "vue"; import { h } from "vue";
/** 上传接口返回的媒体信息。 */
export type UploadResult = { export type UploadResult = {
id: string; id: string;
url: string; url: string;
name?: string; name?: string;
}; };
/** 编辑器待上传媒体的本地状态。 */
export type UploadMediaType = "image" | "video"; export type UploadMediaType = "image" | "video";
export type PendingMediaItem = { export type PendingMediaItem = {
id: string; id: string;
@@ -15,13 +18,16 @@ export type PendingMediaItem = {
url: string; url: string;
}; };
/** 文本对齐方式。 */
export type AlignType = "left" | "center" | "right" | "justify"; export type AlignType = "left" | "center" | "right" | "justify";
/** 富文本媒体节点使用的附件属性名。 */
export const ATTACHMENT_ID_ATTR = "data-attachment-id"; export const ATTACHMENT_ID_ATTR = "data-attachment-id";
export const TEMP_FILE_ID_ATTR = "data-temp-file-id"; export const TEMP_FILE_ID_ATTR = "data-temp-file-id";
export const TABLE_PICKER_SIZE = 7; export const TABLE_PICKER_SIZE = 7;
export const LOCAL_MEDIA_ID_PREFIX = "local-media:"; export const LOCAL_MEDIA_ID_PREFIX = "local-media:";
/** 工具栏支持的标题选项。 */
export const headingMeta = [ export const headingMeta = [
{ label: "正文", value: "p", className: "p" }, { label: "正文", value: "p", className: "p" },
{ label: "标题 1", value: "h1", className: "h1" }, { label: "标题 1", value: "h1", className: "h1" },
@@ -32,6 +38,7 @@ export const headingMeta = [
{ label: "标题 6", value: "h6", className: "h6" } { label: "标题 6", value: "h6", className: "h6" }
] as const; ] as const;
/** 将标题元数据转换为 TDesign 无关的菜单选项结构。 */
export function buildHeadingOptions(currentHeadingValue: string) { export function buildHeadingOptions(currentHeadingValue: string) {
return headingMeta.map((item) => { return headingMeta.map((item) => {
const active = currentHeadingValue === item.value; const active = currentHeadingValue === item.value;
@@ -47,6 +54,7 @@ export function buildHeadingOptions(currentHeadingValue: string) {
}); });
} }
/** 清理空段落并统一 HTML 外层空白。 */
export function normalizeContent(value?: string) { export function normalizeContent(value?: string) {
const text = (value || "").trim(); const text = (value || "").trim();
if (!text || text === "<p></p>") { if (!text || text === "<p></p>") {
@@ -58,6 +66,7 @@ export function normalizeContent(value?: string) {
return normalized === "<p></p>" ? "" : normalized; return normalized === "<p></p>" ? "" : normalized;
} }
/** 将内边距转换为 0 到 16 rem 范围内的数值。 */
export function normalizePaddingValue(value: unknown, fallback: number) { export function normalizePaddingValue(value: unknown, fallback: number) {
const nextValue = typeof value === "number" ? value : Number(value); const nextValue = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(nextValue)) { if (!Number.isFinite(nextValue)) {
@@ -66,16 +75,18 @@ export function normalizePaddingValue(value: unknown, fallback: number) {
return Math.min(Math.max(nextValue, 0), 16); return Math.min(Math.max(nextValue, 0), 16);
} }
/** 兼容滑块组件返回单值或数组值。 */
export function normalizeSliderValue(value: number | number[], fallback: number) { export function normalizeSliderValue(value: number | number[], fallback: number) {
const nextValue = Array.isArray(value) ? value[0] : value; const nextValue = Array.isArray(value) ? value[0] : value;
return normalizePaddingValue(nextValue, fallback); return normalizePaddingValue(nextValue, fallback);
} }
/** 将内边距数值格式化为 CSS rem 值。 */
export function formatPaddingValue(value: number) { export function formatPaddingValue(value: number) {
return `${Number(value.toFixed(2))}rem`; return `${Number(value.toFixed(2))}rem`;
} }
/** 判断媒体 ID 是否为尚未提交到服务器的本地 ID。 */
export function isLocalMediaId(id?: string | null) { export function isLocalMediaId(id?: string | null) {
return !!id && id.startsWith(LOCAL_MEDIA_ID_PREFIX); return !!id && id.startsWith(LOCAL_MEDIA_ID_PREFIX);
} }
@@ -1,8 +1,45 @@
import type { Editor } from "@tiptap/vue-3"; /** Tiptap 编辑器状态管理,封装内容同步、内边距同步和生命周期清理。 */
import { EditorContent, useEditor } from "@tiptap/vue-3"; 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 { buildTiptapExtensions } from "./extensions";
import { normalizeContent, normalizePaddingValue } from "./shared"; 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<string, unknown>;
getHTML: (...args: any[]) => string;
};
type TiptapEditorProps = { type TiptapEditorProps = {
modelValue: string; modelValue: string;
paddingTB?: number; paddingTB?: number;
@@ -10,14 +47,74 @@ type TiptapEditorProps = {
placeholder?: string; placeholder?: string;
}; };
type UpdateModelValue = { type TiptapContentEmit = {
(event: "update:modelValue", value: string): void; (event: "update:modelValue", value: string): void;
(event: "change", 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({ const articlePadding = reactive({
paddingTB: normalizePaddingValue(props.paddingTB, 1), paddingTB: normalizePaddingValue(props.paddingTB, 1),
paddingLR: normalizePaddingValue(props.paddingLR, 1.25) paddingLR: normalizePaddingValue(props.paddingLR, 1.25)
@@ -25,7 +122,7 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue
const lastEmittedContent = ref(""); const lastEmittedContent = ref("");
const pendingEmitContent = ref<string | null>(null); const pendingEmitContent = ref<string | null>(null);
// 代码块输入期间只在编辑器内部累积,离开代码块或失焦后再统一同步 // 代码块输入期间只在编辑器内部累积,离开代码块或失焦后再统一同步。
function flushPendingContent() { function flushPendingContent() {
if (pendingEmitContent.value === null) { if (pendingEmitContent.value === null) {
return; return;
@@ -59,7 +156,8 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue
editorProps: { editorProps: {
attributes: { attributes: {
class: "editor-body tp-content-body tp-editor-body" class: "editor-body tp-content-body tp-editor-body"
} },
handleKeyDown: handleTiptapKeyDown
}, },
onBlur: () => { onBlur: () => {
flushPendingContent(); flushPendingContent();
@@ -81,7 +179,7 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue
emit("change", nextContent); emit("change", nextContent);
emit("update:modelValue", nextContent); emit("update:modelValue", nextContent);
} }
}); }) as unknown as ShallowRef<TiptapEditorInstance | undefined>;
watch(() => props.modelValue, (value) => { watch(() => props.modelValue, (value) => {
if (!editor.value) { if (!editor.value) {
@@ -96,7 +194,7 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue
if (nextContent === lastEmittedContent.value) { if (nextContent === lastEmittedContent.value) {
return; return;
} }
// 聚焦编辑时禁止外部回灌,避免重建选区后把光标拉回开头 // 聚焦编辑时禁止外部回灌,避免重建选区后把光标拉回开头。
if (editor.value.isFocused) { if (editor.value.isFocused) {
return; return;
} }
@@ -112,11 +210,11 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue
}, { immediate: true }); }, { immediate: true });
watch(() => articlePadding.paddingTB, (value) => { watch(() => articlePadding.paddingTB, (value) => {
emit("update:paddingTB", value); onPaddingUpdate?.("update:paddingTB", value);
}); });
watch(() => articlePadding.paddingLR, (value) => { watch(() => articlePadding.paddingLR, (value) => {
emit("update:paddingLR", value); onPaddingUpdate?.("update:paddingLR", value);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -124,12 +222,9 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue
}); });
return { return {
EditorContent, EditorContent: EditorContent as unknown as Component,
articlePadding, articlePadding,
editor, editor,
editorBodyStyle editorBodyStyle
}; };
} }
export type TiptapEditorInstance = Editor;
+61
View File
@@ -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<HTMLElement>();
const editor = useEditor({
editable: false,
extensions: buildTiptapExtensions(),
content: content.value || "<p></p>",
editorProps: {
attributes: {
class: "editor-body tp-content-body"
}
}
}) as unknown as ShallowRef<TiptapEditorInstance | undefined>;
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 || "<p></p>", {
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
};
}
+1 -1
View File
@@ -6,12 +6,12 @@ import "./assets/style/variable.less";
import "./assets/style/timi-web.less"; import "./assets/style/timi-web.less";
import "prismjs/themes/prism.css"; import "prismjs/themes/prism.css";
import "./components/prism-code-block.less"; import "./components/prism-code-block.less";
import "./components/tiptap-content.less";
import { Network } from "./utils"; import { Network } from "./utils";
import VPopup from "./utils/directives/Popup"; import VPopup from "./utils/directives/Popup";
export * from "./api"; export * from "./api";
export * from "./components"; export * from "./components";
export * from "./editor";
export * from "./store"; export * from "./store";
export * from "./types"; export * from "./types";
export * from "./utils"; export * from "./utils";
+18 -1
View File
@@ -1,5 +1,23 @@
import Prism from "prismjs"; 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<string, string> = { const prismLanguageAliasMap: Record<string, string> = {
html: "markup", html: "markup",
xml: "markup", xml: "markup",
@@ -16,4 +34,3 @@ export function resolvePrismLanguage(language?: string | null) {
const nextLanguage = prismLanguageAliasMap[language] || language; const nextLanguage = prismLanguageAliasMap[language] || language;
return Prism.languages[nextLanguage] ? nextLanguage : ""; return Prism.languages[nextLanguage] ? nextLanguage : "";
} }
-27
View File
@@ -1,27 +0,0 @@
import type { AppContext } from "vue";
type MessageType = "info" | "success" | "warning" | "error";
type MessageHandler = (content: string) => unknown | Promise<unknown>;
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);
};
+2 -11
View File
@@ -35,23 +35,14 @@ export default defineConfig({
}, },
rollupOptions: { rollupOptions: {
external: [ external: [
"vue", "vue"
"tdesign-vue-next"
], ],
output: { output: {
globals: { globals: {
vue: "Vue", vue: "Vue"
"tdesign-vue-next": "TDesign"
} }
} }
}, },
commonjsOptions: {
esmExternals: [
"vue",
"tdesign-vue-next"
],
requireReturnsDefault: false
},
minify: "terser" minify: "terser"
}, },
plugins: [ plugins: [