董国庆
2026-07-01 11124bdc25b5bcdbce5d792fef14d4c167d52143
修改:富文本超出20000截取优化
1个文件已修改
99 ■■■■■ 已修改文件
src/components/custom/Editor/AiRichTextEditor/index.vue 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/custom/Editor/AiRichTextEditor/index.vue
@@ -284,7 +284,11 @@
  const container = document.createElement('div')
  container.innerHTML = html
  return (container.textContent || '').length
  // 必须严格使用 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) {
@@ -301,17 +305,28 @@
  let currentNode = walker.nextNode()
  while (currentNode) {
    const nodeText = currentNode.nodeValue || ''
    const nodeLength = nodeText.length
    // 计算长度时,剔除不可见字符,避免长度计算偏大导致错误截断
    const actualText = nodeText.replace(/[\r\n\u200B-\u200D\uFEFF]/g, '')
    const nodeLength = actualText.length
    if (currentLength + nodeLength > maxLength) {
      const allowedLength = Math.max(0, maxLength - currentLength)
      if (allowedLength > 0) {
        currentNode.nodeValue = nodeText.substring(0, allowedLength)
        currentLength += allowedLength
    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++
      }
      else {
        nodesToRemove.push(currentNode)
      }
      currentNode.nodeValue = nodeText.substring(0, sliceIndex)
      currentLength += allowedLength
    }
    else {
      currentLength += nodeLength
@@ -321,14 +336,25 @@
  }
  nodesToRemove.forEach((node) => {
    let target: Node | null = node
    const parent = node.parentNode
    if (!parent)
      return
    parent.removeChild(node)
    let target: Node | null = parent
    while (target && target !== container && !target.textContent?.trim()) {
      const parent: Node | null = target.parentNode
      if (parent)
        parent.removeChild(target)
      target = parent
      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
}
@@ -379,6 +405,10 @@
  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))
@@ -387,20 +417,17 @@
  return range.toString().length
}
function getRemainingInsertLength() {
  const maxLength = getEffectiveMaxLength()
  if (maxLength === undefined)
    return Number.POSITIVE_INFINITY
  const selectedTextLength = getSelectedTextLength()
  return Math.max(0, maxLength - (currentTextCount.value - selectedTextLength))
}
function shouldBlockInsert(incomingLength: number) {
  if (incomingLength <= 0)
    return false
  return incomingLength > getRemainingInsertLength()
  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) {
@@ -411,7 +438,8 @@
  if (!inputEvent.inputType?.startsWith('insert'))
    return
  if (inputEvent.inputType === 'insertFromPaste')
  // 如果是粘贴操作,交由 onChange 进行柔性截断
  if (inputEvent.inputType === 'insertFromPaste' || inputEvent.inputType === 'insertFromDrop')
    return
  const incomingLength = inputEvent.data?.length ?? 0
@@ -549,17 +577,24 @@
      const currentHtml = currentEditor.getHtml()
      const maxLength = getEffectiveMaxLength()
      const textLength = currentEditor.getText().length
      // 这里必须用纯文本长度判断,否则带格式的文本长度会被低估导致无法截断
      const textLength = getTextLengthByHtml(currentHtml)
      if (maxLength !== undefined && textLength > maxLength) {
        isTruncating.value = true
        // 计算出截断后的合法 HTML
        const truncatedHtml = truncateHtmlByTextLength(currentHtml, maxLength)
        currentEditor.setContent(truncatedHtml)
        currentEditor.focusEnd()
        
        // 【核心修复】必须使用 setTimeout 异步更新内容。
        // 因为 ProseMirror 在处理粘贴事务时,如果同步调用 setContent 替换整个文档,会被底层引擎直接忽略!
        setTimeout(() => {
          isTruncating.value = false
        }, 10)
          isTruncating.value = true
          currentEditor.setContent(truncatedHtml)
          currentEditor.focusEnd()
          setTimeout(() => {
            isTruncating.value = false
          }, 50)
        }, 0)
        hasMaxLengthOverflow.value = true
        syncModelValue(truncatedHtml)