278 lines
6.2 KiB
Vue
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>
|