refactor editor
This commit is contained in:
+6
-2
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-94
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import view from "./index.vue";
|
|
||||||
import Toolkit from "../../utils/Toolkit";
|
|
||||||
|
|
||||||
export const ArticleView = Toolkit.withInstall(view);
|
|
||||||
export default ArticleView;
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
@@ -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("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll("\"", """)
|
|
||||||
.replaceAll("'", "'");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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
@@ -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";
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import view from "./index.vue";
|
|
||||||
import Toolkit from "../../utils/Toolkit";
|
|
||||||
|
|
||||||
export const NotifyView = Toolkit.withInstall(view);
|
|
||||||
export default NotifyView;
|
|
||||||
@@ -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>
|
|
||||||
@@ -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 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import view from "./index.vue";
|
|
||||||
import Toolkit from "../../utils/Toolkit";
|
|
||||||
|
|
||||||
export const TiptapView = Toolkit.withInstall(view);
|
|
||||||
export default TiptapView;
|
|
||||||
|
|
||||||
@@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
@@ -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: ""
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
+13
-1
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
+110
-15
@@ -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;
|
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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 : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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: [
|
||||||
|
|||||||
Reference in New Issue
Block a user