Remove unused rich form widgets

This commit is contained in:
2026-06-09 09:34:11 +08:00
parent 42e49a1b34
commit 94e422582d
24 changed files with 1 additions and 6981 deletions
-429
View File
@@ -1,429 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@tiptap/core':
specifier: ^3.15.3
version: 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-code-block-lowlight':
specifier: ^3.20.4
version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/extension-code-block@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@tiptap/pm':
specifier: ^3.15.3
version: 3.20.4
'@vben-core/menu-ui':
specifier: workspace:*
version: link:../../packages/@core/ui-kit/menu-ui
'@vben/access':
specifier: workspace:*
version: link:../../packages/effects/access
'@vben/common-ui':
specifier: workspace:*
version: link:../../packages/effects/common-ui
'@vben/constants':
specifier: workspace:*
version: link:../../packages/constants
'@vben/hooks':
specifier: workspace:*
version: link:../../packages/effects/hooks
'@vben/icons':
specifier: workspace:*
version: link:../../packages/icons
'@vben/layouts':
specifier: workspace:*
version: link:../../packages/effects/layouts
'@vben/locales':
specifier: workspace:*
version: link:../../packages/locales
'@vben/plugins':
specifier: workspace:*
version: link:../../packages/effects/plugins
'@vben/preferences':
specifier: workspace:*
version: link:../../packages/preferences
'@vben/request':
specifier: workspace:*
version: link:../../packages/effects/request
'@vben/stores':
specifier: workspace:*
version: link:../../packages/stores
'@vben/styles':
specifier: workspace:*
version: link:../../packages/styles
'@vben/types':
specifier: workspace:*
version: link:../../packages/types
'@vben/utils':
specifier: workspace:*
version: link:../../packages/utils
lowlight:
specifier: ^3.3.0
version: 3.3.0
packages:
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
'@tiptap/core@3.20.4':
resolution: {integrity: sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==}
peerDependencies:
'@tiptap/pm': ^3.20.4
'@tiptap/extension-code-block-lowlight@3.20.4':
resolution: {integrity: sha512-YE+OxuvQx3oXGzSkhRyQzCGXOWrVntdTUQgRfOu5Ky+ZtScPWCVsTwUP8SBGBQjqp3sbbBehZznipbUIpWWJDA==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/extension-code-block': ^3.20.4
'@tiptap/pm': ^3.20.4
highlight.js: ^11
lowlight: ^2 || ^3
'@tiptap/extension-code-block@3.20.4':
resolution: {integrity: sha512-Zlw3FrXTy01+o1yISeX/LC+iJeHA+ym602bMXGmtA6lyl7QSOSO7WExweJ6xeJGhbCjldwT5al6fkRAs8iGJZg==}
peerDependencies:
'@tiptap/core': ^3.20.4
'@tiptap/pm': ^3.20.4
'@tiptap/pm@3.20.4':
resolution: {integrity: sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==}
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
markdown-it@14.1.1:
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
hasBin: true
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
prosemirror-changeset@2.4.0:
resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==}
prosemirror-collab@1.3.1:
resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
prosemirror-commands@1.7.1:
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
prosemirror-dropcursor@1.8.2:
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
prosemirror-gapcursor@1.4.1:
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
prosemirror-history@1.5.0:
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
prosemirror-inputrules@1.5.1:
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
prosemirror-keymap@1.2.3:
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
prosemirror-markdown@1.13.4:
resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==}
prosemirror-menu@1.3.0:
resolution: {integrity: sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==}
prosemirror-model@1.25.4:
resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
prosemirror-schema-basic@1.2.4:
resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
prosemirror-schema-list@1.5.1:
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
prosemirror-state@1.4.4:
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
prosemirror-tables@1.8.5:
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
prosemirror-trailing-node@3.0.0:
resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
peerDependencies:
prosemirror-model: ^1.22.1
prosemirror-state: ^1.4.2
prosemirror-view: ^1.33.8
prosemirror-transform@1.11.0:
resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==}
prosemirror-view@1.41.7:
resolution: {integrity: sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
snapshots:
'@remirror/core-constants@3.0.0': {}
'@tiptap/core@3.20.4(@tiptap/pm@3.20.4)':
dependencies:
'@tiptap/pm': 3.20.4
'@tiptap/extension-code-block-lowlight@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/extension-code-block@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/extension-code-block': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)
'@tiptap/pm': 3.20.4
highlight.js: 11.11.1
lowlight: 3.3.0
'@tiptap/extension-code-block@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
dependencies:
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
'@tiptap/pm': 3.20.4
'@tiptap/pm@3.20.4':
dependencies:
prosemirror-changeset: 2.4.0
prosemirror-collab: 1.3.1
prosemirror-commands: 1.7.1
prosemirror-dropcursor: 1.8.2
prosemirror-gapcursor: 1.4.1
prosemirror-history: 1.5.0
prosemirror-inputrules: 1.5.1
prosemirror-keymap: 1.2.3
prosemirror-markdown: 1.13.4
prosemirror-menu: 1.3.0
prosemirror-model: 1.25.4
prosemirror-schema-basic: 1.2.4
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7)
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.7
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.3
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdurl@2.0.0': {}
'@types/unist@3.0.3': {}
argparse@2.0.1: {}
crelt@1.0.6: {}
dequal@2.0.3: {}
devlop@1.1.0:
dependencies:
dequal: 2.0.3
entities@4.5.0: {}
escape-string-regexp@4.0.0: {}
highlight.js@11.11.1: {}
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
lowlight@3.3.0:
dependencies:
'@types/hast': 3.0.4
devlop: 1.1.0
highlight.js: 11.11.1
markdown-it@14.1.1:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
mdurl@2.0.0: {}
orderedmap@2.1.1: {}
prosemirror-changeset@2.4.0:
dependencies:
prosemirror-transform: 1.11.0
prosemirror-collab@1.3.1:
dependencies:
prosemirror-state: 1.4.4
prosemirror-commands@1.7.1:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-dropcursor@1.8.2:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.7
prosemirror-gapcursor@1.4.1:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-view: 1.41.7
prosemirror-history@1.5.0:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.7
rope-sequence: 1.3.4
prosemirror-inputrules@1.5.1:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-keymap@1.2.3:
dependencies:
prosemirror-state: 1.4.4
w3c-keyname: 2.2.8
prosemirror-markdown@1.13.4:
dependencies:
'@types/markdown-it': 14.1.2
markdown-it: 14.1.1
prosemirror-model: 1.25.4
prosemirror-menu@1.3.0:
dependencies:
crelt: 1.0.6
prosemirror-commands: 1.7.1
prosemirror-history: 1.5.0
prosemirror-state: 1.4.4
prosemirror-model@1.25.4:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-basic@1.2.4:
dependencies:
prosemirror-model: 1.25.4
prosemirror-schema-list@1.5.1:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-state@1.4.4:
dependencies:
prosemirror-model: 1.25.4
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.7
prosemirror-tables@1.8.5:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.7
prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.7):
dependencies:
'@remirror/core-constants': 3.0.0
escape-string-regexp: 4.0.0
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-view: 1.41.7
prosemirror-transform@1.11.0:
dependencies:
prosemirror-model: 1.25.4
prosemirror-view@1.41.7:
dependencies:
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-transform: 1.11.0
punycode.js@2.3.1: {}
rope-sequence@1.3.4: {}
uc.micro@2.1.0: {}
w3c-keyname@2.2.8: {}
@@ -1,205 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { CircleStencil, Cropper } from 'vue-advanced-cropper';
import 'vue-advanced-cropper/dist/style.css';
import { IconifyIcon } from '@vben/icons';
import { ElButton, ElDialog } from 'element-plus';
interface ImageCropperProps {
modelValue: boolean;
imageSrc: string;
aspectRatio?: number; // 宽高比,例如 16/9, 4/3, 1 (正方形), undefined (自由裁剪)
shape?: 'circle' | 'rect'; // 裁剪形状
}
interface ImageCropperEmits {
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm', result: { blob: Blob; canvas: HTMLCanvasElement }): void;
}
const props = withDefaults(defineProps<ImageCropperProps>(), {
aspectRatio: undefined,
shape: 'rect',
});
const emit = defineEmits<ImageCropperEmits>();
const visible = ref(false);
const cropperRef = ref<InstanceType<typeof Cropper>>();
// 计算裁剪组件类型
const stencilComponent = computed(() => {
return props.shape === 'circle' ? CircleStencil : undefined;
});
// 计算裁剪组件属性
const stencilProps = computed(() => {
const baseProps: any = {};
// 圆形裁剪时,aspectRatio 必须为 1
if (props.shape === 'circle') {
baseProps.aspectRatio = 1;
} else if (props.aspectRatio !== undefined) {
baseProps.aspectRatio = props.aspectRatio;
}
return baseProps;
});
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newVal) => {
visible.value = newVal;
},
{ immediate: true },
);
// 监听 visible 变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal);
});
// 向左旋转 90 度
function handleRotateLeft() {
cropperRef.value?.rotate(-90);
}
// 向右旋转 90 度
function handleRotateRight() {
cropperRef.value?.rotate(90);
}
// 水平翻转
function handleFlipHorizontal() {
cropperRef.value?.flip(true, false);
}
// 垂直翻转
function handleFlipVertical() {
cropperRef.value?.flip(false, true);
}
// 重置
function handleReset() {
cropperRef.value?.reset();
}
// 取消
function handleCancel() {
visible.value = false;
}
// 关闭
function handleClose() {
visible.value = false;
}
// 确认裁剪
async function handleConfirm() {
const result = cropperRef.value?.getResult();
if (!result) {
return;
}
const canvas = result.canvas;
if (!canvas) {
return;
}
// 将 canvas 转换为 Blob
canvas.toBlob((blob) => {
if (blob) {
emit('confirm', { canvas, blob });
visible.value = false;
}
}, 'image/png');
}
</script>
<template>
<ElDialog
v-model="visible"
title="裁剪图片"
width="800px"
:close-on-click-modal="false"
append-to-body
@close="handleClose"
>
<div class="image-cropper-container">
<Cropper
ref="cropperRef"
:src="imageSrc"
:stencil-component="stencilComponent"
:stencil-props="stencilProps"
class="cropper"
/>
</div>
<template #footer>
<div class="cropper-footer">
<div class="cropper-actions">
<ElButton @click="handleRotateLeft">
<IconifyIcon icon="i-carbon:rotate-counterclockwise" class="mr-1" />
向左旋转
</ElButton>
<ElButton @click="handleRotateRight">
<IconifyIcon icon="i-carbon:rotate-clockwise" class="mr-1" />
向右旋转
</ElButton>
<ElButton @click="handleFlipHorizontal">
<IconifyIcon icon="i-carbon:arrows-horizontal" class="mr-1" />
水平翻转
</ElButton>
<ElButton @click="handleFlipVertical">
<IconifyIcon icon="i-carbon:arrows-vertical" class="mr-1" />
垂直翻转
</ElButton>
<ElButton @click="handleReset">
<IconifyIcon icon="i-carbon:reset" class="mr-1" />
重置
</ElButton>
</div>
<div class="cropper-confirm">
<ElButton @click="handleCancel">取消</ElButton>
<ElButton type="primary" @click="handleConfirm">确认裁剪</ElButton>
</div>
</div>
</template>
</ElDialog>
</template>
<style scoped lang="scss">
.image-cropper-container {
width: 100%;
height: 500px;
background: hsl(var(--muted));
.cropper {
width: 100%;
height: 100%;
}
}
.cropper-footer {
display: flex;
flex-direction: column;
gap: 16px;
.cropper-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.cropper-confirm {
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
</style>
@@ -1 +0,0 @@
export { default as ImageCropper } from './image-cropper.vue';
@@ -1,807 +0,0 @@
<script setup lang="ts">
import type { AiOcrConfig } from '#/components/form-design/store/formDesignStore';
import { computed, ref, watch } from 'vue';
import {
DeleteOutlined,
EyeOutlined,
FileImageOutlined,
FileOutlined,
Sparkles,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElDialog,
ElImageViewer,
ElMessage,
ElProgress,
ElTag,
} from 'element-plus';
import { uploadFile as uploadFileApi } from '#/api/core/file';
import { requestClient } from '#/api/request';
import { getFileUrl } from '#/composables/useFileUrl';
defineOptions({
name: 'AiImageOcr',
inheritAttrs: false,
});
const props = withDefaults(defineProps<Props>(), {
multiple: false,
placeholder: () => $t('form-design.aiImageOcr.uploadPlaceholder'),
disabled: false,
clearable: true,
maxSize: 10,
});
const emit = defineEmits<Emits>();
interface Props {
modelValue?: string | string[];
multiple?: boolean;
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
maxSize?: number;
height?: number;
aiOcrConfig?: AiOcrConfig;
formData?: Record<string, any>;
}
interface Emits {
(e: 'update:modelValue', value: string | string[] | undefined): void;
(e: 'change', value: string | string[] | undefined): void;
(
e: 'ocr-success',
data: { extractedData: null | Record<string, any>; rawText: string },
): void;
(e: 'fill-fields', data: Record<string, any>): void;
}
// 状态
const uploadInputRef = ref<HTMLInputElement>();
const isUploading = ref(false);
const isRecognizing = ref(false);
const uploadProgress = ref(0);
const currentImageId = ref<string>('');
const currentImageUrl = ref<string>('');
const currentFileMimeType = ref<string>('');
const previewVisible = ref(false);
const ocrResult = ref<null | {
extractedData: null | Record<string, any>;
rawText: string;
}>(null);
// 文件类型到accept属性的映射
const FILE_TYPE_ACCEPT_MAP: Record<string, string[]> = {
image: ['image/*'],
text: [
'.txt',
'.md',
'.log',
'.py',
'.js',
'.ts',
'.jsx',
'.tsx',
'.vue',
'.html',
'.css',
'.json',
'.yaml',
'.yml',
'.xml',
'.csv',
],
pdf: ['.pdf', 'application/pdf'],
word: [
'.doc',
'.docx',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
excel: [
'.xls',
'.xlsx',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
};
// 计算属性
const hasFile = computed(() => !!currentImageId.value);
// 计算接受的文件类型
const acceptString = computed(() => {
const types = props.aiOcrConfig?.acceptFileTypes || ['image'];
if (types.includes('all')) {
return '*/*';
}
const accepts: string[] = [];
types.forEach((type) => {
if (FILE_TYPE_ACCEPT_MAP[type]) {
accepts.push(...FILE_TYPE_ACCEPT_MAP[type]);
}
});
return accepts.join(',') || 'image/*';
});
// 判断是否为图片文件
const isImageFile = computed(() => {
if (!currentImageId.value) return false;
const url = currentImageUrl.value.toLowerCase();
const mimeType = currentFileMimeType.value.toLowerCase();
return (
mimeType.startsWith('image/') ||
url.includes('image/') ||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url)
);
});
// 判断是否为PDF文件
const isPdfFile = computed(() => {
if (!currentImageId.value) return false;
const url = currentImageUrl.value.toLowerCase();
const mimeType = currentFileMimeType.value.toLowerCase();
return (
mimeType === 'application/pdf' ||
url.includes('application/pdf') ||
url.endsWith('.pdf')
);
});
const templateTypeLabel = computed(() => {
const labels: Record<string, string> = {
custom: $t('form-design.aiImageOcr.customTemplate'),
id_card: $t('form-design.aiImageOcr.idCard'),
business_license: $t('form-design.aiImageOcr.businessLicense'),
invoice: $t('form-design.aiImageOcr.invoice'),
receipt: $t('form-design.aiImageOcr.receipt'),
contract: $t('form-design.aiImageOcr.contract'),
};
return labels[props.aiOcrConfig?.templateType || 'custom'] || labels.custom;
});
// 获取支持的文件类型标签
const acceptedFileTypesLabel = computed(() => {
const types = props.aiOcrConfig?.acceptFileTypes || ['image'];
if (types.includes('all')) {
return $t('form-design.aiImageOcr.allFiles');
}
const labels: Record<string, string> = {
image: $t('form-design.aiImageOcr.imageFile'),
text: $t('form-design.aiImageOcr.textFile'),
pdf: 'PDF',
word: 'Word',
excel: 'Excel',
};
return types.map((t) => labels[t] || t).join(', ');
});
// 监听 modelValue 变化
watch(
() => props.modelValue,
async (newValue) => {
if (!newValue) {
currentImageId.value = '';
currentImageUrl.value = '';
currentFileMimeType.value = '';
return;
}
const id = Array.isArray(newValue) ? newValue[0] : newValue;
if (id && id !== currentImageId.value) {
currentImageId.value = id;
currentImageUrl.value = await getFileUrl(id);
// 从后端获取文件信息
try {
const fileInfo = await requestClient.get<{ mime_type?: string }>(
`/api/core/file_manager/${id}`,
);
currentFileMimeType.value = fileInfo.mime_type || '';
} catch (error) {
console.warn('Failed to fetch file info:', error);
}
}
},
{ immediate: true },
);
// 打开文件选择器
function openFileSelector() {
if (props.disabled) return;
uploadInputRef.value?.click();
}
// 处理文件选择
async function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
target.value = '';
// 验证文件类型
const acceptedTypes = props.aiOcrConfig?.acceptFileTypes || ['image'];
const isValidType = validateFileType(file, acceptedTypes);
if (!isValidType) {
ElMessage.error($t('form-design.aiImageOcr.invalidFileType'));
return;
}
// 验证文件大小
if (props.maxSize && file.size > props.maxSize * 1024 * 1024) {
ElMessage.error(
$t('form-design.aiImageOcr.fileTooLarge', { size: props.maxSize }),
);
return;
}
await uploadAndRecognize(file);
}
// 验证文件类型
function validateFileType(file: File, acceptedTypes: string[]): boolean {
if (acceptedTypes.includes('all')) return true;
const fileName = file.name.toLowerCase();
const mimeType = file.type.toLowerCase();
for (const type of acceptedTypes) {
const accepts = FILE_TYPE_ACCEPT_MAP[type] || [];
for (const accept of accepts) {
if (accept.startsWith('.')) {
// 扩展名匹配
if (fileName.endsWith(accept)) return true;
} else if (accept.endsWith('/*')) {
// MIME 类型通配符匹配
const prefix = accept.replace('/*', '');
if (mimeType.startsWith(prefix)) return true;
} else {
// 完整 MIME 类型匹配
if (mimeType === accept) return true;
}
}
}
return false;
}
// 上传并识别
async function uploadAndRecognize(file: File) {
try {
isUploading.value = true;
uploadProgress.value = 0;
// 上传文件
const response = await uploadFileApi(file, {
source: 'form',
onProgress: (progressEvent) => {
uploadProgress.value = progressEvent.percentage;
},
});
if (!response?.id) {
throw new Error($t('form-design.aiImageOcr.uploadFailed'));
}
currentImageId.value = response.id;
currentImageUrl.value = await getFileUrl(response.id);
currentFileMimeType.value = file.type;
isUploading.value = false;
// 更新 modelValue
emit('update:modelValue', response.id);
emit('change', response.id);
// 如果启用了 AI 识别,开始识别
if (props.aiOcrConfig?.enabled) {
await recognizeImage(response.id);
}
} catch (error: any) {
console.error('Upload failed:', error);
ElMessage.error(error.message || $t('form-design.aiImageOcr.uploadFailed'));
isUploading.value = false;
}
}
// AI 识别文件
async function recognizeImage(fileId: string) {
if (!props.aiOcrConfig?.enabled) return;
try {
isRecognizing.value = true;
// 构建请求参数
const requestData: any = {
fileId,
};
// 如果有结构化输出配置
if (
props.aiOcrConfig.outputSchema &&
props.aiOcrConfig.outputSchema.length > 0
) {
requestData.outputSchema = props.aiOcrConfig.outputSchema;
}
// 如果有自定义提示词
if (props.aiOcrConfig.customPrompt) {
requestData.prompt = props.aiOcrConfig.customPrompt;
}
// 调用 OCR API
const result = await requestClient.post<{
error: null | string;
extractedData: null | Record<string, any>;
rawText: null | string;
success: boolean;
}>('/api/core/file_manager/ocr/recognize', requestData);
if (!result.success) {
throw new Error(
result.error || $t('form-design.aiImageOcr.recognizeFailed'),
);
}
ocrResult.value = {
rawText: result.rawText || '',
extractedData: result.extractedData,
};
emit('ocr-success', ocrResult.value);
if (result.extractedData) {
applyFieldMapping(result.extractedData);
} else {
ElMessage.success($t('form-design.aiImageOcr.recognizeSuccess'));
}
} catch (error: any) {
console.error('OCR failed:', error);
ElMessage.error(
error.message || $t('form-design.aiImageOcr.recognizeFailed'),
);
} finally {
isRecognizing.value = false;
}
}
// 应用字段映射
function applyFieldMapping(extractedData: Record<string, any>) {
if (!props.aiOcrConfig?.fieldMapping?.length) {
// 没有配置映射,直接发送原始数据
emit('fill-fields', extractedData);
ElMessage.success($t('form-design.aiImageOcr.fillSuccess'));
return;
}
const mappedData: Record<string, any> = {};
for (const mapping of props.aiOcrConfig.fieldMapping) {
const value = extractedData[mapping.source];
if (value !== undefined) {
mappedData[mapping.target] = value;
}
}
emit('fill-fields', mappedData);
ElMessage.success($t('form-design.aiImageOcr.fillSuccess'));
}
// 预览图片
function handlePreview() {
if (currentImageUrl.value) {
previewVisible.value = true;
}
}
// 删除图片
function handleDelete() {
currentImageId.value = '';
currentImageUrl.value = '';
ocrResult.value = null;
emit('update:modelValue', undefined);
emit('change', undefined);
}
// 重新识别
async function handleReRecognize() {
if (currentImageId.value) {
await recognizeImage(currentImageId.value);
}
}
</script>
<template>
<div class="ai-image-ocr">
<!-- 上传区域 -->
<div
class="upload-area"
:class="{
'has-file': hasFile,
'is-disabled': disabled,
'is-uploading': isUploading,
'is-recognizing': isRecognizing,
}"
:style="{ height: height ? `${height}px` : undefined }"
@click="!hasFile && openFileSelector()"
>
<!-- 隐藏的文件输入 -->
<input
ref="uploadInputRef"
type="file"
:accept="acceptString"
style="display: none"
@change="handleFileChange"
/>
<!-- 空状态 -->
<template v-if="!hasFile && !isUploading">
<div class="upload-placeholder">
<div class="upload-icon">
<Sparkles class="ai-icon" />
<FileImageOutlined class="image-icon" />
</div>
<div class="upload-text">{{ placeholder }}</div>
<div class="upload-hint">
<ElTag size="small" type="primary">
{{ templateTypeLabel }}
</ElTag>
<ElTag size="small" type="info" class="ml-1">
{{ acceptedFileTypesLabel }}
</ElTag>
</div>
</div>
</template>
<!-- 上传中 -->
<template v-else-if="isUploading">
<div class="upload-progress">
<ElProgress
type="circle"
:percentage="uploadProgress"
:width="80"
:stroke-width="6"
/>
<div class="progress-text">
{{ $t('form-design.aiImageOcr.uploading') }}
</div>
</div>
</template>
<!-- 已上传文件 -->
<template v-else>
<div class="file-preview">
<!-- 图片预览 -->
<img
v-if="isImageFile"
:src="currentImageUrl"
alt="uploaded"
class="preview-img"
/>
<!-- PDF预览 -->
<iframe
v-else-if="isPdfFile"
:src="`${currentImageUrl}#toolbar=0&navpanes=0&scrollbar=1`"
class="pdf-preview"
frameborder="0"
></iframe>
<!-- 其他文件显示图标 -->
<div v-else class="file-icon-wrapper">
<FileOutlined class="file-icon" />
<div class="file-name">
{{ $t('form-design.aiImageOcr.fileUploaded') }}
</div>
</div>
<!-- 识别中遮罩 -->
<div v-if="isRecognizing" class="recognizing-mask">
<div class="recognizing-content">
<Sparkles class="recognizing-icon" />
<div class="recognizing-text">
{{ $t('form-design.aiImageOcr.recognizing') }}
</div>
</div>
</div>
<!-- 操作按钮 -->
<div v-if="!isRecognizing" class="file-actions">
<ElButton
v-if="isImageFile || isPdfFile"
circle
size="small"
@click.stop="handlePreview"
>
<EyeOutlined />
</ElButton>
<ElButton
v-if="aiOcrConfig?.enabled"
circle
size="small"
type="primary"
@click.stop="handleReRecognize"
>
<Sparkles />
</ElButton>
<ElButton
v-if="clearable"
circle
size="small"
type="danger"
@click.stop="handleDelete"
>
<DeleteOutlined />
</ElButton>
</div>
<!-- AI 标识 -->
<div v-if="aiOcrConfig?.enabled" class="ai-badge">
<Sparkles class="badge-icon" />
AI
</div>
</div>
</template>
</div>
<!-- 图片预览 -->
<ElImageViewer
v-if="previewVisible && isImageFile"
:url-list="[currentImageUrl]"
:z-index="3000"
@close="previewVisible = false"
/>
<!-- PDF预览对话框 -->
<ElDialog
v-model="previewVisible"
v-if="isPdfFile"
:title="$t('form-design.aiImageOcr.preview')"
width="80%"
top="5vh"
>
<iframe
:src="`${currentImageUrl}#toolbar=0&navpanes=0&scrollbar=1`"
style="width: 100%; height: 70vh; border: none"
></iframe>
</ElDialog>
</div>
</template>
<style lang="scss" scoped>
.ai-image-ocr {
width: 100%;
}
.upload-area {
position: relative;
width: 100%;
min-height: 120px;
border: 2px dashed var(--el-border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
background: var(--el-fill-color-lighter);
&:hover:not(.is-disabled):not(.has-file) {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
&.has-file {
cursor: default;
border-style: solid;
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 8px;
.upload-icon {
position: relative;
display: flex;
align-items: center;
justify-content: center;
.image-icon {
font-size: 40px;
color: var(--el-color-primary);
}
.ai-icon {
position: absolute;
top: -8px;
right: -12px;
font-size: 20px;
color: var(--el-color-warning);
animation: sparkle 1.5s ease-in-out infinite;
}
}
.upload-text {
font-size: 14px;
color: var(--el-text-color-regular);
}
.upload-hint {
margin-top: 4px;
}
}
.upload-progress {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 12px;
.progress-text {
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
.file-preview {
position: relative;
width: 100%;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
.preview-img {
max-width: 100%;
max-height: 200px;
object-fit: contain;
border-radius: 4px;
}
.pdf-preview {
width: 100%;
height: 100%;
min-height: 400px;
border: none;
border-radius: 4px;
}
.file-icon-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
.file-icon {
font-size: 48px;
color: var(--el-color-primary);
}
.file-name {
font-size: 14px;
color: var(--el-text-color-regular);
}
}
.file-actions {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.3s;
}
&:hover .file-actions {
opacity: 1;
}
.ai-badge {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: linear-gradient(
135deg,
var(--el-color-primary),
var(--el-color-primary-light-3)
);
color: white;
font-size: 12px;
font-weight: 500;
border-radius: 4px;
.badge-icon {
font-size: 14px;
}
}
}
.recognizing-mask {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
.recognizing-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: white;
.recognizing-icon {
font-size: 32px;
animation: sparkle 1s ease-in-out infinite;
}
.recognizing-text {
font-size: 14px;
}
}
}
.confirm-content {
.extracted-data {
max-height: 300px;
overflow-y: auto;
text-align: left;
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 4px;
.data-item {
display: flex;
padding: 8px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
.data-label {
flex-shrink: 0;
width: 120px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.data-value {
flex: 1;
color: var(--el-text-color-primary);
word-break: break-all;
}
}
}
}
@keyframes sparkle {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
}
</style>
@@ -1,2 +0,0 @@
export { default as AiImageOcr } from './ai-image-ocr.vue';
export type { AiImageOcrEmits, AiImageOcrProps } from './types';
@@ -1,18 +0,0 @@
export interface AiImageOcrProps {
modelValue?: string | string[];
multiple?: boolean;
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
maxSize?: number;
}
export interface AiImageOcrEmits {
(e: 'update:modelValue', value: string | string[] | undefined): void;
(e: 'change', value: string | string[] | undefined): void;
(
e: 'ocr-success',
data: { extractedData: null | Record<string, any>; rawText: string },
): void;
(e: 'fill-fields', data: Record<string, any>): void;
}
File diff suppressed because it is too large Load Diff
@@ -1,224 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
AlignJustify,
Bold,
Code,
Heading1,
Heading2,
Heading3,
Highlighter,
Italic,
List,
ListOrdered,
Minus,
Quote,
} from '@vben/icons';
interface CommandItem {
title: string;
description: string;
icon: string;
command: (props: any) => void;
}
interface Props {
items: CommandItem[];
command: (item: CommandItem) => void;
}
const props = defineProps<Props>();
const selectedIndex = ref(0);
const iconComponents: Record<string, any> = {
Text: AlignJustify,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Minus,
Bold,
Italic,
Highlighter,
};
const filteredItems = computed(() => props.items || []);
function selectItem(index: number) {
const item = filteredItems.value[index];
if (item) {
props.command(item);
}
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex.value =
(selectedIndex.value + filteredItems.value.length - 1) %
filteredItems.value.length;
return true;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex.value =
(selectedIndex.value + 1) % filteredItems.value.length;
return true;
}
if (event.key === 'Enter') {
event.preventDefault();
selectItem(selectedIndex.value);
return true;
}
return false;
}
onMounted(() => {
selectedIndex.value = 0;
});
onUnmounted(() => {
selectedIndex.value = 0;
});
defineExpose({
onKeyDown,
});
</script>
<template>
<div class="slash-command-menu">
<div
v-for="(item, index) in filteredItems"
:key="index"
class="slash-command-item"
:class="{ 'is-selected': index === selectedIndex }"
@click="selectItem(index)"
@mouseenter="selectedIndex = index"
>
<div class="slash-command-icon">
<component :is="iconComponents[item.icon]" class="h-4 w-4" />
</div>
<div class="slash-command-content">
<div class="slash-command-title">{{ item.title }}</div>
<div class="slash-command-description">{{ item.description }}</div>
</div>
</div>
<div v-if="filteredItems.length === 0" class="slash-command-empty">
没有找到匹配的命令
</div>
</div>
</template>
<style>
/* 覆盖 tippy.js 的默认样式 */
.tippy-box {
background-color: transparent !important;
color: inherit !important;
}
.tippy-content {
padding: 0 !important;
}
.tippy-arrow {
display: none !important;
}
</style>
<style scoped>
.slash-command-menu {
min-width: 280px;
max-width: 400px;
max-height: 400px;
overflow-y: auto;
background: var(--el-bg-color);
/* border: 1px solid var(--el-border-color); */
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 0.5rem;
}
/* 美化滚动条 - 鼠标悬停时显示 */
.slash-command-menu::-webkit-scrollbar {
width: 6px;
}
.slash-command-menu::-webkit-scrollbar-track {
background: transparent;
border-radius: 3px;
}
.slash-command-menu::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 3px;
transition: background 0.2s;
}
.slash-command-menu:hover::-webkit-scrollbar-thumb {
background: var(--el-border-color);
}
.slash-command-menu::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color-darker);
}
.slash-command-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 0.375rem;
cursor: pointer;
transition: background-color 0.15s;
}
.slash-command-item:hover,
.slash-command-item.is-selected {
background-color: var(--el-fill-color-light);
}
.slash-command-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.375rem;
background-color: var(--el-fill-color);
color: var(--el-text-color-regular);
flex-shrink: 0;
}
.slash-command-content {
flex: 1;
min-width: 0;
}
.slash-command-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 0.125rem;
}
.slash-command-description {
font-size: 0.75rem;
color: var(--el-text-color-secondary);
}
.slash-command-empty {
padding: 1rem;
text-align: center;
color: var(--el-text-color-placeholder);
font-size: 0.875rem;
}
</style>
@@ -1,302 +0,0 @@
<script setup lang="ts">
import type { Editor } from '@tiptap/vue-3';
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { GripVertical } from '@vben/icons';
import TableMenu from './TableMenu.vue';
interface Props {
editor: Editor;
}
const props = defineProps<Props>();
const showRowHandle = ref(false);
const showColumnHandle = ref(false);
const rowHandlePosition = ref({ top: 0, left: 0, height: 0 });
const columnHandlePosition = ref({ top: 0, left: 0, width: 0 });
const currentRowIndex = ref(-1);
const currentColumnIndex = ref(-1);
// 保存当前悬停的单元格,用于点击手柄时聚焦
let currentCell: HTMLElement | null = null;
const showMenu = ref(false);
const menuType = ref<'column' | 'row'>('row');
const menuPosition = ref({ x: 0, y: 0 });
// 用于追踪鼠标是否在手柄区域
const isMouseOverHandle = ref(false);
let hideTimeout: null | ReturnType<typeof setTimeout> = null;
let editorElement: HTMLElement | null = null;
function handleMouseMove(e: MouseEvent) {
if (!props.editor || !editorElement) return;
const target = e.target as HTMLElement;
const cell = target.closest('td, th') as HTMLElement;
const table = target.closest('table') as HTMLElement;
if (!cell || !table) {
// 不立即隐藏,给用户时间移动到手柄
scheduleHide();
return;
}
// 取消隐藏计时器
cancelHide();
const row = cell.closest('tr') as HTMLElement;
if (!row) return;
const tableRect = table.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const cellRect = cell.getBoundingClientRect();
// 计算行索引
const rows = [...table.querySelectorAll('tr')] as HTMLElement[];
currentRowIndex.value = rows.indexOf(row);
// 计算列索引
const cells = [...row.querySelectorAll('td, th')];
currentColumnIndex.value = cells.indexOf(cell);
// 保存当前单元格引用
currentCell = cell;
// 行手柄位置(左侧)- 高度与行高一致
rowHandlePosition.value = {
top: rowRect.top,
left: tableRect.left - 20,
height: rowRect.height,
};
// 列手柄位置(顶部)- 宽度与列宽一致
columnHandlePosition.value = {
top: tableRect.top - 20,
left: cellRect.left,
width: cellRect.width,
};
showRowHandle.value = true;
showColumnHandle.value = true;
}
function scheduleHide() {
if (hideTimeout) return;
hideTimeout = setTimeout(() => {
if (!isMouseOverHandle.value && !showMenu.value) {
showRowHandle.value = false;
showColumnHandle.value = false;
}
hideTimeout = null;
}, 300);
}
function cancelHide() {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
}
function handleMouseLeave() {
scheduleHide();
}
function handleHandleMouseEnter() {
isMouseOverHandle.value = true;
cancelHide();
}
function handleHandleMouseLeave() {
isMouseOverHandle.value = false;
scheduleHide();
}
function handleRowClick(e: MouseEvent) {
e.stopPropagation();
// 选中当前行
if (currentRowIndex.value >= 0) {
selectRow(currentRowIndex.value);
}
menuType.value = 'row';
menuPosition.value = { x: e.clientX, y: e.clientY };
showMenu.value = true;
// 点击其他地方关闭菜单
const closeMenu = (event: MouseEvent) => {
const target = event.target as HTMLElement;
// 如果点击的是菜单内部,不关闭
if (target.closest('.table-menu')) return;
showMenu.value = false;
showRowHandle.value = false;
showColumnHandle.value = false;
document.removeEventListener('click', closeMenu);
};
setTimeout(() => {
document.addEventListener('click', closeMenu);
}, 0);
}
function handleColumnClick(e: MouseEvent) {
e.stopPropagation();
// 选中当前列
if (currentColumnIndex.value >= 0) {
selectColumn(currentColumnIndex.value);
}
menuType.value = 'column';
menuPosition.value = { x: e.clientX, y: e.clientY };
showMenu.value = true;
// 点击其他地方关闭菜单
const closeMenu = (event: MouseEvent) => {
const target = event.target as HTMLElement;
// 如果点击的是菜单内部,不关闭
if (target.closest('.table-menu')) return;
showMenu.value = false;
showRowHandle.value = false;
showColumnHandle.value = false;
document.removeEventListener('click', closeMenu);
};
setTimeout(() => {
document.addEventListener('click', closeMenu);
}, 0);
}
function focusCurrentCell() {
if (!currentCell || !props.editor) return;
// 获取单元格在编辑器中的位置并聚焦
const view = props.editor.view;
const pos = view.posAtDOM(currentCell, 0);
if (pos >= 0) {
props.editor.chain().focus().setTextSelection(pos).run();
}
}
function selectRow(_rowIndex: number) {
focusCurrentCell();
}
function selectColumn(_colIndex: number) {
focusCurrentCell();
}
function closeMenu() {
showMenu.value = false;
}
onMounted(() => {
nextTick(() => {
editorElement = document.querySelector(
'.notion-editor-wrapper .ProseMirror',
);
if (editorElement) {
editorElement.addEventListener('mousemove', handleMouseMove);
editorElement.addEventListener('mouseleave', handleMouseLeave);
}
});
});
onBeforeUnmount(() => {
if (editorElement) {
editorElement.removeEventListener('mousemove', handleMouseMove);
editorElement.removeEventListener('mouseleave', handleMouseLeave);
}
cancelHide();
});
</script>
<template>
<!-- 行手柄 -->
<Teleport to="body">
<div
v-if="showRowHandle"
class="table-handle row-handle"
:style="{
top: `${rowHandlePosition.top}px`,
left: `${rowHandlePosition.left}px`,
height: `${rowHandlePosition.height}px`,
}"
@click="handleRowClick"
@mouseenter="handleHandleMouseEnter"
@mouseleave="handleHandleMouseLeave"
>
<GripVertical class="handle-icon" />
</div>
</Teleport>
<!-- 列手柄 -->
<Teleport to="body">
<div
v-if="showColumnHandle"
class="table-handle column-handle"
:style="{
top: `${columnHandlePosition.top}px`,
left: `${columnHandlePosition.left}px`,
width: `${columnHandlePosition.width}px`,
}"
@click="handleColumnClick"
@mouseenter="handleHandleMouseEnter"
@mouseleave="handleHandleMouseLeave"
>
<GripVertical class="handle-icon rotated" />
</div>
</Teleport>
<!-- 菜单 -->
<Teleport to="body">
<TableMenu
v-if="showMenu"
:editor="editor"
:type="menuType"
:position="menuPosition"
@close="closeMenu"
/>
</Teleport>
</template>
<style scoped>
.table-handle {
position: fixed;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: var(--el-fill-color-light);
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s;
}
.table-handle:hover {
background: var(--el-fill-color);
}
.row-handle {
width: 16px;
}
.column-handle {
height: 16px;
}
.handle-icon {
width: 12px;
height: 12px;
color: var(--el-text-color-secondary);
}
.handle-icon.rotated {
transform: rotate(90deg);
}
</style>
@@ -1,320 +0,0 @@
<script setup lang="ts">
import type { Editor } from '@tiptap/vue-3';
import { computed, ref } from 'vue';
import {
ArrowLeft,
ArrowRight,
ChevronRight,
Copy,
MoveDown,
MoveUp,
Paintbrush,
Plus,
Trash2,
X,
} from '@vben/icons';
interface Props {
editor: Editor;
type: 'column' | 'row';
position: { x: number; y: number };
}
const props = defineProps<Props>();
const emit = defineEmits<{
close: [];
}>();
const showColorSubmenu = ref(false);
const isRow = computed(() => props.type === 'row');
const cellColors = [
{ name: '默认', value: '' },
{ name: '浅灰', value: '#f1f5f9' },
{ name: '浅红', value: '#fee2e2' },
{ name: '浅橙', value: '#ffedd5' },
{ name: '浅黄', value: '#fef9c3' },
{ name: '浅绿', value: '#dcfce7' },
{ name: '浅蓝', value: '#dbeafe' },
{ name: '浅紫', value: '#f3e8ff' },
{ name: '浅粉', value: '#fce7f3' },
];
function moveUp() {
// Tiptap 没有直接的移动行/列命令
// 暂不实现
emit('close');
}
function moveDown() {
emit('close');
}
function insertBefore() {
if (isRow.value) {
(props.editor.chain().focus() as any).addRowBefore().run();
} else {
(props.editor.chain().focus() as any).addColumnBefore().run();
}
emit('close');
}
function insertAfter() {
if (isRow.value) {
(props.editor.chain().focus() as any).addRowAfter().run();
} else {
(props.editor.chain().focus() as any).addColumnAfter().run();
}
emit('close');
}
function deleteRowOrColumn() {
if (isRow.value) {
(props.editor.chain().focus() as any).deleteRow().run();
} else {
(props.editor.chain().focus() as any).deleteColumn().run();
}
emit('close');
}
function duplicateRowOrColumn() {
// 复制行/列:先插入,然后复制内容(简化实现)
if (isRow.value) {
(props.editor.chain().focus() as any).addRowAfter().run();
} else {
(props.editor.chain().focus() as any).addColumnAfter().run();
}
emit('close');
}
function clearContents() {
// 清空内容:选中单元格后删除内容
// 简化实现
emit('close');
}
function setCellBackground(color: string) {
if (color) {
(props.editor.chain().focus() as any)
.setCellAttribute('backgroundColor', color)
.run();
} else {
(props.editor.chain().focus() as any)
.setCellAttribute('backgroundColor', null)
.run();
}
showColorSubmenu.value = false;
emit('close');
}
</script>
<template>
<div
class="table-menu"
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
@click.stop
>
<div class="menu-content">
<!-- 移动操作 -->
<button class="menu-item" @click="moveUp" :disabled="true">
<component :is="isRow ? MoveUp : ArrowLeft" class="menu-icon" />
<span>{{ isRow ? '上移行' : '左移列' }}</span>
</button>
<button class="menu-item" @click="moveDown" :disabled="true">
<component :is="isRow ? MoveDown : ArrowRight" class="menu-icon" />
<span>{{ isRow ? '下移行' : '右移列' }}</span>
</button>
<div class="menu-divider"></div>
<!-- 插入操作 -->
<button class="menu-item" @click="insertBefore">
<Plus class="menu-icon" />
<span>{{ isRow ? '在上方插入行' : '在左侧插入列' }}</span>
</button>
<button class="menu-item" @click="insertAfter">
<Plus class="menu-icon" />
<span>{{ isRow ? '在下方插入行' : '在右侧插入列' }}</span>
</button>
<div class="menu-divider"></div>
<!-- 颜色 -->
<div
class="menu-item-with-submenu"
@mouseenter="showColorSubmenu = true"
@mouseleave="showColorSubmenu = false"
>
<button class="menu-item">
<Paintbrush class="menu-icon" />
<span>颜色</span>
<ChevronRight class="submenu-arrow" />
</button>
<div v-if="showColorSubmenu" class="submenu color-submenu">
<button
v-for="color in cellColors"
:key="color.value"
class="color-item"
@click="setCellBackground(color.value)"
>
<span
class="color-preview"
:style="{
backgroundColor: color.value || '#ffffff',
border: color.value
? 'none'
: '1px solid var(--el-border-color)',
}"
></span>
<span>{{ color.name }}</span>
</button>
</div>
</div>
<!-- 清空内容仅列 -->
<button
v-if="!isRow"
class="menu-item"
@click="clearContents"
:disabled="true"
>
<X class="menu-icon" />
<span>清空列内容</span>
</button>
<div class="menu-divider"></div>
<!-- 复制 -->
<button class="menu-item" @click="duplicateRowOrColumn">
<Copy class="menu-icon" />
<span>{{ isRow ? '复制行' : '复制列' }}</span>
</button>
<!-- 删除 -->
<button class="menu-item menu-item-danger" @click="deleteRowOrColumn">
<Trash2 class="menu-icon" />
<span>{{ isRow ? '删除行' : '删除列' }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.table-menu {
position: fixed;
z-index: 9999;
min-width: 180px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
padding: 4px;
}
.menu-content {
display: flex;
flex-direction: column;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
color: var(--el-text-color-primary);
border-radius: 4px;
transition: background-color 0.15s;
text-align: left;
}
.menu-item:hover:not(:disabled) {
background-color: var(--el-fill-color-light);
}
.menu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-item-danger {
color: var(--el-color-danger);
}
.menu-item-danger:hover:not(:disabled) {
background-color: var(--el-color-danger-light-9);
}
.menu-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.menu-divider {
height: 1px;
background-color: var(--el-border-color-lighter);
margin: 4px 8px;
}
.menu-item-with-submenu {
position: relative;
}
.submenu-arrow {
width: 14px;
height: 14px;
margin-left: auto;
opacity: 0.5;
}
.submenu {
position: absolute;
left: calc(100% - 4px);
top: -4px;
min-width: 140px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
padding: 4px;
padding-left: 8px;
}
.color-submenu {
min-width: 120px;
}
.color-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
border: none;
background: transparent;
cursor: pointer;
font-size: 13px;
color: var(--el-text-color-primary);
border-radius: 4px;
transition: background-color 0.15s;
}
.color-item:hover {
background-color: var(--el-fill-color-light);
}
.color-preview {
width: 16px;
height: 16px;
border-radius: 4px;
flex-shrink: 0;
}
</style>
@@ -1,237 +0,0 @@
import type { Instance as TippyInstance } from 'tippy.js';
import { Extension } from '@tiptap/core';
import Suggestion from '@tiptap/suggestion';
import { VueRenderer } from '@tiptap/vue-3';
import tippy from 'tippy.js';
import SlashCommandMenu from '../components/SlashCommandMenu.vue';
export const SlashCommand = Extension.create({
name: 'slashCommand',
addOptions() {
return {
suggestion: {
char: '/',
startOfLine: false,
command: ({ editor, range, props }: any) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
export function createSlashCommandSuggestion() {
return {
items: ({ query }: { query: string }) => {
const commands = [
{
title: '正文',
description: '普通段落文本',
icon: 'Text',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setParagraph().run();
},
},
{
title: '标题 1',
description: '大标题',
icon: 'Heading1',
command: ({ editor, range }: any) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode('heading', { level: 1 })
.run();
},
},
{
title: '标题 2',
description: '中标题',
icon: 'Heading2',
command: ({ editor, range }: any) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode('heading', { level: 2 })
.run();
},
},
{
title: '标题 3',
description: '小标题',
icon: 'Heading3',
command: ({ editor, range }: any) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode('heading', { level: 3 })
.run();
},
},
{
title: '无序列表',
description: '创建无序列表',
icon: 'List',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: '有序列表',
description: '创建有序列表',
icon: 'ListOrdered',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: '引用',
description: '创建引用块',
icon: 'Quote',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
},
},
{
title: '代码块',
description: '创建代码块',
icon: 'Code',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
},
},
{
title: '分割线',
description: '插入分割线',
icon: 'Minus',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: '表格',
description: '插入表格',
icon: 'Table',
command: ({ editor, range }: any) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
},
},
{
title: '加粗',
description: '加粗文本',
icon: 'Bold',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleBold().run();
},
},
{
title: '斜体',
description: '斜体文本',
icon: 'Italic',
command: ({ editor, range }: any) => {
editor.chain().focus().deleteRange(range).toggleItalic().run();
},
},
{
title: '高亮',
description: '高亮文本',
icon: 'Highlighter',
command: ({ editor, range }: any) => {
editor
.chain()
.focus()
.deleteRange(range)
.toggleHighlight({ color: '#fef08a' })
.run();
},
},
];
return commands.filter((item) =>
item.title.toLowerCase().includes(query.toLowerCase()),
);
},
render: () => {
let component: null | VueRenderer = null;
let popup: null | TippyInstance[] = null;
return {
onStart: (props: any) => {
component = new VueRenderer(SlashCommandMenu, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
const referenceElement = document.createElement('div');
const instances = tippy(referenceElement, {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element as HTMLElement,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
});
popup = Array.isArray(instances) ? instances : [instances];
},
onUpdate(props: any) {
if (!component) return;
component.updateProps(props);
if (!props.clientRect || !popup) {
return;
}
popup[0]?.setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props: any) {
if (props.event.key === 'Escape') {
popup?.[0]?.hide();
return true;
}
return (component?.ref as any)?.onKeyDown(props);
},
onExit() {
if (popup) {
popup[0]?.destroy();
}
if (component) {
component.destroy();
}
},
};
},
};
}
@@ -1 +0,0 @@
export { default as NotionEditor } from './NotionEditor.vue';
@@ -1,654 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import {
AlignCenter,
AlignLeft,
AlignRight,
Maximize2,
RotateCw,
Trash2,
} from '@vben/icons';
import { NodeViewWrapper } from '@tiptap/vue-3';
import { ElInputNumber } from 'element-plus';
const props = defineProps<{
deleteNode: () => void;
editor: any;
node: any;
selected: boolean;
updateAttributes: (attrs: Record<string, any>) => void;
}>();
// 图片属性
const imgRef = ref<HTMLImageElement | null>(null);
const isResizing = ref(false);
const startX = ref(0);
const startY = ref(0);
const startWidth = ref(0);
const startHeight = ref(0);
const resizeDirection = ref<string>('');
// 右键菜单
const contextMenuVisible = ref(false);
const contextMenuPosition = ref({ x: 0, y: 0 });
const showSizePanel = ref(false);
// 尺寸输入
const widthInput = ref<number>(0);
const heightInput = ref<number>(0);
const keepRatio = ref(true);
const originalRatio = ref(1);
// 预设尺寸
const presetSizes = [
{ label: '25%', value: 25 },
{ label: '50%', value: 50 },
{ label: '75%', value: 75 },
{ label: '100%', value: 100 },
];
// 计算图片样式
const imageStyle = computed(() => {
const style: Record<string, string> = {};
if (props.node.attrs.width) {
style.width =
typeof props.node.attrs.width === 'number'
? `${props.node.attrs.width}px`
: props.node.attrs.width;
}
if (props.node.attrs.height) {
style.height =
typeof props.node.attrs.height === 'number'
? `${props.node.attrs.height}px`
: props.node.attrs.height;
}
return style;
});
// 计算容器样式(对齐)
const wrapperStyle = computed(() => {
const alignment = props.node.attrs.alignment || 'center';
const justifyMap: Record<string, string> = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
};
return {
justifyContent: justifyMap[alignment] || 'center',
};
});
// 图片加载完成后获取原始尺寸
const handleImageLoad = () => {
if (imgRef.value) {
const img = imgRef.value;
originalRatio.value = img.naturalWidth / img.naturalHeight;
if (props.node.attrs.width) {
widthInput.value =
typeof props.node.attrs.width === 'number'
? props.node.attrs.width
: Number.parseInt(props.node.attrs.width) || img.naturalWidth;
heightInput.value =
typeof props.node.attrs.height === 'number'
? props.node.attrs.height
: Number.parseInt(props.node.attrs.height) || img.naturalHeight;
} else {
widthInput.value = img.naturalWidth;
heightInput.value = img.naturalHeight;
}
}
};
// 右键菜单
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
contextMenuPosition.value = { x: e.clientX, y: e.clientY };
contextMenuVisible.value = true;
showSizePanel.value = false;
// 点击其他地方关闭菜单
nextTick(() => {
document.addEventListener('click', closeContextMenu);
document.addEventListener('contextmenu', closeContextMenu);
});
};
const closeContextMenu = () => {
contextMenuVisible.value = false;
showSizePanel.value = false;
document.removeEventListener('click', closeContextMenu);
document.removeEventListener('contextmenu', closeContextMenu);
};
// 开始调整大小
const startResize = (e: MouseEvent, direction: string) => {
e.preventDefault();
e.stopPropagation();
isResizing.value = true;
resizeDirection.value = direction;
startX.value = e.clientX;
startY.value = e.clientY;
if (imgRef.value) {
startWidth.value = imgRef.value.offsetWidth;
startHeight.value = imgRef.value.offsetHeight;
}
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize);
};
// 处理调整大小
const handleResize = (e: MouseEvent) => {
if (!isResizing.value) return;
const deltaX = e.clientX - startX.value;
const deltaY = e.clientY - startY.value;
let newWidth = startWidth.value;
let newHeight = startHeight.value;
if (resizeDirection.value.includes('e')) {
newWidth = Math.max(50, startWidth.value + deltaX);
}
if (resizeDirection.value.includes('w')) {
newWidth = Math.max(50, startWidth.value - deltaX);
}
if (resizeDirection.value.includes('s')) {
newHeight = Math.max(50, startHeight.value + deltaY);
}
if (resizeDirection.value.includes('n')) {
newHeight = Math.max(50, startHeight.value - deltaY);
}
// 保持比例
if (keepRatio.value && originalRatio.value) {
if (
resizeDirection.value.includes('e') ||
resizeDirection.value.includes('w')
) {
newHeight = Math.round(newWidth / originalRatio.value);
} else {
newWidth = Math.round(newHeight * originalRatio.value);
}
}
props.updateAttributes({
width: newWidth,
height: newHeight,
});
widthInput.value = newWidth;
heightInput.value = newHeight;
};
// 停止调整大小
const stopResize = () => {
isResizing.value = false;
resizeDirection.value = '';
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
};
// 设置对齐方式
const setAlignment = (alignment: 'center' | 'left' | 'right') => {
props.updateAttributes({ alignment });
closeContextMenu();
};
// 设置预设尺寸(百分比)
const setPresetSize = (percent: number) => {
if (imgRef.value) {
const editorEl = props.editor?.view?.dom?.parentElement;
const containerWidth = editorEl?.clientWidth || 800;
const maxWidth = containerWidth - 32;
const newWidth = Math.round((maxWidth * percent) / 100);
const newHeight = Math.round(newWidth / originalRatio.value);
props.updateAttributes({
width: newWidth,
height: newHeight,
});
widthInput.value = newWidth;
heightInput.value = newHeight;
}
closeContextMenu();
};
// 切换自定义尺寸面板
const toggleSizePanel = () => {
showSizePanel.value = !showSizePanel.value;
};
// 手动输入宽度
const handleWidthChange = (value: number | undefined) => {
if (!value) return;
const newWidth = value;
let newHeight = heightInput.value;
if (keepRatio.value && originalRatio.value) {
newHeight = Math.round(newWidth / originalRatio.value);
heightInput.value = newHeight;
}
props.updateAttributes({
width: newWidth,
height: newHeight,
});
};
// 手动输入高度
const handleHeightChange = (value: number | undefined) => {
if (!value) return;
const newHeight = value;
let newWidth = widthInput.value;
if (keepRatio.value && originalRatio.value) {
newWidth = Math.round(newHeight * originalRatio.value);
widthInput.value = newWidth;
}
props.updateAttributes({
width: newWidth,
height: newHeight,
});
};
// 重置为原始尺寸
const resetSize = () => {
if (imgRef.value) {
const img = imgRef.value;
props.updateAttributes({
width: img.naturalWidth,
height: img.naturalHeight,
});
widthInput.value = img.naturalWidth;
heightInput.value = img.naturalHeight;
}
closeContextMenu();
};
// 删除图片
const handleDelete = () => {
props.deleteNode();
closeContextMenu();
};
onBeforeUnmount(() => {
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
document.removeEventListener('click', closeContextMenu);
document.removeEventListener('contextmenu', closeContextMenu);
});
</script>
<template>
<NodeViewWrapper class="resizable-image-wrapper flex" :style="wrapperStyle">
<div
class="resizable-image-container relative inline-block"
:class="{ 'is-selected': selected, 'is-resizing': isResizing }"
>
<!-- 图片 -->
<img
ref="imgRef"
:src="node.attrs.src"
:alt="node.attrs.alt"
:title="node.attrs.title"
:style="imageStyle"
class="block max-w-full rounded"
draggable="false"
@load="handleImageLoad"
@contextmenu="handleContextMenu"
/>
<!-- 选中时显示调整手柄 -->
<template v-if="selected">
<div
class="resize-handle resize-handle-nw"
@mousedown="(e) => startResize(e, 'nw')"
></div>
<div
class="resize-handle resize-handle-ne"
@mousedown="(e) => startResize(e, 'ne')"
></div>
<div
class="resize-handle resize-handle-sw"
@mousedown="(e) => startResize(e, 'sw')"
></div>
<div
class="resize-handle resize-handle-se"
@mousedown="(e) => startResize(e, 'se')"
></div>
</template>
</div>
<!-- 右键菜单 -->
<Teleport to="body">
<div
v-if="contextMenuVisible"
class="image-context-menu"
:style="{
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
}"
@click.stop
>
<!-- 对齐方式 -->
<div class="menu-group">
<div class="menu-group-title">对齐方式</div>
<div class="menu-row">
<button
class="menu-icon-btn"
:class="{ active: node.attrs.alignment === 'left' }"
title="左对齐"
@click="setAlignment('left')"
>
<AlignLeft class="h-4 w-4" />
</button>
<button
class="menu-icon-btn"
:class="{ active: node.attrs.alignment === 'center' }"
title="居中"
@click="setAlignment('center')"
>
<AlignCenter class="h-4 w-4" />
</button>
<button
class="menu-icon-btn"
:class="{ active: node.attrs.alignment === 'right' }"
title="右对齐"
@click="setAlignment('right')"
>
<AlignRight class="h-4 w-4" />
</button>
</div>
</div>
<div class="menu-divider"></div>
<!-- 快速缩放 -->
<div class="menu-group">
<div class="menu-group-title">快速缩放</div>
<div class="menu-row">
<button
v-for="size in presetSizes"
:key="size.value"
class="menu-size-btn"
@click="setPresetSize(size.value)"
>
{{ size.label }}
</button>
</div>
</div>
<div class="menu-divider"></div>
<!-- 自定义尺寸 -->
<button class="menu-item" @click="toggleSizePanel">
<Maximize2 class="menu-item-icon" />
<span>自定义尺寸</span>
</button>
<!-- 尺寸面板 -->
<div v-if="showSizePanel" class="size-panel">
<div class="size-row">
<span class="size-label"></span>
<ElInputNumber
v-model="widthInput"
:min="50"
:max="2000"
size="small"
controls-position="right"
@change="handleWidthChange"
/>
</div>
<div class="size-row">
<span class="size-label"></span>
<ElInputNumber
v-model="heightInput"
:min="50"
:max="2000"
size="small"
controls-position="right"
@change="handleHeightChange"
/>
</div>
<label class="size-checkbox">
<input v-model="keepRatio" type="checkbox" />
<span>保持比例</span>
</label>
</div>
<div class="menu-divider"></div>
<!-- 重置尺寸 -->
<button class="menu-item" @click="resetSize">
<RotateCw class="menu-item-icon" />
<span>重置尺寸</span>
</button>
<!-- 删除 -->
<button class="menu-item menu-item-danger" @click="handleDelete">
<Trash2 class="menu-item-icon" />
<span>删除图片</span>
</button>
</div>
</Teleport>
</NodeViewWrapper>
</template>
<style scoped>
.resizable-image-wrapper {
margin: 0.5em 0;
}
.resizable-image-container {
position: relative;
display: inline-block;
line-height: 0;
&.is-selected {
img {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
}
}
&.is-resizing {
user-select: none;
}
}
/* 调整手柄样式 - 只保留四个角 */
.resize-handle {
position: absolute;
z-index: 10;
width: 10px;
height: 10px;
background-color: var(--el-color-primary);
border: 2px solid var(--el-bg-color);
border-radius: 2px;
&:hover {
background-color: var(--el-color-primary-dark-2);
}
}
.resize-handle-nw {
top: -5px;
left: -5px;
cursor: nw-resize;
}
.resize-handle-ne {
top: -5px;
right: -5px;
cursor: ne-resize;
}
.resize-handle-sw {
bottom: -5px;
left: -5px;
cursor: sw-resize;
}
.resize-handle-se {
right: -5px;
bottom: -5px;
cursor: se-resize;
}
/* 右键菜单样式 */
.image-context-menu {
position: fixed;
z-index: 9999;
min-width: 180px;
padding: 6px 0;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.menu-group {
padding: 6px 12px;
}
.menu-group-title {
margin-bottom: 6px;
font-size: 11px;
color: var(--el-text-color-secondary);
}
.menu-row {
display: flex;
gap: 4px;
}
.menu-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
color: var(--el-text-color-regular);
cursor: pointer;
background-color: transparent;
border: 1px solid var(--el-border-color);
border-radius: 4px;
transition: all 0.2s;
&:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
&.active {
color: #fff;
background-color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
}
.menu-size-btn {
flex: 1;
height: 28px;
font-size: 12px;
color: var(--el-text-color-regular);
cursor: pointer;
background-color: transparent;
border: 1px solid var(--el-border-color);
border-radius: 4px;
transition: all 0.2s;
&:hover {
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
}
.menu-divider {
height: 1px;
margin: 6px 0;
background-color: var(--el-border-color-lighter);
}
.menu-item {
display: flex;
align-items: center;
width: 100%;
padding: 8px 12px;
font-size: 13px;
color: var(--el-text-color-regular);
cursor: pointer;
background-color: transparent;
border: none;
transition: background-color 0.2s;
&:hover {
background-color: var(--el-fill-color-light);
}
&.menu-item-danger {
color: var(--el-color-danger);
&:hover {
background-color: var(--el-color-danger-light-9);
}
}
}
.menu-item-icon {
width: 16px;
height: 16px;
margin-right: 8px;
}
/* 尺寸面板 */
.size-panel {
padding: 8px 12px;
background-color: var(--el-fill-color-lighter);
border-top: 1px solid var(--el-border-color-lighter);
}
.size-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.size-label {
width: 20px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.size-checkbox {
display: flex;
gap: 6px;
align-items: center;
font-size: 12px;
color: var(--el-text-color-secondary);
cursor: pointer;
input {
width: 14px;
height: 14px;
cursor: pointer;
}
}
:deep(.el-input-number--small) {
width: 100px;
}
</style>
@@ -1,536 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
AlignCenter,
AlignLeft,
AlignRight,
RotateCcw,
Trash2,
} from '@vben/icons';
import { NodeViewWrapper } from '@tiptap/vue-3';
import { ElButton, ElCheckbox, ElInputNumber } from 'element-plus';
import { getFileUrl } from '#/composables/useFileUrl';
const props = defineProps<{
deleteNode: () => void;
node: any;
selected: boolean;
updateAttributes: (attrs: Record<string, any>) => void;
}>();
// 状态
const isSelected = ref(false);
const showContextMenu = ref(false);
const contextMenuPosition = ref({ x: 0, y: 0 });
const showCustomSize = ref(false);
const keepRatio = ref(true);
const videoRef = ref<HTMLVideoElement | null>(null);
const originalSize = ref({ width: 0, height: 0 });
// 视频URL(响应式,支持token刷新)
const resolvedVideoSrc = ref('');
// 异步解析视频URL
async function resolveVideoUrl() {
const id = props.node.attrs.id;
const src = props.node.attrs.src;
// 如果有文件ID,优先使用ID获取带token的URL
if (id) {
resolvedVideoSrc.value = await getFileUrl(id);
} else if (src) {
resolvedVideoSrc.value = src;
} else {
resolvedVideoSrc.value = '';
}
}
// 监听属性变化
watch(
() => [props.node.attrs.id, props.node.attrs.src],
() => {
resolveVideoUrl();
},
{ immediate: true },
);
// 计算属性
const videoSrc = computed(() => resolvedVideoSrc.value);
const currentWidth = computed(() => {
const w = props.node.attrs.width;
if (typeof w === 'string' && w.endsWith('%')) {
return Number.parseInt(w);
}
return w || 100;
});
const currentHeight = computed(() => props.node.attrs.height || 'auto');
const alignment = computed(() => props.node.attrs.alignment || 'center');
const nodeStyle = computed(() => {
const styles: Record<string, string> = {
display: 'flex',
};
if (alignment.value === 'left') {
styles.justifyContent = 'flex-start';
} else if (alignment.value === 'right') {
styles.justifyContent = 'flex-end';
} else {
styles.justifyContent = 'center';
}
return styles;
});
const containerStyle = computed(() => {
const styles: Record<string, string> = {};
const w = props.node.attrs.width;
if (typeof w === 'number') {
styles.width = `${w}px`;
} else if (typeof w === 'string') {
styles.width = w;
} else {
styles.width = '100%';
}
return styles;
});
// 方法
const handleClick = () => {
isSelected.value = true;
};
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
isSelected.value = true;
showContextMenu.value = true;
contextMenuPosition.value = { x: e.clientX, y: e.clientY };
};
const hideContextMenu = () => {
showContextMenu.value = false;
showCustomSize.value = false;
};
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (
!target.closest('.resizable-video-wrapper') &&
!target.closest('.video-context-menu')
) {
isSelected.value = false;
hideContextMenu();
}
};
const setAlignment = (align: 'center' | 'left' | 'right') => {
props.updateAttributes({ alignment: align });
};
const setPresetSize = (percent: number) => {
props.updateAttributes({ width: `${percent}%`, height: 'auto' });
hideContextMenu();
};
const updateWidth = (value: number | undefined) => {
if (!value) return;
if (
keepRatio.value &&
originalSize.value.width &&
originalSize.value.height
) {
const ratio = originalSize.value.height / originalSize.value.width;
props.updateAttributes({ width: value, height: Math.round(value * ratio) });
} else {
props.updateAttributes({ width: value });
}
};
const updateHeight = (value: number | undefined) => {
if (!value) return;
if (
keepRatio.value &&
originalSize.value.width &&
originalSize.value.height
) {
const ratio = originalSize.value.width / originalSize.value.height;
props.updateAttributes({ height: value, width: Math.round(value * ratio) });
} else {
props.updateAttributes({ height: value });
}
};
const resetSize = () => {
props.updateAttributes({ width: '100%', height: 'auto' });
hideContextMenu();
};
const handleDelete = () => {
props.deleteNode();
};
// 拖拽调整大小
const isResizing = ref(false);
const resizeStartPos = ref({ x: 0, y: 0 });
const resizeStartSize = ref({ width: 0, height: 0 });
const resizeCorner = ref('');
const startResize = (e: MouseEvent, corner: string) => {
e.preventDefault();
e.stopPropagation();
isResizing.value = true;
resizeCorner.value = corner;
resizeStartPos.value = { x: e.clientX, y: e.clientY };
const wrapper = (e.target as HTMLElement).closest(
'.resizable-video-wrapper',
) as HTMLElement;
if (wrapper) {
resizeStartSize.value = {
width: wrapper.offsetWidth,
height: wrapper.offsetHeight,
};
}
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize);
};
const handleResize = (e: MouseEvent) => {
if (!isResizing.value) return;
const deltaX = e.clientX - resizeStartPos.value.x;
// 左侧控制点需要反向计算
const isLeftCorner =
resizeCorner.value === 'nw' || resizeCorner.value === 'sw';
const adjustedDeltaX = isLeftCorner ? -deltaX : deltaX;
let newWidth = resizeStartSize.value.width + adjustedDeltaX;
let newHeight = resizeStartSize.value.height;
// 保持比例
if (originalSize.value.width && originalSize.value.height) {
const ratio = originalSize.value.height / originalSize.value.width;
newHeight = Math.round(newWidth * ratio);
}
// 限制最小尺寸
newWidth = Math.max(100, newWidth);
newHeight = Math.max(60, newHeight);
props.updateAttributes({ width: newWidth, height: newHeight });
};
const stopResize = () => {
isResizing.value = false;
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
};
// 获取视频原始尺寸
const handleVideoLoaded = () => {
if (videoRef.value) {
originalSize.value = {
width: videoRef.value.videoWidth,
height: videoRef.value.videoHeight,
};
}
};
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
});
</script>
<template>
<NodeViewWrapper class="resizable-video-node" :style="nodeStyle">
<div
class="resizable-video-wrapper"
:class="{ 'is-selected': isSelected || selected }"
:style="containerStyle"
@click="handleClick"
@contextmenu="handleContextMenu"
>
<video
ref="videoRef"
:src="videoSrc"
controls
class="resizable-video"
@loadedmetadata="handleVideoLoaded"
>
您的浏览器不支持视频播放
</video>
<!-- 调整大小的控制点 -->
<template v-if="isSelected || selected">
<div
class="resize-handle resize-handle-se"
@mousedown="startResize($event, 'se')"
></div>
<div
class="resize-handle resize-handle-sw"
@mousedown="startResize($event, 'sw')"
></div>
<div
class="resize-handle resize-handle-ne"
@mousedown="startResize($event, 'ne')"
></div>
<div
class="resize-handle resize-handle-nw"
@mousedown="startResize($event, 'nw')"
></div>
</template>
</div>
<!-- 右键菜单 -->
<Teleport to="body">
<div
v-if="showContextMenu"
class="video-context-menu"
:style="{
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
}"
@click.stop
>
<!-- 对齐方式 -->
<div class="menu-section">
<div class="menu-label">对齐方式</div>
<div class="menu-buttons">
<ElButton
:type="alignment === 'left' ? 'primary' : 'default'"
size="small"
@click="setAlignment('left')"
>
<AlignLeft class="h-4 w-4" />
</ElButton>
<ElButton
:type="alignment === 'center' ? 'primary' : 'default'"
size="small"
@click="setAlignment('center')"
>
<AlignCenter class="h-4 w-4" />
</ElButton>
<ElButton
:type="alignment === 'right' ? 'primary' : 'default'"
size="small"
@click="setAlignment('right')"
>
<AlignRight class="h-4 w-4" />
</ElButton>
</div>
</div>
<!-- 快速缩放 -->
<div class="menu-section">
<div class="menu-label">快速缩放</div>
<div class="menu-buttons">
<ElButton size="small" @click="setPresetSize(25)">25%</ElButton>
<ElButton size="small" @click="setPresetSize(50)">50%</ElButton>
<ElButton size="small" @click="setPresetSize(75)">75%</ElButton>
<ElButton size="small" @click="setPresetSize(100)">100%</ElButton>
</div>
</div>
<!-- 自定义尺寸 -->
<div class="menu-section">
<ElButton
size="small"
class="w-full"
@click="showCustomSize = !showCustomSize"
>
自定义尺寸
</ElButton>
<div v-if="showCustomSize" class="custom-size-panel">
<div class="size-inputs">
<div class="size-input-group">
<span class="size-label">:</span>
<ElInputNumber
:model-value="
typeof currentWidth === 'number' ? currentWidth : undefined
"
:min="100"
:max="1920"
size="small"
controls-position="right"
@update:model-value="updateWidth"
/>
</div>
<div class="size-input-group">
<span class="size-label">:</span>
<ElInputNumber
:model-value="
typeof currentHeight === 'number'
? currentHeight
: undefined
"
:min="60"
:max="1080"
size="small"
controls-position="right"
@update:model-value="updateHeight"
/>
</div>
</div>
<ElCheckbox v-model="keepRatio" size="small">保持比例</ElCheckbox>
</div>
</div>
<!-- 操作按钮 -->
<div class="menu-section menu-actions">
<ElButton size="small" @click="resetSize">
<RotateCcw class="mr-1 h-3 w-3" />
重置尺寸
</ElButton>
<ElButton size="small" type="danger" @click="handleDelete">
<Trash2 class="mr-1 h-3 w-3" />
删除视频
</ElButton>
</div>
</div>
</Teleport>
</NodeViewWrapper>
</template>
<style scoped>
.resizable-video-node {
display: block;
margin: 12px 0;
}
.resizable-video-wrapper {
position: relative;
display: inline-block;
max-width: 100%;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.resizable-video-wrapper:hover {
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
}
.resizable-video-wrapper.is-selected {
box-shadow: 0 0 0 2px var(--el-color-primary);
}
.resizable-video {
display: block;
width: 100%;
height: auto;
background-color: var(--el-fill-color-darker);
border-radius: 8px;
}
/* 调整大小控制点 */
.resize-handle {
position: absolute;
z-index: 10;
width: 12px;
height: 12px;
background-color: var(--el-color-primary);
border: 2px solid var(--el-bg-color);
border-radius: 50%;
}
.resize-handle-se {
right: -6px;
bottom: -6px;
cursor: se-resize;
}
.resize-handle-sw {
bottom: -6px;
left: -6px;
cursor: sw-resize;
}
.resize-handle-ne {
top: -6px;
right: -6px;
cursor: ne-resize;
}
.resize-handle-nw {
top: -6px;
left: -6px;
cursor: nw-resize;
}
/* 右键菜单 */
.video-context-menu {
position: fixed;
z-index: 9999;
min-width: 200px;
padding: 12px;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.menu-section {
margin-bottom: 12px;
}
.menu-section:last-child {
margin-bottom: 0;
}
.menu-label {
margin-bottom: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.menu-buttons {
display: flex;
gap: 4px;
}
.custom-size-panel {
padding: 8px;
margin-top: 8px;
background-color: var(--el-fill-color-light);
border-radius: 4px;
}
.size-inputs {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.size-input-group {
display: flex;
gap: 8px;
align-items: center;
}
.size-label {
min-width: 24px;
font-size: 12px;
color: var(--el-text-color-regular);
}
.menu-actions {
display: flex;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
}
</style>
@@ -1,115 +0,0 @@
import { mergeAttributes, Node } from '@tiptap/core';
export interface AttachmentOptions {
HTMLAttributes: Record<string, any>;
}
export interface AttachmentAttributes {
id: string;
name: string;
size: number;
type: string;
url: string;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
attachment: {
setAttachment: (options: AttachmentAttributes) => ReturnType;
};
}
}
export const Attachment = Node.create<AttachmentOptions>({
name: 'attachment',
group: 'block',
atom: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
id: {
default: null,
},
name: {
default: null,
},
size: {
default: 0,
},
type: {
default: null,
},
url: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="attachment"]',
},
];
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) {
const { id, name, size, type, url } = HTMLAttributes;
return [
'div',
mergeAttributes(this.options.HTMLAttributes, {
'data-type': 'attachment',
'data-id': id,
'data-name': name,
'data-size': size,
'data-file-type': type,
'data-url': url,
class: 'attachment-node',
}),
[
'a',
{
href: url,
target: '_blank',
download: name,
class: 'attachment-link',
},
['span', { class: 'attachment-icon' }, '📎'],
['span', { class: 'attachment-name' }, name],
['span', { class: 'attachment-size' }, formatFileSize(size)],
],
];
},
addCommands() {
return {
setAttachment:
(options: AttachmentAttributes) =>
({ commands }: { commands: any }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
}
export default Attachment;
@@ -1,66 +0,0 @@
import { Extension } from '@tiptap/core';
export interface FontSizeOptions {
types: string[];
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fontSize: {
setFontSize: (size: string) => ReturnType;
unsetFontSize: () => ReturnType;
};
}
}
export const FontSize = Extension.create<FontSizeOptions>({
name: 'fontSize',
addOptions() {
return {
types: ['textStyle'],
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
fontSize: {
default: null,
parseHTML: (element) =>
element.style.fontSize?.replace(/['"]+/g, ''),
renderHTML: (attributes) => {
if (!attributes.fontSize) {
return {};
}
return {
style: `font-size: ${attributes.fontSize}`,
};
},
},
},
},
];
},
addCommands() {
return {
setFontSize:
(fontSize) =>
({ chain }) => {
return chain().setMark('textStyle', { fontSize }).run();
},
unsetFontSize:
() =>
({ chain }) => {
return chain()
.setMark('textStyle', { fontSize: null })
.removeEmptyTextStyle()
.run();
},
};
},
});
@@ -1,57 +0,0 @@
import Image from '@tiptap/extension-image';
import { VueNodeViewRenderer } from '@tiptap/vue-3';
import ResizableImageComponent from './ResizableImageComponent.vue';
export const ResizableImage = Image.extend({
name: 'resizableImage',
addAttributes() {
return {
...this.parent?.(),
width: {
default: null,
parseHTML: (element) =>
element.getAttribute('width') ||
element.style.width?.replace('px', ''),
renderHTML: (attributes) => {
if (!attributes.width) return {};
return { width: attributes.width };
},
},
height: {
default: null,
parseHTML: (element) =>
element.getAttribute('height') ||
element.style.height?.replace('px', ''),
renderHTML: (attributes) => {
if (!attributes.height) return {};
return { height: attributes.height };
},
},
alignment: {
default: 'center',
parseHTML: (element) => element.dataset.alignment || 'center',
renderHTML: (attributes) => {
const alignment = attributes.alignment || 'center';
// 同时输出 data-alignment 和 style
const styleMap: Record<string, string> = {
left: 'display: block; margin-left: 0; margin-right: auto;',
center: 'display: block; margin-left: auto; margin-right: auto;',
right: 'display: block; margin-left: auto; margin-right: 0;',
};
return {
'data-alignment': alignment,
style: styleMap[alignment] || styleMap.center,
};
},
},
};
},
addNodeView() {
return VueNodeViewRenderer(ResizableImageComponent as any);
},
});
export default ResizableImage;
@@ -1,124 +0,0 @@
import { mergeAttributes, Node } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-3';
import ResizableVideoComponent from './ResizableVideoComponent.vue';
export interface VideoOptions {
HTMLAttributes: Record<string, any>;
allowFullscreen: boolean;
}
export interface VideoAttributes {
id?: string;
src: string;
width?: number | string;
height?: number | string;
alignment?: 'center' | 'left' | 'right';
poster?: string;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
video: {
setVideo: (options: VideoAttributes) => ReturnType;
};
}
}
export const Video = Node.create<VideoOptions>({
name: 'video',
group: 'block',
atom: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {},
allowFullscreen: true,
};
},
addAttributes() {
return {
id: {
default: null,
},
src: {
default: null,
},
width: {
default: '100%',
},
height: {
default: 'auto',
},
alignment: {
default: 'center',
},
poster: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="video"]',
},
];
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) {
const { id, src, width, poster, alignment } = HTMLAttributes;
let marginStyle = 'margin-left: auto; margin-right: auto;';
if (alignment === 'left') {
marginStyle = 'margin-right: auto;';
} else if (alignment === 'right') {
marginStyle = 'margin-left: auto;';
}
return [
'div',
mergeAttributes(this.options.HTMLAttributes, {
'data-type': 'video',
'data-id': id,
class: 'video-node',
style: `max-width: ${typeof width === 'number' ? `${width}px` : width}; ${marginStyle}`,
}),
[
'video',
{
src,
controls: true,
poster,
style: 'width: 100%; height: auto; border-radius: 8px;',
},
'您的浏览器不支持视频播放',
],
];
},
addNodeView() {
return VueNodeViewRenderer(ResizableVideoComponent as any);
},
addCommands() {
return {
setVideo:
(options: VideoAttributes) =>
({ commands }: { commands: any }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});
export default Video;
@@ -1,2 +0,0 @@
export { default as RichTextEditor } from './rich-text-editor.vue';
export type * from './types';
File diff suppressed because it is too large Load Diff
@@ -1,82 +0,0 @@
import type { Editor } from '@tiptap/vue-3';
export interface RichTextEditorProps {
/** 编辑器内容 (HTML 格式) */
modelValue?: string;
/** 占位符 */
placeholder?: string;
/** 是否禁用 */
disabled?: boolean;
/** 是否只读 */
readonly?: boolean;
/** 最小高度 */
minHeight?: number | string;
/** 最大高度 */
maxHeight?: number | string;
/** 是否显示工具栏 */
showToolbar?: boolean;
/** 是否显示字数统计 */
showWordCount?: boolean;
/** 最大字符数 */
maxLength?: number;
/** 自定义工具栏配置 */
toolbarConfig?: ToolbarConfig;
}
export interface RichTextEditorEmits {
(e: 'update:modelValue', value: string): void;
(e: 'change', value: string): void;
(e: 'focus', event: FocusEvent): void;
(e: 'blur', event: FocusEvent): void;
(e: 'ready', editor: Editor): void;
}
export interface ToolbarConfig {
/** 显示的工具组 */
groups?: ToolbarGroup[];
/** 插入功能细分控制 */
insert?: {
attachment?: boolean;
image?: boolean;
link?: boolean;
table?: boolean;
video?: boolean;
};
}
export type ToolbarGroup =
| 'align' // 对齐方式
| 'blockquote' // 引用
| 'clear' // 清除格式
| 'code' // 代码
| 'color' // 文字颜色/背景色
| 'divider' // 分割线
| 'format' // 加粗/斜体/下划线/删除线
| 'heading' // 标题
| 'history' // 撤销/重做
| 'indent' // 缩进
| 'insert' // 插入链接/图片/表格/附件/视频
| 'list'; // 列表
export const defaultToolbarGroups: ToolbarGroup[] = [
'history',
'heading',
'format',
'color',
'align',
'list',
'insert',
'blockquote',
'code',
'divider',
'clear',
];
/** 默认插入功能配置 - 基础功能默认开启 */
export const defaultInsertConfig = {
link: true,
image: true,
table: true,
attachment: false, // 附件默认关闭
video: false, // 视频默认关闭
};
@@ -1,37 +1,5 @@
import type { RouteRecordRaw } from 'vue-router';
// import { BasicLayout } from '#/layouts';
// import { BookOpenText } from '@vben/icons';
const routes: RouteRecordRaw[] = [
// {
// component: BasicLayout,
// meta: {
// icon: BookOpenText,
// order: 9999,
// title: '组件示例',
// },
// name: 'Demos',
// path: '/demos',
// children: [
// {
// meta: {
// title: 'Notion 编辑器',
// },
// name: 'NotionEditorDemo',
// path: '/demos/notion-editor',
// component: () => import('#/views/demos/notion-editor.vue'),
// },
// {
// meta: {
// title: '富文本编辑器',
// },
// name: 'RichTextEditorDemo',
// path: '/demos/rich-text-editor',
// component: () => import('#/views/demos/rich-text-editor.vue'),
// },
// ],
// },
];
const routes: RouteRecordRaw[] = [];
export default routes;