zjk
2026-07-01 01b7aaee963317f2bfeb6bc68094e7eee76f3b0c
src/components/custom/Editor/AiRichTextEditor/index.vue
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AiEditor as AiEditorInstance, AiEditorOptions, Uploader } from 'aieditor'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AiEditor } from 'aieditor'
import 'aieditor/dist/style.css'
import { uploadSingleFile } from '@/utils/upload'
@@ -13,6 +13,7 @@
  disabled: false,
  readOnly: false,
  value: '',
  maxLength: 20000,
  placeholder: '请输入内容...',
  height: '360px',
  toolbarKeys: () => [
@@ -58,17 +59,26 @@
  value?: string
  placeholder?: string
  height?: string
  maxLength?: number
  toolbarKeys?: AiEditorOptions['toolbarKeys']
}
const model = defineModel<string>()
const editorRef = ref<HTMLElement | null>(null)
const editableRoot = ref<HTMLElement | null>(null)
const guardObserver = ref<MutationObserver | null>(null)
const editor = ref<AiEditorInstance | null>(null)
const lastHtml = ref('')
const currentTextCount = ref(0)
const hasMaxLengthOverflow = ref(false)
const isUnmounted = ref(false)
const isApplyingExternalContent = ref(false)
const isTruncating = ref(false)
let applyContentVersion = 0
const showCount = computed(() => typeof props.maxLength === 'number' && props.maxLength > 0)
const isCountOverflow = computed(() => showCount.value && hasMaxLengthOverflow.value)
const commonUploader: Uploader = async (file) => {
  const res = await uploadSingleFile(file)
@@ -265,12 +275,247 @@
  return preserveConsecutiveSpaces(formatRichTextList(formatTableForMiniProgram(html)))
}
function syncModelValue(html: string) {
  const formattedHtml = formatRichTextContent(html)
  if (isApplyingExternalContent.value) {
    lastHtml.value = formattedHtml
function getTextLengthByHtml(html: string) {
  if (!html)
    return 0
  if (typeof document === 'undefined')
    return html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').length
  const container = document.createElement('div')
  container.innerHTML = html
  // 必须严格使用 textContent,绝对不能用 innerText。
  // 因为 innerText 会根据 CSS 样式额外计算出换行和空格,导致总长度和底层 TreeWalker 遍历的节点长度不一致!
  // 这种不一致会导致长文本截断算法失效。
  const text = container.textContent || ''
  return text.replace(/[\r\n\u200B-\u200D\uFEFF]/g, '').length
}
function truncateHtmlByTextLength(html: string, maxLength: number) {
  if (!html || typeof document === 'undefined')
    return html || ''
  const container = document.createElement('div')
  container.innerHTML = html
  const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT)
  let currentLength = 0
  const nodesToRemove: Node[] = []
  let currentNode = walker.nextNode()
  while (currentNode) {
    const nodeText = currentNode.nodeValue || ''
    // 计算长度时,剔除不可见字符,避免长度计算偏大导致错误截断
    const actualText = nodeText.replace(/[\r\n\u200B-\u200D\uFEFF]/g, '')
    const nodeLength = actualText.length
    if (currentLength >= maxLength) {
      nodesToRemove.push(currentNode)
    }
    else if (currentLength + nodeLength > maxLength) {
      const allowedLength = maxLength - currentLength
      // 为了确保保留原有的换行格式,我们在原字符串上进行子串截取
      // 这里采用更精确的方式:逐字符遍历原字符串,直到有效字符达到 allowedLength
      let sliceIndex = 0
      let validCharCount = 0
      while (sliceIndex < nodeText.length && validCharCount < allowedLength) {
        const char = nodeText[sliceIndex]
        if (!/[\r\n\u200B-\u200D\uFEFF]/.test(char)) {
          validCharCount++
        }
        sliceIndex++
      }
      currentNode.nodeValue = nodeText.substring(0, sliceIndex)
      currentLength += allowedLength
    }
    else {
      currentLength += nodeLength
    }
    currentNode = walker.nextNode()
  }
  nodesToRemove.forEach((node) => {
    const parent = node.parentNode
    if (!parent)
      return
    parent.removeChild(node)
    let target: Node | null = parent
    while (target && target !== container && !target.textContent?.trim()) {
      const ancestor = target.parentNode
      if (ancestor)
        ancestor.removeChild(target)
      target = ancestor
    }
  })
  // 如果容器内没有文本内容了,直接返回空字符串,避免残留空标签
  if (!container.textContent?.replace(/[\r\n\u200B-\u200D\uFEFF]/g, '').trim()) {
    return ''
  }
  return container.innerHTML
}
function syncTextCount(html: string) {
  currentTextCount.value = getTextLengthByHtml(html)
}
function getEffectiveMaxLength() {
  return typeof props.maxLength === 'number' && props.maxLength > 0
    ? props.maxLength
    : undefined
}
function syncOverflowStateByLength(length: number) {
  const maxLength = getEffectiveMaxLength()
  if (maxLength === undefined) {
    hasMaxLengthOverflow.value = false
    return
  }
  if (length > maxLength) {
    hasMaxLengthOverflow.value = true
    return
  }
  if (length < maxLength)
    hasMaxLengthOverflow.value = false
}
function getPreparedContentState(html: string) {
  const formattedHtml = formatRichTextContent(html)
  const maxLength = getEffectiveMaxLength()
  if (maxLength === undefined)
    return { html: formattedHtml, overflow: false }
  return {
    html: formattedHtml,
    overflow: getTextLengthByHtml(formattedHtml) > maxLength,
  }
}
function getSelectedTextLength() {
  if (typeof window === 'undefined' || !editableRoot.value)
    return 0
  const selection = window.getSelection()
  if (!selection || selection.rangeCount === 0)
    return 0
  // 当从外部复制富文本时,不应该算作编辑器内被选中的文本
  if (editorRef.value && !editorRef.value.contains(selection.anchorNode))
    return 0
  const range = selection.getRangeAt(0)
  const commonAncestor = range.commonAncestorContainer
  if (!editableRoot.value.contains(commonAncestor))
    return 0
  return range.toString().length
}
function shouldBlockInsert(incomingLength: number) {
  if (incomingLength <= 0)
    return false
  const maxLength = getEffectiveMaxLength()
  if (maxLength === undefined)
    return false
  const selectedTextLength = getSelectedTextLength()
  const currentLength = getTextLengthByHtml(getEditor()?.getHtml() || '')
  return currentLength - selectedTextLength + incomingLength > maxLength
}
function handleBeforeInput(event: Event) {
  if (props.disabled || props.readOnly)
    return
  const inputEvent = event as InputEvent
  if (!inputEvent.inputType?.startsWith('insert'))
    return
  // 如果是粘贴操作,交由 onChange 进行柔性截断
  if (inputEvent.inputType === 'insertFromPaste' || inputEvent.inputType === 'insertFromDrop')
    return
  const incomingLength = inputEvent.data?.length ?? 0
  if (!shouldBlockInsert(incomingLength))
    return
  inputEvent.preventDefault()
  hasMaxLengthOverflow.value = true
}
function handleKeydown(event: Event) {
  if (props.disabled || props.readOnly)
    return
  const keyboardEvent = event as KeyboardEvent
  if (keyboardEvent.isComposing || keyboardEvent.ctrlKey || keyboardEvent.metaKey || keyboardEvent.altKey)
    return
  if (keyboardEvent.key.length !== 1)
    return
  if (!shouldBlockInsert(keyboardEvent.key.length))
    return
  keyboardEvent.preventDefault()
  hasMaxLengthOverflow.value = true
}
function disconnectGuardObserver() {
  guardObserver.value?.disconnect()
  guardObserver.value = null
}
function bindMaxLengthGuards() {
  if (typeof document === 'undefined' || !editorRef.value)
    return
  const root = editorRef.value.querySelector('.aie-content, [contenteditable="true"]') as HTMLElement | null
  if (!root || editableRoot.value === root)
    return
  unbindMaxLengthGuards()
  editableRoot.value = root
  editableRoot.value.addEventListener('beforeinput', handleBeforeInput, true)
  editableRoot.value.addEventListener('keydown', handleKeydown, true)
  disconnectGuardObserver()
}
function unbindMaxLengthGuards() {
  if (!editableRoot.value)
    return
  editableRoot.value.removeEventListener('beforeinput', handleBeforeInput, true)
  editableRoot.value.removeEventListener('keydown', handleKeydown, true)
  editableRoot.value = null
}
function ensureMaxLengthGuards() {
  if (typeof document === 'undefined' || !editorRef.value)
    return
  bindMaxLengthGuards()
  if (editableRoot.value || guardObserver.value)
    return
  guardObserver.value = new MutationObserver(() => {
    bindMaxLengthGuards()
  })
  guardObserver.value.observe(editorRef.value, {
    childList: true,
    subtree: true,
  })
}
function applyFormattedValue(formattedHtml: string) {
  syncTextCount(formattedHtml)
  if (formattedHtml === lastHtml.value)
    return
@@ -281,7 +526,22 @@
  emit('change', formattedHtml)
}
function syncModelValue(html: string) {
  const { html: formattedHtml, overflow } = getPreparedContentState(html)
  syncOverflowStateByLength(getTextLengthByHtml(formattedHtml))
  if (overflow)
    hasMaxLengthOverflow.value = true
  if (isApplyingExternalContent.value) {
    lastHtml.value = formattedHtml
    syncTextCount(formattedHtml)
    return
  }
  applyFormattedValue(formattedHtml)
}
function destroyEditor() {
  unbindMaxLengthGuards()
  disconnectGuardObserver()
  editor.value?.destroy()
  editor.value = null
@@ -297,7 +557,8 @@
  if (!editorRef.value || editor.value || isUnmounted.value)
    return
  const initialContent = formatRichTextContent(content || '')
  const { html: initialContent, overflow } = getPreparedContentState(content || '')
  hasMaxLengthOverflow.value = overflow
  const instance = new AiEditor({
    element: editorRef.value,
@@ -312,7 +573,36 @@
    video: createUploadConfig(),
    attachment: createUploadConfig(),
    onChange: (currentEditor) => {
      syncModelValue(currentEditor.getHtml())
      if (isTruncating.value) return
      const currentHtml = currentEditor.getHtml()
      const maxLength = getEffectiveMaxLength()
      // 这里必须用纯文本长度判断,否则带格式的文本长度会被低估导致无法截断
      const textLength = getTextLengthByHtml(currentHtml)
      if (maxLength !== undefined && textLength > maxLength) {
        // 计算出截断后的合法 HTML
        const truncatedHtml = truncateHtmlByTextLength(currentHtml, maxLength)
        // 【核心修复】必须使用 setTimeout 异步更新内容。
        // 因为 ProseMirror 在处理粘贴事务时,如果同步调用 setContent 替换整个文档,会被底层引擎直接忽略!
        setTimeout(() => {
          isTruncating.value = true
          currentEditor.setContent(truncatedHtml)
          currentEditor.focusEnd()
          setTimeout(() => {
            isTruncating.value = false
          }, 50)
        }, 0)
        hasMaxLengthOverflow.value = true
        syncModelValue(truncatedHtml)
        return
      }
      syncOverflowStateByLength(textLength)
      syncModelValue(currentHtml)
    },
    onFocus: () => {
      emit('focus')
@@ -327,15 +617,21 @@
  editor.value = instance
  lastHtml.value = initialContent
  syncTextCount(initialContent)
  void nextTick(() => {
    ensureMaxLengthGuards()
  })
}
async function rebuildEditor(content: string, focus = false) {
  if (isUnmounted.value)
    return
  const formattedContent = formatRichTextContent(content || '')
  const { html: formattedContent, overflow } = getPreparedContentState(content || '')
  if (formattedContent === lastHtml.value && editor.value) {
    lastHtml.value = formattedContent
    hasMaxLengthOverflow.value = overflow
    syncTextCount(formattedContent)
    return
  }
@@ -367,6 +663,8 @@
  if (!isUnmounted.value && editorRef.value)
    initEditor('')
  lastHtml.value = ''
  hasMaxLengthOverflow.value = false
  syncTextCount('')
  syncModelValue('')
}
@@ -428,6 +726,15 @@
  },
)
watch(
  () => props.maxLength,
  () => {
    const html = editor.value?.getHtml() || getIncomingContent() || lastHtml.value
    syncTextCount(html)
    syncOverflowStateByLength(getTextLengthByHtml(html))
  },
)
onMounted(() => {
  isUnmounted.value = false
  void nextTick(() => {
@@ -457,11 +764,19 @@
      class="ai-editor-host"
      :style="{ minHeight: height }"
    />
    <div
      v-if="showCount"
      class="ai-editor-count"
      :class="{ 'ai-editor-count--overflow': isCountOverflow }"
    >
      {{ currentTextCount }} / {{ props.maxLength }}
    </div>
  </div>
</template>
<style scoped>
.ai-editor-container {
  position: relative;
  width: 100%;
  border-radius: 4px;
  background: #fff;
@@ -483,16 +798,36 @@
  min-height: v-bind(height);
}
.ai-editor-count {
  position: absolute;
  right: 12px;
  bottom: 10px;
  z-index: 2;
  padding: 0 4px;
  font-size: 12px;
  line-height: 16px;
  color: rgba(23, 31, 38, 0.45);
  pointer-events: none;
  background: rgba(255, 255, 255, 0.92);
  border-radius: 2px;
}
.ai-editor-count--overflow {
  color: #d03050;
}
:deep(aie-footer) {
  display: none !important;
}
:deep(.aie-container) {
  display: flex;
  flex-direction: column;
  border-radius: 4px;
  overflow: hidden;
  border: 1px solid rgba(23, 31, 38, 0.15);
  box-sizing: border-box;
  min-height: v-bind(height);
  min-height: 100%;
}
:deep(.aie-container aie-header) {
@@ -513,7 +848,15 @@
}
:deep(.aie-main) {
  min-height: v-bind(height);
  flex: 1;
   min-height: v-bind(height);
  overflow: auto;
}
:deep(.aie-content) {
  min-height: 100%;
  padding-bottom: 28px;
  box-sizing: border-box;
}
:deep(.aie-content ul:not([data-type='taskList'])) {