| | |
| | | <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' |
| | |
| | | value?: string |
| | | placeholder?: string |
| | | height?: string |
| | | maxLength?: number |
| | | toolbarKeys?: AiEditorOptions['toolbarKeys'] |
| | | } |
| | | |
| | |
| | | const editorRef = ref<HTMLElement | null>(null) |
| | | const editor = ref<AiEditorInstance | null>(null) |
| | | const lastHtml = ref('') |
| | | const currentTextCount = ref(0) |
| | | const isUnmounted = ref(false) |
| | | const isApplyingExternalContent = 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 commonUploader: Uploader = async (file) => { |
| | | const res = await uploadSingleFile(file) |
| | |
| | | return preserveConsecutiveSpaces(formatRichTextList(formatTableForMiniProgram(html))) |
| | | } |
| | | |
| | | function syncModelValue(html: string) { |
| | | const formattedHtml = formatRichTextContent(html) |
| | | if (isApplyingExternalContent.value) { |
| | | lastHtml.value = formattedHtml |
| | | return |
| | | function getTextLengthByHtml(html: string) { |
| | | if (!html) |
| | | return 0 |
| | | |
| | | if (typeof document === 'undefined') |
| | | return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').length |
| | | |
| | | const container = document.createElement('div') |
| | | container.innerHTML = html |
| | | return (container.textContent || '').length |
| | | } |
| | | |
| | | function truncateHtmlByTextLength(html: string, maxLength: number) { |
| | | if (!html || maxLength < 0 || typeof document === 'undefined') |
| | | return html || '' |
| | | |
| | | const container = document.createElement('div') |
| | | container.innerHTML = html |
| | | |
| | | let remaining = maxLength |
| | | |
| | | 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 |
| | | } |
| | | textNode.nodeValue = text.slice(0, remaining) |
| | | remaining = 0 |
| | | return true |
| | | } |
| | | |
| | | 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 |
| | | } |
| | | |
| | | Array.from(container.childNodes).forEach((child) => { |
| | | if (remaining <= 0) { |
| | | child.remove() |
| | | return |
| | | } |
| | | |
| | | if (!trimNode(child)) |
| | | child.remove() |
| | | }) |
| | | |
| | | 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 applyFormattedValue(formattedHtml: string) { |
| | | syncTextCount(formattedHtml) |
| | | if (formattedHtml === lastHtml.value) |
| | | return |
| | | |
| | |
| | | model.value = formattedHtml |
| | | emit('input', formattedHtml) |
| | | emit('change', formattedHtml) |
| | | } |
| | | |
| | | function syncModelValue(html: string) { |
| | | const formattedHtml = formatRichTextContent(html) |
| | | if (isApplyingExternalContent.value) { |
| | | lastHtml.value = formattedHtml |
| | | syncTextCount(formattedHtml) |
| | | return |
| | | } |
| | | applyFormattedValue(formattedHtml) |
| | | } |
| | | |
| | | function destroyEditor() { |
| | |
| | | video: createUploadConfig(), |
| | | attachment: createUploadConfig(), |
| | | onChange: (currentEditor) => { |
| | | syncModelValue(currentEditor.getHtml()) |
| | | 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 |
| | | currentEditor.setContent(truncatedHtml) |
| | | requestAnimationFrame(() => { |
| | | isApplyingExternalContent.value = false |
| | | }) |
| | | return |
| | | } |
| | | syncModelValue(currentHtml) |
| | | }, |
| | | onFocus: () => { |
| | | emit('focus') |
| | |
| | | |
| | | editor.value = instance |
| | | lastHtml.value = initialContent |
| | | syncTextCount(initialContent) |
| | | } |
| | | |
| | | async function rebuildEditor(content: string, focus = false) { |
| | |
| | | const formattedContent = formatRichTextContent(content || '') |
| | | if (formattedContent === lastHtml.value && editor.value) { |
| | | lastHtml.value = formattedContent |
| | | syncTextCount(formattedContent) |
| | | return |
| | | } |
| | | |
| | |
| | | if (!isUnmounted.value && editorRef.value) |
| | | initEditor('') |
| | | lastHtml.value = '' |
| | | syncTextCount('') |
| | | syncModelValue('') |
| | | } |
| | | |
| | |
| | | }, |
| | | ) |
| | | |
| | | watch( |
| | | () => props.maxLength, |
| | | () => { |
| | | syncTextCount(getIncomingContent() || lastHtml.value) |
| | | }, |
| | | ) |
| | | |
| | | onMounted(() => { |
| | | isUnmounted.value = false |
| | | void nextTick(() => { |
| | |
| | | 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; |
| | |
| | | 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) { |
| | |
| | | } |
| | | |
| | | :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'])) { |