| | |
| | | disabled: false, |
| | | readOnly: false, |
| | | value: '', |
| | | maxLength: 20000, |
| | | placeholder: '请输入内容...', |
| | | height: '360px', |
| | | 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 && currentTextCount.value > (props.maxLength || 0)) |
| | | const isCountOverflow = computed(() => showCount.value && hasMaxLengthOverflow.value) |
| | | |
| | | const commonUploader: Uploader = async (file) => { |
| | | const res = await uploadSingleFile(file) |
| | |
| | | } |
| | | |
| | | function truncateHtmlByTextLength(html: string, maxLength: number) { |
| | | if (!html || maxLength < 0 || typeof document === 'undefined') |
| | | if (!html || typeof document === 'undefined') |
| | | return html || '' |
| | | |
| | | const container = document.createElement('div') |
| | | container.innerHTML = html |
| | | |
| | | let remaining = maxLength |
| | | const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT) |
| | | let currentLength = 0 |
| | | const nodesToRemove: Node[] = [] |
| | | |
| | | const trimNode = (node: Node) => { |
| | | if (node.nodeType === Node.TEXT_NODE) { |
| | | const textNode = node as Text |
| | | const text = textNode.nodeValue || '' |
| | | if (!text) |
| | | return false |
| | | if (remaining <= 0) |
| | | return false |
| | | if (text.length <= remaining) { |
| | | remaining -= text.length |
| | | return true |
| | | let currentNode = walker.nextNode() |
| | | while (currentNode) { |
| | | const nodeText = currentNode.nodeValue || '' |
| | | const nodeLength = nodeText.length |
| | | |
| | | if (currentLength + nodeLength > maxLength) { |
| | | const allowedLength = Math.max(0, maxLength - currentLength) |
| | | if (allowedLength > 0) { |
| | | currentNode.nodeValue = nodeText.substring(0, allowedLength) |
| | | currentLength += allowedLength |
| | | } |
| | | textNode.nodeValue = text.slice(0, remaining) |
| | | remaining = 0 |
| | | return true |
| | | else { |
| | | nodesToRemove.push(currentNode) |
| | | } |
| | | } |
| | | else { |
| | | currentLength += nodeLength |
| | | } |
| | | |
| | | if (node.nodeType !== Node.ELEMENT_NODE) |
| | | return true |
| | | |
| | | const element = node as Element |
| | | const childNodes = Array.from(element.childNodes) |
| | | |
| | | if (!childNodes.length) |
| | | return true |
| | | |
| | | for (const child of childNodes) { |
| | | if (remaining <= 0) { |
| | | child.remove() |
| | | continue |
| | | } |
| | | |
| | | if (!trimNode(child)) |
| | | child.remove() |
| | | } |
| | | |
| | | return true |
| | | currentNode = walker.nextNode() |
| | | } |
| | | |
| | | Array.from(container.childNodes).forEach((child) => { |
| | | if (remaining <= 0) { |
| | | child.remove() |
| | | return |
| | | nodesToRemove.forEach((node) => { |
| | | let target: Node | null = node |
| | | while (target && target !== container && !target.textContent?.trim()) { |
| | | const parent: Node | null = target.parentNode |
| | | if (parent) |
| | | parent.removeChild(target) |
| | | target = parent |
| | | } |
| | | |
| | | if (!trimNode(child)) |
| | | child.remove() |
| | | }) |
| | | |
| | | return container.innerHTML |
| | |
| | | : 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 |
| | | |
| | | const range = selection.getRangeAt(0) |
| | | const commonAncestor = range.commonAncestorContainer |
| | | if (!editableRoot.value.contains(commonAncestor)) |
| | | return 0 |
| | | |
| | | 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() |
| | | } |
| | | |
| | | function handleBeforeInput(event: Event) { |
| | | if (props.disabled || props.readOnly) |
| | | return |
| | | |
| | | const inputEvent = event as InputEvent |
| | | if (!inputEvent.inputType?.startsWith('insert')) |
| | | return |
| | | |
| | | if (inputEvent.inputType === 'insertFromPaste') |
| | | 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) |
| | |
| | | } |
| | | |
| | | function syncModelValue(html: string) { |
| | | const formattedHtml = formatRichTextContent(html) |
| | | const { html: formattedHtml, overflow } = getPreparedContentState(html) |
| | | syncOverflowStateByLength(getTextLengthByHtml(formattedHtml)) |
| | | if (overflow) |
| | | hasMaxLengthOverflow.value = true |
| | | if (isApplyingExternalContent.value) { |
| | | lastHtml.value = formattedHtml |
| | | syncTextCount(formattedHtml) |
| | |
| | | } |
| | | |
| | | function destroyEditor() { |
| | | unbindMaxLengthGuards() |
| | | disconnectGuardObserver() |
| | | editor.value?.destroy() |
| | | editor.value = null |
| | | |
| | |
| | | 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, |
| | |
| | | video: createUploadConfig(), |
| | | attachment: createUploadConfig(), |
| | | onChange: (currentEditor) => { |
| | | if (isTruncating.value) return |
| | | |
| | | const currentHtml = currentEditor.getHtml() |
| | | const maxLength = getEffectiveMaxLength() |
| | | const textLength = currentEditor.getText().length |
| | | |
| | | if (maxLength !== undefined && textLength > maxLength) { |
| | | const truncatedHtml = formatRichTextContent(truncateHtmlByTextLength(currentHtml, maxLength)) |
| | | applyFormattedValue(truncatedHtml) |
| | | isApplyingExternalContent.value = true |
| | | isTruncating.value = true |
| | | const truncatedHtml = truncateHtmlByTextLength(currentHtml, maxLength) |
| | | currentEditor.setContent(truncatedHtml) |
| | | requestAnimationFrame(() => { |
| | | isApplyingExternalContent.value = false |
| | | }) |
| | | currentEditor.focusEnd() |
| | | |
| | | setTimeout(() => { |
| | | isTruncating.value = false |
| | | }, 10) |
| | | |
| | | hasMaxLengthOverflow.value = true |
| | | syncModelValue(truncatedHtml) |
| | | return |
| | | } |
| | | |
| | | syncOverflowStateByLength(textLength) |
| | | syncModelValue(currentHtml) |
| | | }, |
| | | onFocus: () => { |
| | |
| | | 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 |
| | | } |
| | |
| | | if (!isUnmounted.value && editorRef.value) |
| | | initEditor('') |
| | | lastHtml.value = '' |
| | | hasMaxLengthOverflow.value = false |
| | | syncTextCount('') |
| | | syncModelValue('') |
| | | } |
| | |
| | | watch( |
| | | () => props.maxLength, |
| | | () => { |
| | | syncTextCount(getIncomingContent() || lastHtml.value) |
| | | const html = editor.value?.getHtml() || getIncomingContent() || lastHtml.value |
| | | syncTextCount(html) |
| | | syncOverflowStateByLength(getTextLengthByHtml(html)) |
| | | }, |
| | | ) |
| | | |