ProsemirrorEditor.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. <template>
  2. <div
  3. class="prosemirror-editor"
  4. :class="{ 'format-painter': textFormatPainter }"
  5. ref="editorViewRef"
  6. @mousedown="$event => emit('mousedown', $event)"
  7. ></div>
  8. </template>
  9. <script lang="ts" setup>
  10. import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
  11. import { debounce } from 'lodash'
  12. import { storeToRefs } from 'pinia'
  13. import { useMainStore } from '@/store'
  14. import type { EditorView } from 'prosemirror-view'
  15. import { toggleMark, wrapIn, lift } from 'prosemirror-commands'
  16. import { initProsemirrorEditor, createDocument } from '@/utils/prosemirror'
  17. import { isActiveOfParentNodeType, findNodesWithSameMark, getTextAttrs, autoSelectAll, addMark, markActive, getFontsize } from '@/utils/prosemirror/utils'
  18. import emitter, { EmitterEvents, type RichTextAction, type RichTextCommand } from '@/utils/emitter'
  19. import { alignmentCommand } from '@/utils/prosemirror/commands/setTextAlign'
  20. import { indentCommand, textIndentCommand } from '@/utils/prosemirror/commands/setTextIndent'
  21. import { toggleList } from '@/utils/prosemirror/commands/toggleList'
  22. import { setListStyle } from '@/utils/prosemirror/commands/setListStyle'
  23. import type { TextFormatPainterKeys } from '@/types/edit'
  24. import { KEYS } from '@/configs/hotkey'
  25. const props = withDefaults(defineProps<{
  26. elementId: string
  27. defaultColor: string
  28. defaultFontName: string
  29. value: string
  30. editable?: boolean
  31. autoFocus?: boolean
  32. }>(), {
  33. editable: false,
  34. autoFocus: false,
  35. })
  36. const emit = defineEmits<{
  37. (event: 'update', payload: { value: string; ignore: boolean }): void
  38. (event: 'focus'): void
  39. (event: 'blur'): void
  40. (event: 'mousedown', payload: MouseEvent): void
  41. }>()
  42. const mainStore = useMainStore()
  43. const { handleElementId, textFormatPainter, richTextAttrs } = storeToRefs(mainStore)
  44. const editorViewRef = ref<HTMLElement>()
  45. let editorView: EditorView
  46. // 富文本的各种交互事件监听:
  47. // 聚焦时取消全局快捷键事件
  48. // 输入文字时同步数据到vuex
  49. // 点击鼠标和键盘时同步富文本状态到工具栏
  50. const handleInput = debounce(function(isHanldeHistory = false) {
  51. if (props.value.replace(/ style=\"\"/g, '') === editorView.dom.innerHTML.replace(/ style=\"\"/g, '')) return
  52. emit('update', {
  53. value: editorView.dom.innerHTML,
  54. ignore: isHanldeHistory,
  55. })
  56. }, 300, { trailing: true })
  57. const handleFocus = () => {
  58. mainStore.setDisableHotkeysState(true)
  59. emit('focus')
  60. }
  61. const handleBlur = () => {
  62. mainStore.setDisableHotkeysState(false)
  63. emit('blur')
  64. }
  65. const handleClick = debounce(function() {
  66. const attrs = getTextAttrs(editorView, {
  67. color: props.defaultColor,
  68. fontname: props.defaultFontName,
  69. })
  70. mainStore.setRichtextAttrs(attrs)
  71. }, 30, { trailing: true })
  72. const handleKeydown = (editorView: EditorView, e: KeyboardEvent) => {
  73. const { ctrlKey, shiftKey, metaKey } = e
  74. const ctrlActive = ctrlKey || shiftKey || metaKey
  75. const key = e.key.toUpperCase()
  76. const isHanldeHistory = ctrlActive && (key === KEYS.Z || key === KEYS.Y)
  77. handleInput(isHanldeHistory)
  78. handleClick()
  79. }
  80. // 将富文本内容同步到DOM
  81. const textContent = computed(() => props.value)
  82. watch(textContent, () => {
  83. if (!editorView) return
  84. if (editorView.hasFocus()) return
  85. const { doc, tr } = editorView.state
  86. editorView.dispatch(tr.replaceRangeWith(0, doc.content.size, createDocument(textContent.value)))
  87. })
  88. // 打开/关闭编辑器的编辑模式
  89. watch(() => props.editable, () => {
  90. editorView.setProps({ editable: () => props.editable })
  91. })
  92. // 暴露 focus 方法
  93. const focus = () => editorView.focus()
  94. defineExpose({ focus })
  95. // 执行富文本命令(可以是一个或多个)
  96. // 部分命令在执行前先判断当前选区是否为空,如果选区为空先进行全选操作
  97. const execCommand = ({ target, action }: RichTextCommand) => {
  98. if (!target && handleElementId.value !== props.elementId) return
  99. if (target && target !== props.elementId) return
  100. const actions = ('command' in action) ? [action] : action
  101. for (const item of actions) {
  102. if (item.command === 'fontname' && item.value) {
  103. const mark = editorView.state.schema.marks.fontname.create({ fontname: item.value })
  104. autoSelectAll(editorView)
  105. addMark(editorView, mark)
  106. }
  107. else if (item.command === 'fontsize' && item.value) {
  108. const mark = editorView.state.schema.marks.fontsize.create({ fontsize: item.value })
  109. autoSelectAll(editorView)
  110. addMark(editorView, mark)
  111. setListStyle(editorView, { key: 'fontsize', value: item.value })
  112. }
  113. else if (item.command === 'fontsize-add') {
  114. const step = item.value ? +item.value : 2
  115. autoSelectAll(editorView)
  116. const fontsize = getFontsize(editorView) + step + 'px'
  117. const mark = editorView.state.schema.marks.fontsize.create({ fontsize })
  118. addMark(editorView, mark)
  119. setListStyle(editorView, { key: 'fontsize', value: fontsize })
  120. }
  121. else if (item.command === 'fontsize-reduce') {
  122. const step = item.value ? +item.value : 2
  123. autoSelectAll(editorView)
  124. let fontsize = getFontsize(editorView) - step
  125. if (fontsize < 12) fontsize = 12
  126. const mark = editorView.state.schema.marks.fontsize.create({ fontsize: fontsize + 'px' })
  127. addMark(editorView, mark)
  128. setListStyle(editorView, { key: 'fontsize', value: fontsize + 'px' })
  129. }
  130. else if (item.command === 'color' && item.value) {
  131. const mark = editorView.state.schema.marks.forecolor.create({ color: item.value })
  132. autoSelectAll(editorView)
  133. addMark(editorView, mark)
  134. setListStyle(editorView, { key: 'color', value: item.value })
  135. }
  136. else if (item.command === 'backcolor' && item.value) {
  137. const mark = editorView.state.schema.marks.backcolor.create({ backcolor: item.value })
  138. autoSelectAll(editorView)
  139. addMark(editorView, mark)
  140. }
  141. else if (item.command === 'bold') {
  142. autoSelectAll(editorView)
  143. toggleMark(editorView.state.schema.marks.strong)(editorView.state, editorView.dispatch)
  144. }
  145. else if (item.command === 'em') {
  146. autoSelectAll(editorView)
  147. toggleMark(editorView.state.schema.marks.em)(editorView.state, editorView.dispatch)
  148. }
  149. else if (item.command === 'underline') {
  150. autoSelectAll(editorView)
  151. toggleMark(editorView.state.schema.marks.underline)(editorView.state, editorView.dispatch)
  152. }
  153. else if (item.command === 'strikethrough') {
  154. autoSelectAll(editorView)
  155. toggleMark(editorView.state.schema.marks.strikethrough)(editorView.state, editorView.dispatch)
  156. }
  157. else if (item.command === 'subscript') {
  158. toggleMark(editorView.state.schema.marks.subscript)(editorView.state, editorView.dispatch)
  159. }
  160. else if (item.command === 'superscript') {
  161. toggleMark(editorView.state.schema.marks.superscript)(editorView.state, editorView.dispatch)
  162. }
  163. else if (item.command === 'blockquote') {
  164. const isBlockquote = isActiveOfParentNodeType('blockquote', editorView.state)
  165. if (isBlockquote) lift(editorView.state, editorView.dispatch)
  166. else wrapIn(editorView.state.schema.nodes.blockquote)(editorView.state, editorView.dispatch)
  167. }
  168. else if (item.command === 'code') {
  169. toggleMark(editorView.state.schema.marks.code)(editorView.state, editorView.dispatch)
  170. }
  171. else if (item.command === 'align' && item.value) {
  172. alignmentCommand(editorView, item.value)
  173. }
  174. else if (item.command === 'indent' && item.value) {
  175. indentCommand(editorView, +item.value)
  176. }
  177. else if (item.command === 'textIndent' && item.value) {
  178. textIndentCommand(editorView, +item.value)
  179. }
  180. else if (item.command === 'bulletList') {
  181. const listStyleType = item.value || ''
  182. const { bullet_list: bulletList, list_item: listItem } = editorView.state.schema.nodes
  183. const textStyle = {
  184. color: richTextAttrs.value.color,
  185. fontsize: richTextAttrs.value.fontsize
  186. }
  187. toggleList(bulletList, listItem, listStyleType, textStyle)(editorView.state, editorView.dispatch)
  188. }
  189. else if (item.command === 'orderedList') {
  190. const listStyleType = item.value || ''
  191. const { ordered_list: orderedList, list_item: listItem } = editorView.state.schema.nodes
  192. const textStyle = {
  193. color: richTextAttrs.value.color,
  194. fontsize: richTextAttrs.value.fontsize
  195. }
  196. toggleList(orderedList, listItem, listStyleType, textStyle)(editorView.state, editorView.dispatch)
  197. }
  198. else if (item.command === 'clear') {
  199. autoSelectAll(editorView)
  200. const { $from, $to } = editorView.state.selection
  201. editorView.dispatch(editorView.state.tr.removeMark($from.pos, $to.pos))
  202. setListStyle(editorView, [
  203. { key: 'fontsize', value: '' },
  204. { key: 'color', value: '' },
  205. ])
  206. }
  207. else if (item.command === 'link') {
  208. const markType = editorView.state.schema.marks.link
  209. const { from, to } = editorView.state.selection
  210. const result = findNodesWithSameMark(editorView.state.doc, from, to, markType)
  211. if (result) {
  212. if (item.value) {
  213. const mark = editorView.state.schema.marks.link.create({ href: item.value, title: item.value })
  214. addMark(editorView, mark, { from: result.from.pos, to: result.to.pos + 1 })
  215. }
  216. else editorView.dispatch(editorView.state.tr.removeMark(result.from.pos, result.to.pos + 1, markType))
  217. }
  218. else if (markActive(editorView.state, markType)) {
  219. if (item.value) {
  220. const mark = editorView.state.schema.marks.link.create({ href: item.value, title: item.value })
  221. addMark(editorView, mark)
  222. }
  223. else toggleMark(markType)(editorView.state, editorView.dispatch)
  224. }
  225. else if (item.value) {
  226. autoSelectAll(editorView)
  227. toggleMark(markType, { href: item.value, title: item.value })(editorView.state, editorView.dispatch)
  228. }
  229. }
  230. else if (item.command === 'insert' && item.value) {
  231. editorView.dispatch(editorView.state.tr.insertText(item.value))
  232. }
  233. }
  234. editorView.focus()
  235. handleInput()
  236. handleClick()
  237. }
  238. // 鼠标抬起时,执行格式刷命令
  239. const handleMouseup = () => {
  240. if (!textFormatPainter.value) return
  241. const { keep, ...newProps } = textFormatPainter.value
  242. const actions: RichTextAction[] = [{ command: 'clear' }]
  243. for (const key of Object.keys(newProps) as TextFormatPainterKeys[]) {
  244. const command = key
  245. const value = textFormatPainter.value[key]
  246. if (value === true) actions.push({ command })
  247. else if (value) actions.push({ command, value })
  248. }
  249. execCommand({ action: actions })
  250. if (!keep) mainStore.setTextFormatPainter(null)
  251. }
  252. // Prosemirror编辑器的初始化和卸载
  253. onMounted(() => {
  254. editorView = initProsemirrorEditor((editorViewRef.value as Element), textContent.value, {
  255. handleDOMEvents: {
  256. focus: handleFocus,
  257. blur: handleBlur,
  258. keydown: handleKeydown,
  259. click: handleClick,
  260. mouseup: handleMouseup,
  261. },
  262. editable: () => props.editable,
  263. })
  264. if (props.autoFocus) editorView.focus()
  265. })
  266. onUnmounted(() => {
  267. editorView && editorView.destroy()
  268. })
  269. const syncAttrsToStore = () => {
  270. if (handleElementId.value !== props.elementId) return
  271. handleClick()
  272. }
  273. emitter.on(EmitterEvents.RICH_TEXT_COMMAND, execCommand)
  274. emitter.on(EmitterEvents.SYNC_RICH_TEXT_ATTRS_TO_STORE, syncAttrsToStore)
  275. onUnmounted(() => {
  276. emitter.off(EmitterEvents.RICH_TEXT_COMMAND, execCommand)
  277. emitter.off(EmitterEvents.SYNC_RICH_TEXT_ATTRS_TO_STORE, syncAttrsToStore)
  278. })
  279. </script>
  280. <style lang="scss" scoped>
  281. .prosemirror-editor {
  282. cursor: text;
  283. &.format-painter {
  284. cursor: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuMzUuMDEybC0uMDY2Ljk5OGE1LjI3MSA1LjI3MSAwIDAwLTEuMTg0LjA2IDMuOCAzLjggMCAwMC0uOTMzLjQ3MmMtLjQ0LjM1Ni0uNzgzLjgxMS0uOTk4IDEuMzI0bC4wMTgtLjAzNnY1LjEyaDEuMDR2Ljk4aC0xLjA0bC0uMDAyIDQuMTVjLjE4Ny40MjYuNDYuODEuNzkxIDEuMTE3bC4xNzUuMTUyYy4yOTMuMjA4LjYxNS4zNzMuODkuNDcyLjQxLjA4Mi44My4xMTIgMS4yNDkuMDlsLjA1Ny45OTlhNi4wNjMgNi4wNjMgMCAwMS0xLjU4OC0uMTI5IDQuODM2IDQuODM2IDAgMDEtMS4yNS0uNjQ3IDQuNDYzIDQuNDYzIDAgMDEtLjgzOC0uODgzYy0uMjI0LjMzMi0uNS42NDItLjgyNC45MjdhNC4xMSA0LjExIDAgMDEtMS4zMDUuNjMzQTYuMTI2IDYuMTI2IDAgMDEwIDE1LjkwOWwuMDY4LS45OTdjLjQyNC4wMjYuODUtLjAwMSAxLjIxNy0uMDcuMzM2LS4wOTkuNjUxLS4yNTQuODk0LS40My40My0uMzguNzY1LS44NDcuOTgyLTEuMzY4bC0uMDA1LjAxNFY4LjkzSDIuMTE1di0uOThoMS4wNFYyLjg2MmEzLjc3IDMuNzcgMCAwMC0uNzc0LTEuMTY3bC0uMTY1LS4xNTZhMy4wNjQgMy4wNjQgMCAwMC0uODgtLjQ0OEE1LjA2MiA1LjA2MiAwIDAwLjA2NyAxLjAxTDAgLjAxMmE2LjE0IDYuMTQgMCAwMTEuNTkyLjExYy40NTMuMTM1Ljg3Ny4zNDUgMS4yOS42NS4zLjI2NS41NjUuNTY0Ljc4Ny44OS4yMzMtLjMzMS41Mi0uNjM0Ljg1My0uOTA0YTQuODM1IDQuODM1IDAgMDExLjMtLjY0OEE2LjE1NSA2LjE1NSAwIDAxNy4zNS4wMTJ6IiBmaWxsPSIjMEQwRDBEIi8+PHBhdGggZD0iTTE3LjM1IDE0LjVsNC41LTQuNS02LTZjLTIgMi0zIDItNS41IDIuNS40IDMuMiA0LjgzMyA2LjY2NyA3IDh6bTQuNTg4LTQuNDkzYS4zLjMgMCAwMC40MjQgMGwuNjgtLjY4YTEuNSAxLjUgMCAwMDAtMi4xMjJMMjEuNjkgNS44NTNsMi4wMjUtMS41ODNhMS42MjkgMS42MjkgMCAxMC0yLjI3OS0yLjI5NmwtMS42MDMgMi4wMjItMS4zNTctMS4zNTdhMS41IDEuNSAwIDAwLTIuMTIxIDBsLS42OC42OGEuMy4zIDAgMDAwIC40MjVsNi4yNjMgNi4yNjN6IiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTE1Ljg5MiAzLjk2MnMtMS4wMyAxLjIwMi0yLjQ5NCAxLjg5Yy0xLjAwNi40NzQtMi4xOC41ODYtMi43MzQuNjI3LS4yLjAxNS0uMzQ0LjIxLS4yNzYuMzk5LjI5Mi44MiAxLjExMiAyLjggMi42NTggNC4zNDYgMi4xMjYgMi4xMjcgMy42NTggMi45NjggNC4xNDIgMy4yMDMuMS4wNDguMjE0LjAzLjI5OC0uMDQyLjM4Ni0uMzI1IDEuNS0xLjI3NyAyLjIxLTEuOTg2Ljg5Mi0uODg5IDIuMTg3LTIuNDQ3IDIuMTg3LTIuNDQ3bS40NzkuMDU1YS4zLjMgMCAwMS0uNDI0IDBsLTYuMjY0LTYuMjYzYS4zLjMgMCAwMTAtLjQyNWwuNjgtLjY4YTEuNSAxLjUgMCAwMTIuMTIyIDBsMS4zNTcgMS4zNTcgMS42MDMtMi4wMjJhMS42MjkgMS42MjkgMCAxMTIuMjggMi4yOTZMMjEuNjkgNS44NTNsMS4zNTIgMS4zNTJhMS41IDEuNSAwIDAxMCAyLjEyMmwtLjY4LjY4eiIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+) 2 5, default !important;
  285. }
  286. }
  287. </style>