Files
workspace/code/link56/link56-frontend/src/components/wang-editor/index.vue
T
2026-04-24 17:28:11 +08:00

278 lines
6.2 KiB
Vue

<script setup lang="ts">
import type { PropType } from 'vue'
import type { AnchorMenuItem } from '@/components/anchor-menu/type'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { ElMessage } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { uploadImage, uploadToAliyun, uploadVideo } from '@/utils/file'
import { registerPreviewMenu, toolbarConfig } from './config'
import '@wangeditor/editor/dist/css/style.css'
const props = defineProps({
mode: {
type: String as PropType<'edit' | 'view'>,
default: 'edit',
},
modelValue: {
type: String,
default: '',
},
showDirecotry: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue', 'update:directory'])
// 内容 HTML
const valueHtml = ref('')
// Directory
const activeCollapseNames = ref(['1'])
const directory = ref<AnchorMenuItem[]>([])
const updateDirectory = throttle(doGenerateDirectory)
watch(
() => valueHtml.value,
(newContent) => {
if (newContent) {
nextTick(() => {
updateDirectory()
})
}
},
{ immediate: true },
)
// 只读副本
const previewHtml = ref('')
const showPreview = ref(false)
watch(
() => props.modelValue,
(newVal) => {
valueHtml.value = newVal ?? ''
},
{ immediate: true },
)
onMounted(() => {
registerPreviewMenu(() => {
previewHtml.value = valueHtml.value
showPreview.value = true
})
})
const toolbarClass = computed(() => {
return props.mode === 'view' ? 'wang-toolbar-view' : 'wang-toolbar-edit'
})
const editorClass = computed(() => {
return props.mode === 'view' ? 'wang-editor-view' : 'wang-editor-edit'
})
// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef()
defineExpose({
getText: () => {
if (editorRef.value) {
return editorRef.value.getText()
}
else {
console.error('Editor not initialized')
return ''
}
},
})
function handleChange() {
emit('update:modelValue', valueHtml.value)
}
const editorConfig: any = {
placeholder: props.mode === 'edit' ? '请输入内容...' : '',
readOnly: props.mode === 'view',
MENU_CONF: {}, // ✅ 这一步不能漏!
}
editorConfig.MENU_CONF.uploadImage = {
// 自定义选择图片
customBrowseAndUpload(insertFn: any) {
uploadImage().then((data) => {
if (data) {
uploadToAliyun(data.blob, data.name, data.size).then((res) => {
insertFn(res.url, res.fileName, '')
})
}
})
},
}
editorConfig.MENU_CONF.uploadVideo = {
customBrowseAndUpload(insertFn: any) {
uploadVideo().then((data) => {
if (data) {
uploadToAliyun(data.blob, data.name, data.size).then((res) => {
insertFn(res.url, res.fileName, '')
})
}
else {
ElMessage.error('上传失败,请稍后再试!')
}
})
},
}
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null)
return
editor.destroy()
})
function handleCreated(editor: any) {
editorRef.value = editor
nextTick(() => {})
}
function throttle(fn: any) {
let isRunning = false
return function (...args: any) {
if (!isRunning) {
isRunning = true
requestAnimationFrame(() => {
fn(...args)
isRunning = false
})
}
}
}
function doGenerateDirectory() {
const root = document.querySelector('.editor')
if (!root)
return
// 获取所有 h1, h2, h3 标签
const headings = Array.from(root.querySelectorAll('h1, h2, h3'))
// 用栈来管理当前的父子关系
const toc: AnchorMenuItem[] = []
const stack: AnchorMenuItem[] = []
headings.forEach((heading, idx) => {
if (!heading.id)
heading.id = `section-${idx + 1}`
const level = +heading.tagName[1] // 获取 h1 = 1, h2 = 2, h3 = 3
const item: AnchorMenuItem = {
href: heading.id,
label: heading.textContent || '',
level,
children: [],
}
// 处理层级关系
while (stack.length > 0) {
const top = stack[stack.length - 1] // 获取栈顶元素
if (top.level && top.level >= level) {
stack.pop() // 弹出更高层级的项
}
else {
break // 如果栈顶元素的级别小于当前的级别,停止弹栈
}
}
if (stack.length > 0) {
// 如果栈非空,当前节点为栈顶节点的子节点
stack[stack.length - 1].children.push(item)
}
else {
// 否则,当前节点是顶层节点
toc.push(item)
}
// 将当前节点推入栈中,成为下一个节点的父节点
stack.push(item)
})
// 保存目录结构
directory.value = toc
emit('update:directory', directory.value)
}
</script>
<template>
<div class="wang-editor-container">
<div class="main">
<Toolbar v-if="props.mode === 'edit'" :class="toolbarClass" class="toolbar" :editor="editorRef" :default-config="toolbarConfig" />
<Editor key="main" v-model="valueHtml" :class="editorClass" class="editor" :default-config="editorConfig" @on-change="handleChange" @on-created="handleCreated" />
</div>
<div v-if="showDirecotry" class="wang-editor-directory">
<el-collapse v-model="activeCollapseNames">
<el-collapse-item title="目录" name="1">
<el-anchor :offset="70">
<AnchorMenu :menu="directory" />
</el-anchor>
</el-collapse-item>
</el-collapse>
</div>
</div>
<!-- <Preview v-model:visible="showPreview" :content="previewHtml" /> -->
</template>
<style lang="scss" scoped>
.toolbar {
}
.wang-toolbar-view {
}
.wang-toolbar-edit {
border: 1px solid #ccc;
}
.editor {
overflow-y: hidden;
:deep(.w-e-text-container video) {
width: 100% !important;
}
}
.wang-editor-view {
height: auto;
:deep(.w-e-text-container) {
background-color: transparent;
}
:deep(.w-e-text-container .w-e-scroll [data-slate-editor]) {
overflow-y: auto;
padding: 0 0px !important;
}
}
.wang-editor-edit {
height: 600px !important;
border: 1px solid #ccc;
border-top: transparent;
}
.wang-editor-container {
display: flex;
width: 100%;
height: 100%;
.wang-editor-directory {
width: 200px;
height: 100%;
margin-left: 16px;
}
.main {
min-width: 0;
flex: 1;
}
}
</style>