473 lines
12 KiB
Vue
473 lines
12 KiB
Vue
<template>
|
|
<div class="rich-text-editor">
|
|
<!-- 工具栏 -->
|
|
<div v-if="showToolbar" class="editor-toolbar">
|
|
<div class="toolbar-group">
|
|
<!-- 文本格式 -->
|
|
<el-button-group size="small">
|
|
<el-button @click="execCommand('bold')" :class="{ active: isActive('bold') }">
|
|
<el-icon><Bold /></el-icon>
|
|
</el-button>
|
|
<el-button @click="execCommand('italic')" :class="{ active: isActive('italic') }">
|
|
<el-icon><Italic /></el-icon>
|
|
</el-button>
|
|
<el-button @click="execCommand('underline')" :class="{ active: isActive('underline') }">
|
|
<el-icon><Underline /></el-icon>
|
|
</el-button>
|
|
<el-button @click="execCommand('strikeThrough')" :class="{ active: isActive('strikeThrough') }">
|
|
<el-icon><Strikethrough /></el-icon>
|
|
</el-button>
|
|
</el-button-group>
|
|
|
|
<!-- 对齐方式 -->
|
|
<el-button-group size="small">
|
|
<el-button @click="execCommand('justifyLeft')" :class="{ active: isActive('justifyLeft') }">
|
|
<el-icon><AlignLeft /></el-icon>
|
|
</el-button>
|
|
<el-button @click="execCommand('justifyCenter')" :class="{ active: isActive('justifyCenter') }">
|
|
<el-icon><AlignCenter /></el-icon>
|
|
</el-button>
|
|
<el-button @click="execCommand('justifyRight')" :class="{ active: isActive('justifyRight') }">
|
|
<el-icon><AlignRight /></el-icon>
|
|
</el-button>
|
|
</el-button-group>
|
|
|
|
<!-- 列表 -->
|
|
<el-button-group size="small">
|
|
<el-button @click="execCommand('insertUnorderedList')" :class="{ active: isActive('insertUnorderedList') }">
|
|
<el-icon><List /></el-icon>
|
|
</el-button>
|
|
<el-button @click="execCommand('insertOrderedList')" :class="{ active: isActive('insertOrderedList') }">
|
|
<el-icon><Numbered /></el-icon>
|
|
</el-button>
|
|
</el-button-group>
|
|
|
|
<!-- 其他功能 -->
|
|
<el-button-group size="small">
|
|
<el-button @click="insertLink">
|
|
<el-icon><Link /></el-icon>
|
|
</el-button>
|
|
<el-button @click="insertImage">
|
|
<el-icon><Picture /></el-icon>
|
|
</el-button>
|
|
<el-button @click="insertTable">
|
|
<el-icon><Grid /></el-icon>
|
|
</el-button>
|
|
</el-button-group>
|
|
|
|
<!-- 颜色 -->
|
|
<div class="color-picker">
|
|
<el-color-picker
|
|
v-model="textColor"
|
|
size="small"
|
|
@change="changeTextColor"
|
|
/>
|
|
</div>
|
|
|
|
<!-- 清除格式 -->
|
|
<el-button size="small" @click="clearFormat">
|
|
<el-icon><Delete /></el-icon>
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 编辑器内容区 -->
|
|
<div
|
|
ref="editorRef"
|
|
class="editor-content"
|
|
:style="{ height: height }"
|
|
contenteditable
|
|
@input="handleInput"
|
|
@paste="handlePaste"
|
|
@keydown="handleKeydown"
|
|
@focus="handleFocus"
|
|
@blur="handleBlur"
|
|
v-html="content"
|
|
/>
|
|
|
|
<!-- 字数统计 -->
|
|
<div v-if="showWordCount" class="editor-footer">
|
|
<span class="word-count">{{ wordCount }} 字</span>
|
|
<span v-if="maxLength" class="word-limit">/ {{ maxLength }}</span>
|
|
</div>
|
|
|
|
<!-- 插入链接对话框 -->
|
|
<el-dialog
|
|
v-model="linkDialogVisible"
|
|
title="插入链接"
|
|
width="400px"
|
|
>
|
|
<el-form :model="linkForm" label-width="80px">
|
|
<el-form-item label="链接文本">
|
|
<el-input v-model="linkForm.text" placeholder="请输入链接文本" />
|
|
</el-form-item>
|
|
<el-form-item label="链接地址">
|
|
<el-input v-model="linkForm.url" placeholder="请输入链接地址" />
|
|
</el-form-item>
|
|
</el-form>
|
|
|
|
<template #footer>
|
|
<el-button @click="linkDialogVisible = false">取消</el-button>
|
|
<el-button type="primary" @click="confirmInsertLink">确定</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<!-- 插入图片对话框 -->
|
|
<el-dialog
|
|
v-model="imageDialogVisible"
|
|
title="插入图片"
|
|
width="500px"
|
|
>
|
|
<el-tabs v-model="imageTabActive">
|
|
<el-tab-pane label="上传图片" name="upload">
|
|
<ImageUpload
|
|
:limit="1"
|
|
:multiple="false"
|
|
@success="handleImageUpload"
|
|
/>
|
|
</el-tab-pane>
|
|
|
|
<el-tab-pane label="网络图片" name="url">
|
|
<el-form :model="imageForm" label-width="80px">
|
|
<el-form-item label="图片地址">
|
|
<el-input v-model="imageForm.url" placeholder="请输入图片地址" />
|
|
</el-form-item>
|
|
<el-form-item label="替代文本">
|
|
<el-input v-model="imageForm.alt" placeholder="请输入替代文本" />
|
|
</el-form-item>
|
|
</el-form>
|
|
|
|
<div class="dialog-footer">
|
|
<el-button @click="imageDialogVisible = false">取消</el-button>
|
|
<el-button type="primary" @click="confirmInsertImage">确定</el-button>
|
|
</div>
|
|
</el-tab-pane>
|
|
</el-tabs>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, nextTick, onMounted } from 'vue'
|
|
import { ElMessage } from 'element-plus'
|
|
import {
|
|
Bold,
|
|
Italic,
|
|
Underline,
|
|
Strikethrough,
|
|
AlignLeft,
|
|
AlignCenter,
|
|
AlignRight,
|
|
List,
|
|
Link,
|
|
Picture,
|
|
Grid,
|
|
Delete
|
|
} from '@element-plus/icons-vue'
|
|
import ImageUpload from '@/components/upload/ImageUpload.vue'
|
|
|
|
interface Props {
|
|
modelValue?: string
|
|
placeholder?: string
|
|
height?: string
|
|
maxLength?: number
|
|
showToolbar?: boolean
|
|
showWordCount?: boolean
|
|
disabled?: boolean
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
modelValue: '',
|
|
placeholder: '请输入内容...',
|
|
height: '300px',
|
|
maxLength: 0,
|
|
showToolbar: true,
|
|
showWordCount: true,
|
|
disabled: false
|
|
})
|
|
|
|
interface Emits {
|
|
(e: 'update:modelValue', value: string): void
|
|
(e: 'change', value: string): void
|
|
(e: 'focus', event: FocusEvent): void
|
|
(e: 'blur', event: FocusEvent): void
|
|
}
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
// 响应式数据
|
|
const editorRef = ref<HTMLElement>()
|
|
const content = ref(props.modelValue)
|
|
const textColor = ref('#000000')
|
|
const linkDialogVisible = ref(false)
|
|
const imageDialogVisible = ref(false)
|
|
const imageTabActive = ref('upload')
|
|
const focused = ref(false)
|
|
|
|
// 表单数据
|
|
const linkForm = ref({
|
|
text: '',
|
|
url: ''
|
|
})
|
|
|
|
const imageForm = ref({
|
|
url: '',
|
|
alt: ''
|
|
})
|
|
|
|
// 计算属性
|
|
const wordCount = computed(() => {
|
|
const text = editorRef.value?.innerText || ''
|
|
return text.length
|
|
})
|
|
|
|
// 方法
|
|
const handleInput = () => {
|
|
if (!editorRef.value) return
|
|
|
|
const html = editorRef.value.innerHTML
|
|
content.value = html
|
|
|
|
// 检查字数限制
|
|
if (props.maxLength && wordCount.value > props.maxLength) {
|
|
ElMessage.warning(`内容不能超过 ${props.maxLength} 字`)
|
|
return
|
|
}
|
|
|
|
emit('update:modelValue', html)
|
|
emit('change', html)
|
|
}
|
|
|
|
const handlePaste = (event: ClipboardEvent) => {
|
|
event.preventDefault()
|
|
|
|
const clipboardData = event.clipboardData
|
|
if (!clipboardData) return
|
|
|
|
// 获取纯文本内容
|
|
const text = clipboardData.getData('text/plain')
|
|
|
|
// 插入文本
|
|
execCommand('insertText', text)
|
|
}
|
|
|
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
// Ctrl+B 加粗
|
|
if (event.ctrlKey && event.key === 'b') {
|
|
event.preventDefault()
|
|
execCommand('bold')
|
|
}
|
|
|
|
// Ctrl+I 斜体
|
|
if (event.ctrlKey && event.key === 'i') {
|
|
event.preventDefault()
|
|
execCommand('italic')
|
|
}
|
|
|
|
// Ctrl+U 下划线
|
|
if (event.ctrlKey && event.key === 'u') {
|
|
event.preventDefault()
|
|
execCommand('underline')
|
|
}
|
|
}
|
|
|
|
const handleFocus = (event: FocusEvent) => {
|
|
focused.value = true
|
|
emit('focus', event)
|
|
}
|
|
|
|
const handleBlur = (event: FocusEvent) => {
|
|
focused.value = false
|
|
emit('blur', event)
|
|
}
|
|
|
|
const execCommand = (command: string, value?: string) => {
|
|
document.execCommand(command, false, value)
|
|
editorRef.value?.focus()
|
|
handleInput()
|
|
}
|
|
|
|
const isActive = (command: string): boolean => {
|
|
return document.queryCommandState(command)
|
|
}
|
|
|
|
const changeTextColor = (color: string) => {
|
|
execCommand('foreColor', color)
|
|
}
|
|
|
|
const insertLink = () => {
|
|
const selection = window.getSelection()
|
|
if (selection && selection.toString()) {
|
|
linkForm.value.text = selection.toString()
|
|
}
|
|
linkDialogVisible.value = true
|
|
}
|
|
|
|
const confirmInsertLink = () => {
|
|
if (!linkForm.value.url) {
|
|
ElMessage.warning('请输入链接地址')
|
|
return
|
|
}
|
|
|
|
const linkHtml = `<a href="${linkForm.value.url}" target="_blank">${linkForm.value.text || linkForm.value.url}</a>`
|
|
execCommand('insertHTML', linkHtml)
|
|
|
|
linkDialogVisible.value = false
|
|
linkForm.value = { text: '', url: '' }
|
|
}
|
|
|
|
const insertImage = () => {
|
|
imageDialogVisible.value = true
|
|
}
|
|
|
|
const handleImageUpload = (response: any) => {
|
|
const imageHtml = `<img src="${response.url}" alt="上传图片" style="max-width: 100%; height: auto;" />`
|
|
execCommand('insertHTML', imageHtml)
|
|
imageDialogVisible.value = false
|
|
}
|
|
|
|
const confirmInsertImage = () => {
|
|
if (!imageForm.value.url) {
|
|
ElMessage.warning('请输入图片地址')
|
|
return
|
|
}
|
|
|
|
const imageHtml = `<img src="${imageForm.value.url}" alt="${imageForm.value.alt}" style="max-width: 100%; height: auto;" />`
|
|
execCommand('insertHTML', imageHtml)
|
|
|
|
imageDialogVisible.value = false
|
|
imageForm.value = { url: '', alt: '' }
|
|
}
|
|
|
|
const insertTable = () => {
|
|
const tableHtml = `
|
|
<table border="1" style="border-collapse: collapse; width: 100%;">
|
|
<tr>
|
|
<td style="padding: 8px;">单元格1</td>
|
|
<td style="padding: 8px;">单元格2</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 8px;">单元格3</td>
|
|
<td style="padding: 8px;">单元格4</td>
|
|
</tr>
|
|
</table>
|
|
`
|
|
execCommand('insertHTML', tableHtml)
|
|
}
|
|
|
|
const clearFormat = () => {
|
|
execCommand('removeFormat')
|
|
}
|
|
|
|
const focus = () => {
|
|
editorRef.value?.focus()
|
|
}
|
|
|
|
const blur = () => {
|
|
editorRef.value?.blur()
|
|
}
|
|
|
|
const getContent = () => {
|
|
return content.value
|
|
}
|
|
|
|
const setContent = (html: string) => {
|
|
content.value = html
|
|
if (editorRef.value) {
|
|
editorRef.value.innerHTML = html
|
|
}
|
|
}
|
|
|
|
// 监听外部值变化
|
|
watch(() => props.modelValue, (newValue) => {
|
|
if (newValue !== content.value) {
|
|
content.value = newValue
|
|
nextTick(() => {
|
|
if (editorRef.value) {
|
|
editorRef.value.innerHTML = newValue
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
// 生命周期
|
|
onMounted(() => {
|
|
if (editorRef.value && props.modelValue) {
|
|
editorRef.value.innerHTML = props.modelValue
|
|
}
|
|
})
|
|
|
|
// 暴露方法
|
|
defineExpose({
|
|
focus,
|
|
blur,
|
|
getContent,
|
|
setContent,
|
|
execCommand
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.rich-text-editor {
|
|
@apply border border-gray-300 rounded-lg overflow-hidden;
|
|
}
|
|
|
|
.editor-toolbar {
|
|
@apply bg-gray-50 border-b border-gray-300 p-2;
|
|
}
|
|
|
|
.toolbar-group {
|
|
@apply flex items-center space-x-2 flex-wrap;
|
|
}
|
|
|
|
.color-picker {
|
|
@apply flex items-center;
|
|
}
|
|
|
|
.editor-content {
|
|
@apply p-4 outline-none overflow-y-auto;
|
|
min-height: 200px;
|
|
}
|
|
|
|
.editor-content:empty:before {
|
|
content: attr(placeholder);
|
|
@apply text-gray-400;
|
|
}
|
|
|
|
.editor-footer {
|
|
@apply bg-gray-50 border-t border-gray-300 px-4 py-2 text-right text-sm text-gray-500;
|
|
}
|
|
|
|
.word-count {
|
|
@apply mr-1;
|
|
}
|
|
|
|
.word-limit {
|
|
@apply text-gray-400;
|
|
}
|
|
|
|
.dialog-footer {
|
|
@apply flex justify-end space-x-2 mt-4;
|
|
}
|
|
|
|
:deep(.el-button.active) {
|
|
@apply bg-blue-500 text-white;
|
|
}
|
|
|
|
:deep(.editor-content img) {
|
|
@apply max-w-full h-auto;
|
|
}
|
|
|
|
:deep(.editor-content table) {
|
|
@apply border-collapse w-full;
|
|
}
|
|
|
|
:deep(.editor-content table td) {
|
|
@apply border border-gray-300 p-2;
|
|
}
|
|
|
|
:deep(.editor-content a) {
|
|
@apply text-blue-500 underline;
|
|
}
|
|
</style>
|