u
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user