Remove unused rich form widgets
This commit is contained in:
Generated
-429
@@ -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
-224
@@ -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';
|
||||
-654
@@ -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>
|
||||
-536
@@ -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();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
-57
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user