<script setup lang="ts">
|
import type { AiEditor as AiEditorInstance, AiEditorOptions, Uploader } from 'aieditor'
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { AiEditor } from 'aieditor'
|
import 'aieditor/dist/style.css'
|
import { uploadSingleFile } from '@/utils/upload'
|
|
defineOptions({
|
name: 'AiRichTextEditor',
|
})
|
|
const props = withDefaults(defineProps<Props>(), {
|
disabled: false,
|
readOnly: false,
|
value: '',
|
placeholder: '请输入内容...',
|
height: '360px',
|
toolbarKeys: () => [
|
'undo',
|
'redo',
|
'divider',
|
'heading',
|
'font-family',
|
'font-size',
|
'divider',
|
'bold',
|
'italic',
|
'underline',
|
'strike',
|
'link',
|
'divider',
|
'align',
|
'bullet-list',
|
'ordered-list',
|
'indent-decrease',
|
'indent-increase',
|
'divider',
|
'image',
|
'video',
|
'attachment',
|
'table',
|
'fullscreen',
|
],
|
})
|
|
const emit = defineEmits<{
|
change: [value: string]
|
input: [value: string]
|
focus: []
|
blur: []
|
ready: [editor: AiEditorInstance]
|
}>()
|
|
interface Props {
|
disabled?: boolean
|
readOnly?: boolean
|
value?: string
|
placeholder?: string
|
height?: string
|
toolbarKeys?: AiEditorOptions['toolbarKeys']
|
}
|
|
const model = defineModel<string>()
|
|
const editorRef = ref<HTMLElement | null>(null)
|
const editor = ref<AiEditorInstance | null>(null)
|
const lastHtml = ref('')
|
const isUnmounted = ref(false)
|
const isApplyingExternalContent = ref(false)
|
let applyContentVersion = 0
|
|
const commonUploader: Uploader = async (file) => {
|
const res = await uploadSingleFile(file)
|
return {
|
errorCode: 0,
|
data: {
|
src: res.url,
|
href: res.url,
|
fileName: res.name || file.name,
|
alt: res.name || file.name,
|
poster: '',
|
},
|
}
|
}
|
|
function createUploadConfig() {
|
return {
|
uploadUrl: '/api/common/upload',
|
uploadHeaders: {},
|
uploadFormName: 'file',
|
uploader: commonUploader,
|
}
|
}
|
|
function appendStyle(originStyle: string | null, styleText: string) {
|
const style = (originStyle || '').trim()
|
if (!style)
|
return styleText
|
return style.endsWith(';') ? `${style}${styleText}` : `${style};${styleText}`
|
}
|
|
function removeStyleProperties(originStyle: string | null, properties: string[]) {
|
let style = (originStyle || '').trim()
|
for (const property of properties) {
|
const reg = new RegExp(`${property}\\s*:\\s*[^;]+;?`, 'gi')
|
style = style.replace(reg, '')
|
}
|
return style.replace(/\s{2,}/g, ' ').trim()
|
}
|
|
function formatTableForMiniProgram(html: string) {
|
if (!html || typeof document === 'undefined')
|
return html || ''
|
|
const container = document.createElement('div')
|
container.innerHTML = html
|
|
container.querySelectorAll('table').forEach((table) => {
|
table.removeAttribute('width')
|
table.setAttribute('border', '1')
|
table.setAttribute('cellspacing', '0')
|
table.setAttribute('cellpadding', '0')
|
table.setAttribute(
|
'style',
|
appendStyle(
|
removeStyleProperties(table.getAttribute('style'), ['width', 'min-width', 'max-width']),
|
'width:100%;max-width:100%;border-collapse:collapse;table-layout:fixed;box-sizing:border-box;',
|
),
|
)
|
|
table.querySelectorAll('colgroup, col').forEach((col) => {
|
col.removeAttribute('width')
|
const style = removeStyleProperties(col.getAttribute('style'), ['width', 'min-width', 'max-width'])
|
if (style)
|
col.setAttribute('style', style)
|
else
|
col.removeAttribute('style')
|
})
|
|
table.querySelectorAll('th, td').forEach((cell) => {
|
cell.removeAttribute('width')
|
cell.setAttribute(
|
'style',
|
appendStyle(
|
removeStyleProperties(cell.getAttribute('style'), ['width', 'min-width', 'max-width']),
|
'width:auto;max-width:100%;border:1px solid #d9d9d9;padding:8px;vertical-align:top;word-break:break-all;overflow-wrap:anywhere;box-sizing:border-box;',
|
),
|
)
|
})
|
|
table.querySelectorAll('th').forEach((headerCell) => {
|
headerCell.setAttribute(
|
'style',
|
appendStyle(
|
headerCell.getAttribute('style'),
|
'font-weight:600;background:#f5f5f5;',
|
),
|
)
|
})
|
})
|
|
return container.innerHTML
|
}
|
|
function formatRichTextList(html: string) {
|
if (!html)
|
return ''
|
|
if (typeof document === 'undefined')
|
return html
|
|
const container = document.createElement('div')
|
container.innerHTML = html
|
|
const applyListStyle = (list: Element, type: 'ol' | 'ul', depth: number) => {
|
const baseStyle = type === 'ol'
|
? `list-style-type:decimal;margin:8px 0;`
|
: `list-style-type:disc;margin:8px 0;`
|
|
list.setAttribute(
|
'style',
|
appendStyle(
|
removeStyleProperties(list.getAttribute('style'), ['list-style', 'list-style-type', 'padding-left', 'margin', 'margin-top', 'margin-bottom']),
|
baseStyle,
|
),
|
)
|
}
|
|
const walkLists = (root: ParentNode, depth = 1) => {
|
Array.from(root.children).forEach((child) => {
|
const element = child as HTMLElement
|
if (element.tagName === 'OL') {
|
applyListStyle(element, 'ol', depth)
|
element.querySelectorAll(':scope > li').forEach((item) => {
|
const li = item as HTMLElement
|
li.setAttribute(
|
'style',
|
appendStyle(
|
removeStyleProperties(li.getAttribute('style'), ['margin']),
|
'margin:4px 0;',
|
),
|
)
|
Array.from(li.children).forEach((nested) => {
|
if (nested instanceof HTMLOListElement || nested instanceof HTMLUListElement)
|
walkLists({ children: [nested] } as unknown as ParentNode, depth + 1)
|
})
|
})
|
}
|
else if (element.tagName === 'UL' && element.getAttribute('data-type') !== 'taskList') {
|
applyListStyle(element, 'ul', depth)
|
element.querySelectorAll(':scope > li').forEach((item) => {
|
const li = item as HTMLElement
|
li.setAttribute(
|
'style',
|
appendStyle(
|
removeStyleProperties(li.getAttribute('style'), ['margin']),
|
'margin:4px 0;',
|
),
|
)
|
Array.from(li.children).forEach((nested) => {
|
if (nested instanceof HTMLOListElement || nested instanceof HTMLUListElement)
|
walkLists({ children: [nested] } as unknown as ParentNode, depth + 1)
|
})
|
})
|
}
|
})
|
}
|
|
walkLists(container)
|
|
return container.innerHTML
|
}
|
|
function formatRichTextContent(html: string) {
|
return formatRichTextList(formatTableForMiniProgram(html))
|
}
|
|
function syncModelValue(html: string) {
|
const formattedHtml = formatRichTextContent(html)
|
if (isApplyingExternalContent.value) {
|
lastHtml.value = formattedHtml
|
return
|
}
|
if (formattedHtml === lastHtml.value)
|
return
|
|
lastHtml.value = formattedHtml
|
if (model.value !== undefined)
|
model.value = formattedHtml
|
emit('input', formattedHtml)
|
emit('change', formattedHtml)
|
}
|
|
function destroyEditor() {
|
editor.value?.destroy()
|
editor.value = null
|
|
if (editorRef.value)
|
editorRef.value.innerHTML = ''
|
}
|
|
function getIncomingContent() {
|
return model.value ?? props.value ?? ''
|
}
|
|
function initEditor(content = getIncomingContent()) {
|
if (!editorRef.value || editor.value || isUnmounted.value)
|
return
|
|
const initialContent = formatRichTextContent(content || '')
|
|
const instance = new AiEditor({
|
element: editorRef.value,
|
placeholder: props.placeholder,
|
content: initialContent,
|
editable: !(props.disabled || props.readOnly),
|
toolbarKeys: props.toolbarKeys,
|
toolbarExcludeKeys: ['ai'],
|
ai: false,
|
draggable: false,
|
image: createUploadConfig(),
|
video: createUploadConfig(),
|
attachment: createUploadConfig(),
|
onChange: (currentEditor) => {
|
syncModelValue(currentEditor.getHtml())
|
},
|
onFocus: () => {
|
emit('focus')
|
},
|
onBlur: () => {
|
emit('blur')
|
},
|
onCreated: (currentEditor) => {
|
emit('ready', currentEditor)
|
},
|
})
|
|
editor.value = instance
|
lastHtml.value = initialContent
|
}
|
|
async function rebuildEditor(content: string, focus = false) {
|
if (isUnmounted.value)
|
return
|
|
const formattedContent = formatRichTextContent(content || '')
|
if (formattedContent === lastHtml.value && editor.value) {
|
lastHtml.value = formattedContent
|
return
|
}
|
|
const version = ++applyContentVersion
|
isApplyingExternalContent.value = true
|
|
destroyEditor()
|
|
await nextTick()
|
if (isUnmounted.value || version !== applyContentVersion)
|
return
|
|
initEditor(formattedContent)
|
if (focus)
|
editor.value?.focus()
|
|
requestAnimationFrame(() => {
|
if (version === applyContentVersion)
|
isApplyingExternalContent.value = false
|
})
|
}
|
|
async function setContent(content: string, focus = false) {
|
await rebuildEditor(content, focus)
|
}
|
|
function clear() {
|
destroyEditor()
|
if (!isUnmounted.value && editorRef.value)
|
initEditor('')
|
lastHtml.value = ''
|
syncModelValue('')
|
}
|
|
function focus() {
|
editor.value?.focus()
|
}
|
|
function blur() {
|
editor.value?.blur()
|
}
|
|
function getContent() {
|
return editor.value?.getHtml() || ''
|
}
|
|
function getEditor() {
|
return editor.value
|
}
|
|
watch(
|
() => model.value,
|
(value) => {
|
if (value === undefined)
|
return
|
const html = value || ''
|
if (html === lastHtml.value)
|
return
|
void rebuildEditor(html)
|
},
|
{ flush: 'post' },
|
)
|
|
watch(
|
() => props.value,
|
(value) => {
|
if (model.value !== undefined)
|
return
|
const html = value || ''
|
if (html === lastHtml.value)
|
return
|
void rebuildEditor(html)
|
},
|
{ flush: 'post' },
|
)
|
|
watch(
|
() => [props.disabled, props.readOnly],
|
(value) => {
|
const [disabled, readOnly] = value
|
editor.value?.setEditable(!(disabled || readOnly))
|
},
|
)
|
|
watch(
|
() => props.placeholder,
|
() => {
|
const html = getIncomingContent() || lastHtml.value
|
void rebuildEditor(html)
|
},
|
)
|
|
onMounted(() => {
|
isUnmounted.value = false
|
void nextTick(() => {
|
initEditor()
|
})
|
})
|
|
onBeforeUnmount(() => {
|
isUnmounted.value = true
|
destroyEditor()
|
})
|
|
defineExpose({
|
clear,
|
blur,
|
focus,
|
setContent,
|
getContent,
|
getEditor,
|
})
|
</script>
|
|
<template>
|
<div class="ai-editor-container">
|
<div
|
ref="editorRef"
|
class="ai-editor-host"
|
:style="{ minHeight: height }"
|
/>
|
</div>
|
</template>
|
|
<style scoped>
|
.ai-editor-container {
|
width: 100%;
|
border-radius: 4px;
|
background: #fff;
|
--aie-border-color: rgba(23, 31, 38, 0.15);
|
--aie-text-color: #171f26;
|
--aie-text-placeholder-color: rgba(23, 31, 38, 0.3);
|
--aie-button-bg-color: #f4f6f8;
|
--aie-button-border-color: #f4f6f8;
|
--aie-button-hover-bg-color: #e9edf2;
|
--aie-button-hover-border-color: #e9edf2;
|
--aie-menus-bg-color: #f4f6f8;
|
--aie-menus-text-color: #74797d;
|
--aie-menus-svg-color: #74797d;
|
--aie-menus-divider-color: rgba(23, 31, 38, 0.1);
|
}
|
|
.ai-editor-host {
|
width: 100%;
|
min-height: v-bind(height);
|
}
|
|
:deep(aie-footer) {
|
display: none !important;
|
}
|
|
:deep(.aie-container) {
|
border-radius: 4px;
|
overflow: hidden;
|
border: 1px solid rgba(23, 31, 38, 0.15);
|
box-sizing: border-box;
|
min-height: v-bind(height);
|
}
|
|
:deep(.aie-container aie-header) {
|
background: #f4f6f8;
|
border-bottom: 1px solid rgba(23, 31, 38, 0.15);
|
}
|
|
:deep(.aie-container aie-header > div) {
|
border-bottom: none !important;
|
}
|
|
:deep(.aie-container aie-header .aie-menu-item) {
|
color: #74797d;
|
}
|
|
:deep(.aie-container aie-header .aie-menu-item svg) {
|
fill: #74797d;
|
}
|
|
:deep(.aie-main) {
|
min-height: v-bind(height);
|
}
|
|
:deep(.aie-content ul:not([data-type='taskList'])) {
|
list-style-type: disc;
|
padding-left: 20px;
|
margin: 8px 0;
|
}
|
|
:deep(.aie-content ol) {
|
list-style-type: decimal;
|
padding-left: 20px;
|
margin: 8px 0;
|
}
|
|
:deep(.aie-content ul:not([data-type='taskList']) > li),
|
:deep(.aie-content ol > li) {
|
list-style: inherit;
|
margin: 4px 0;
|
}
|
|
:deep(.aie-content li > ul:not([data-type='taskList'])),
|
:deep(.aie-content li > ol) {
|
margin-top: 8px;
|
margin-bottom: 0;
|
}
|
</style>
|