封装

<script setup lang="ts">
import Quill from 'quill'
import type { Delta, EmitterSource, QuillOptions, Range } from 'quill'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Mention, MentionBlot } from 'quill-mention' // 插件
import 'quill/dist/quill.snow.css'
import '@/assets/styles/quill-mention/quill.mention.min.css'
import undo from '@/assets/icons/quill-undo.svg?raw'
import redo from '@/assets/icons/quill-redo.svg?raw'
import attachment from '@/assets/icons/quill-attachment.svg?raw'
import useUsersStore from '@/store/cache/users'
import type { ApiError, User } from '@/models'
import { uploadFile as apiUploadFile } from '@/api/modules'

const props = defineProps<{
  // HTML model value, supports v-model
  modelValue?: string | null
  // Quill initialization options
  options?: QuillOptions
  semantic?: boolean
}>()
const emit = defineEmits<{
  (e: 'update:modelValue', value: string): void
  (e: 'textChange', { delta, oldContent, source }: { delta: Delta; oldContent: Delta; source: EmitterSource }): void
  (e: 'selectionChange', { range, oldRange, source }: { range: Range; oldRange: Range; source: EmitterSource }): void
  (e: 'editorChange', eventName: 'textChange' | 'selectionChange'): void
  (e: 'blur', quill: Quill): void
  (e: 'focus', quill: Quill): void
  (e: 'ready', quill: Quill): void
}>()

let quillInstance: Quill | null = null
const quillEditor = ref<HTMLElement>()
const model = ref<string | null>()
const userList = ref<User[]>([])
const fileUploadLoading = ref(false)

const defaultOptions: QuillOptions = {
  theme: 'snow',
  placeholder: '',
  readOnly: false,
  modules: {
    toolbar: {
      container: [
        ['undo', 'redo'],
        ['bold', 'italic', 'underline'],
        [{ background: [] }],
        [{ align: [] }],
        [{ list: 'ordered' }, { list: 'bullet' }],
        ['link', 'attachment'],
      ],
      handlers: {
        image: fileHandler('image'), // 正确放置 handlers
        attachment: fileHandler(),
        undo: () => {
          quillInstance?.history.undo()
          updateHistoryStatus()
        },
        redo: () => {
          quillInstance?.history.redo()
          updateHistoryStatus()
        },
      },
    },
    mention: {
      blotName: 'styled-mention',
      allowedChars: /^[\u4E00-\u9FA5\w]*$/, // 允许中文/英文/数字/下划线
      mentionDenotationChars: ['@'], // 触发字符
      source(searchTerm: any, renderList: any, mentionChar: any) {
        // 这里可以换成后端接口
        const users = userList.value.map((u) => {
          return {
            id: u.id,
            value: u.fullName,
            avatar: u.avatar,
            email: u.email,
          }
        })
        const matches = users.filter(u => u.value.toLowerCase().includes(searchTerm.toLowerCase()))
        renderList(matches, searchTerm)
      },
      renderItem(item: any) {
        const container = document.createElement('div')
        container.className = 'mention-list-item'

        const avatarWrapper = document.createElement('div')
        avatarWrapper.className = 'mention-avatar'

        if (item.avatar) {
          const avatarImg = document.createElement('img')
          avatarImg.src = item.avatar
          avatarImg.alt = item.value
          avatarImg.className = 'mention-avatar-img'
          avatarWrapper.appendChild(avatarImg)
        }
        else {
          avatarWrapper.textContent = item.value.charAt(0).toUpperCase()
        }

        const text = document.createElement('div')
        text.className = 'mention-text'

        const name = document.createElement('div')
        name.className = 'mention-name'
        name.innerText = item.value

        const email = document.createElement('div')
        email.className = 'mention-email'
        email.innerText = item.email

        text.appendChild(name)
        text.appendChild(email)

        container.appendChild(avatarWrapper)
        container.appendChild(text)

        return container
      },
      onSelect(item: any, insertItem: any) {
        insertItem({ ...item, value: `${item.value} ` })
      },
    },
  },
}

// Convert modelValue HTML to Delta and replace editor content
const pasteHTML = (quill: Quill) => {
  model.value = props.modelValue
  const oldContent = quill.getContents()
  const delta = quill.clipboard.convert({ html: props.modelValue ?? '' })
  quill.setContents(delta)
  emit('textChange', { delta, oldContent, source: 'api' })
}

function registerMention() {
  class StyledMentionBlot extends MentionBlot {
    static render(data: any) {
      const element = document.createElement('span')
      element.innerText = data.value
      element.setAttribute('mention-id', data.id)
      return element
    }
  }
  StyledMentionBlot.blotName = 'styled-mention'
  if (!Quill.imports['formats/styled-mention']) {
    Quill.register('formats/styled-mention', StyledMentionBlot, true)
  }

  if (!Quill.imports['modules/mention']) {
    Quill.register('modules/mention', Mention, true)
  }
}

function customIcons() {
  const icons = Quill.import('ui/icons') as any
  icons.undo = undo
  icons.redo = redo
  icons.attachment = attachment
}

// Editor initialization, returns Quill instance
const initialize = async () => {
  registerMention()
  customIcons()
  userList.value = await useUsersStore().asyncGetList()
  Object.assign(defaultOptions, props.options)
  const quill = new Quill(quillEditor.value as HTMLElement, defaultOptions)

  // Set editor initial model
  if (props.modelValue) {
    pasteHTML(quill)
  }

  // Handle editor selection change, emit blur and focus
  quill.on('selection-change', (range: any, oldRange: any, source: any) => {
    if (!range) {
      emit('blur', quill)
    }
    else {
      emit('focus', quill)
    }
    updateHistoryStatus()
    emit('selectionChange', { range, oldRange, source })
  })

  // Handle editor text change
  quill.on('text-change', (delta: any, oldContent: any, source: any) => {
    model.value = props.semantic ? quill.getSemanticHTML() : quill.root.innerHTML
    updateHistoryStatus()
    emit('textChange', { delta, oldContent, source })
  })

  // Handle editor change
  quill.on('editor-change', (eventName: 'textChange' | 'selectionChange') => {
    emit('editorChange', eventName)
  })

  emit('ready', quill)

  quillInstance = quill
  updateHistoryStatus()

  return quill
}

function updateHistoryStatus() {
  if (!quillInstance) {
    return
  }
  const undoBtn = document.querySelector('.ql-undo') as HTMLButtonElement
  const redoBtn = document.querySelector('.ql-redo') as HTMLButtonElement
  if (!undoBtn || !redoBtn) {
    return
  }
  undoBtn.disabled = quillInstance.history.stack.undo.length === 0
  redoBtn.disabled = quillInstance.history.stack.redo.length === 0
}

// Watch modelValue and paste HTML if has changes
watch(
  () => props.modelValue,
  (newValue) => {
    if (!quillInstance) {
      return
    }
    if (newValue && newValue !== model.value) {
      pasteHTML(quillInstance)
      // Update HTML model depending on type
      model.value = props.semantic ? quillInstance.getSemanticHTML() : quillInstance.root.innerHTML
    }
    else if (!newValue) {
      quillInstance.setContents([])
    }
  },
)

// Watch model and update modelValue if has changes
watch(model, (newValue, oldValue) => {
  if (!quillInstance) {
    return
  }
  if (newValue && newValue !== oldValue) {
    emit('update:modelValue', newValue)
  }
  else if (!newValue) {
    quillInstance.setContents([])
  }
})

function getQuillInstance() {
  return quillInstance
}

function fileHandler(type?: string) {
  return () => {
    const input = document.createElement('input')
    input.setAttribute('type', 'file')
    if (type) {
      input.setAttribute('accept', `${type}/*`)
    }
    input.click()

    input.onchange = async () => {
      const file = input.files?.[0]
      if (!file) {
        return
      }

      fileUploadLoading.value = true
      const formData = new FormData()
      formData.append('file', file)
      apiUploadFile(formData)
        .then((res) => {
          if (quillInstance) {
            const index = quillInstance.getSelection()?.index || 0

            switch (type) {
              case 'image':
                quillInstance.insertEmbed(index, 'image', res)
                break
              default: {
                const linkHtml = `<a href="${res}" target="_blank">🔗${file.name}</a>`
                quillInstance.clipboard.dangerouslyPasteHTML(index, linkHtml)
                break
              }
            }
          }
        })
        .catch((error: ApiError) => {
          ElMessage.error(error.message)
        })
        .finally(() => {
          fileUploadLoading.value = false
        })
    }
  }
}

onMounted(() => {
  initialize()
})

onBeforeUnmount(() => {
  quillInstance = null
})

// Expose init function
defineExpose<{
  getQuillInstance: () => Quill | null
}>({
      getQuillInstance,
    })
</script>

<template>
  <div class="quill-container">
    <div ref="quillEditor" />
  </div>
</template>

<style lang="scss" scoped>
.quill-container {
  width: 100%;
  height: 100%;
  min-height: 200px;
  display: flex;
  flex-direction: column;
  line-height: normal;

  :deep(.ql-toolbar) {
    border-top-left-radius: 4px;
    border-top-right-radius: 4px;
  }

  :deep(.ql-container) {
    font-size: 16px;
    border-bottom-left-radius: 4px;
    border-bottom-right-radius: 4px;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
      'Helvetica Neue', sans-serif;
  }

  :deep(.ql-blank::before) {
    font-style: normal;
  }

  :deep(.ql-undo[disabled]),
  :deep(.ql-redo[disabled]) {
    opacity: 0.5;
    cursor: not-allowed;

    .ql-stroke {
      stroke: #444 !important;
    }
  }

  :deep(.ql-mention-list) {
    padding: 4px;
  }

  :deep(.ql-mention-list-item) {
    padding: 0;
    line-height: normal;
    border-radius: 3px;
  }

  // mention style
  :deep(.mention-list-item) {
    display: flex;
    padding: 4px 8px;
    border-radius: 3px;
    align-items: center;

    .mention-avatar {
      width: 45px;
      height: 45px;
      border-radius: 50%;
      background-color: var(--el-text-color-disabled);
      color: var(--el-color-white);
      display: flex;
      align-items: center;
      justify-content: center;
      margin-right: var(--g-app-margin);
      overflow: hidden;
      flex-shrink: 0;
    }

    .mention-avatar-img {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }

    .mention-text {
      flex: 1;
      display: flex;
      justify-content: flex-start;
      align-items: flex-start;
      flex-direction: column;
    }

    .mention-name {
      display: inline-block;
      font-size: 14px;
      font-weight: 400;
      line-height: 24px;
      text-transform: none;
    }
    .mention-email {
      color: var(--el-text-color-secondary);
      font-size: var(--el-font-size-extra-small);
      width: 160px;
      overflow: hidden;
      text-overflow: ellipsis;
    }
  }
}
</style>

使用

<QuillEditor ref="editorRef" v-model:model-value="content" :options="options" />