| | |
| | | |
| | | 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) { |
| | |
| | | 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 |
| | | } |
| | | else { |
| | | 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 |
| | |
| | | } |
| | | |
| | | 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 |
| | | } |
| | |
| | | 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 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) { |
| | |
| | | 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 |
| | |
| | | |
| | | 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) |
| | | |
| | | // 【核心修复】必须使用 setTimeout 异步更新内容。 |
| | | // 因为 ProseMirror 在处理粘贴事务时,如果同步调用 setContent 替换整个文档,会被底层引擎直接忽略! |
| | | setTimeout(() => { |
| | | isTruncating.value = true |
| | | currentEditor.setContent(truncatedHtml) |
| | | currentEditor.focusEnd() |
| | | |
| | | setTimeout(() => { |
| | | isTruncating.value = false |
| | | }, 10) |
| | | }, 50) |
| | | }, 0) |
| | | |
| | | hasMaxLengthOverflow.value = true |
| | | syncModelValue(truncatedHtml) |