This commit is contained in:
oneao committed 2026-04-24 17:28:11 +08:00
1 parent 07e0001b87
commit 46cbb2f50d
543 files changed
+32576 -4231

No files matched your search

@@ -0,0 +1,67 @@
import type { IButtonMenu, IDomEditor } from '@wangeditor/editor'
import { Boot } from '@wangeditor/editor'
// 当前的点击处理器,始终指向最新传入的函数
let currentClickHandler: (() => void) | null = null
// 是否已经注册过菜单
let hasRegistered = false
class PreviewButtonMenu implements IButtonMenu {
title = '预览'
iconSvg = `
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16">
<path fill="currentColor" fill-rule="evenodd" d="M12 4.57a.5.5 0 0 0-.024-.235l-.013-.063a1.5 1.5 0 0 0-.18-.434c-.092-.15-.222-.28-.482-.54L8.711.707c-.259-.26-.389-.39-.54-.483a1.5 1.5 0 0 0-.496-.193a.5.5 0 0 0-.235-.024C7.329.004 7.194.004 7.015.004h-2.21c-1.68 0-2.52 0-3.16.327a3.02 3.02 0 0 0-1.31 1.31C.008 2.283.008 3.12.008 4.8v6.4c0 1.68 0 2.52.327 3.16a3.02 3.02 0 0 0 1.31 1.31c.642.327 1.48.327 3.16.327h2.423c.401 0 .602-.523.347-.832a.45.45 0 0 0-.345-.168H4.8c-.857 0-1.44-.001-1.89-.038c-.438-.036-.663-.1-.819-.18a2 2 0 0 1-.874-.874c-.08-.156-.145-.38-.18-.819c-.036-.45-.037-1.03-.037-1.89v-6.4c0-.857 0-1.44.037-1.89c.036-.438.101-.663.18-.819c.192-.376.498-.682.874-.874c.156-.08.381-.145.82-.18C3.36.997 3.94.997 4.8.997H7v3.5a.5.5 0 0 0 .5.5H11v.547c0 .25.207.45.456.473c.285.025.543-.188.543-.474V4.99c0-.178 0-.313-.005-.425zM8 1.41L10.59 4H8z" clip-rule="evenodd"/>
<path fill="currentColor" fill-rule="evenodd" d="M11 15c.834 0 1.61-.255 2.25-.691l1.47 1.47a.749.749 0 1 0 1.06-1.06l-1.47-1.47c.436-.641.691-1.41.691-2.25c0-2.21-1.79-4-4-4s-4 1.79-4 4s1.79 4 4 4zm0-1c1.66 0 3-1.34 3-3s-1.34-3-3-3s-3 1.34-3 3s1.34 3 3 3" clip-rule="evenodd"/>
</svg>
`
tag = 'button'
getValue(): string | boolean {
return ''
}
isActive(): boolean {
return false
}
isDisabled(): boolean {
return false
}
exec(editor: IDomEditor): void {
if (this.isDisabled())
return
currentClickHandler?.() // 始终执行最新的回调
}
}
// 提供注册函数,允许多次调用,但只注册一次菜单
export function registerPreviewMenu(handleClick: () => void) {
currentClickHandler = handleClick
if (hasRegistered)
return
try {
Boot.registerMenu({
key: 'preview',
factory: () => new PreviewButtonMenu(),
})
hasRegistered = true
}
catch (e) {
if (!/duplicate/i.test(String(e))) {
console.warn('注册预览菜单失败:', e)
}
}
}
// 菜单插入配置
export const toolbarConfig = {
excludeKeys: ['codeBlock', 'fullScreen', 'code'],
insertKeys: {
index: 32,
keys: ['preview'],
},
}
@@ -0,0 +1,277 @@
<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>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { Editor } from '@wangeditor/editor-for-vue'
import { ref, shallowRef, watch } from 'vue'
import '@wangeditor/editor/dist/css/style.css'
// Props 定义
const props = withDefaults(
defineProps<{
content?: string
visible?: boolean
}>(),
{
content: '',
visible: false,
},
)
const emit = defineEmits(['update:visible'])
// 编辑器配置
const editorConfig = {
placeholder: '暂无内容',
readOnly: true,
MENU_CONF: {},
}
const fullscreen = ref(false)
const localVisible = ref(props.visible)
const editorRef = shallowRef()
// 同步 props.visible 到 localVisible
watch(
() => props.visible,
(val) => {
localVisible.value = val
},
{ immediate: true },
)
watch(
() => props.content,
(val) => {
if (editorRef.value) {
editorRef.value.setHtml(val)
}
},
{ immediate: true },
)
// 编辑器创建回调
function handleCreated(editor: any) {
editorRef.value = editor
}
// 更新 visible 的统一方法
function handleVisibleChange(val: boolean) {
localVisible.value = val
emit('update:visible', val)
}
// 关闭操作
function closeModal() {
fullscreen.value = false
emit('update:visible', false)
handleVisibleChange(false)
}
</script>
<template>
<a-modal v-bind="$attrs" :visible="localVisible" :footer="false" :closable="false" :fullscreen="fullscreen" :width="1100" modal-class="custom-modal" @update:visible="handleVisibleChange">
<template #title>
<div class="header">
<div />
<div class="title">
预览
</div>
<div class="actions">
<div class="action" @click="fullscreen = !fullscreen">
<i-material-symbols:fullscreen-exit v-if="fullscreen" />
<i-material-symbols:fullscreen v-else />
</div>
<div class="action" @click="closeModal">
<i i-material-symbols:close-rounded />
</div>
</div>
</div>
</template>
<div :class="fullscreen ? '' : 'editor'">
<Editor key="preview" class="editorView" :default-config="editorConfig" @on-created="handleCreated" />
</div>
</a-modal>
</template>
<style lang="scss" scoped>
.header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.actions {
display: flex;
gap: 16px;
font-size: 18px;
.action {
cursor: pointer;
}
}
.editor {
max-height: 500px;
}
.editor-view {
overflow-y: hidden;
height: auto;
:deep(.w-e-text-container .w-e-scroll [data-slate-editor]) {
padding: 0 0px !important;
}
}
</style>