u
This commit is contained in:
1 parent
62550b8182
commit
3e172a1d53
26 files changed
+4820
-413
No files matched your search
@@ -0,0 +1,63 @@
|
||||
import { Boot, IDomEditor, IButtonMenu } 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;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
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,236 @@
|
||||
<script setup>
|
||||
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import {
|
||||
computed,
|
||||
defineExpose,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
} from "vue";
|
||||
|
||||
import { uploadFile, uploadImage, uploadVideo } from "@/utils/file";
|
||||
import { registerPreviewMenu, toolbarConfig } from "./config";
|
||||
import Direcotry from "./modules/directory.vue";
|
||||
import Preview from "./modules/preview.vue";
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
|
||||
const props = defineProps({
|
||||
displayMode: {
|
||||
type: String,
|
||||
default: "edit", // edit / view
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
showDirecotry: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
moduleId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
father: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const previewHtml = ref("");
|
||||
const showPreview = ref(false);
|
||||
|
||||
// 内容
|
||||
const valueHtml = ref("");
|
||||
|
||||
onMounted(() => {
|
||||
registerPreviewMenu(() => {
|
||||
previewHtml.value = valueHtml.value;
|
||||
showPreview.value = true;
|
||||
});
|
||||
});
|
||||
|
||||
// 编辑器实例
|
||||
const editorRef = shallowRef();
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
valueHtml.value = val;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const toolbarClass = computed(() =>
|
||||
props.displayMode === "view" ? "toolbar-view" : "toolbar-edit",
|
||||
);
|
||||
|
||||
const editorClass = computed(() => (props.displayMode === "view" ? "editor-view" : "editor-edit"));
|
||||
|
||||
defineExpose({
|
||||
getText: () => {
|
||||
return editorRef.value ? editorRef.value.getText() : "";
|
||||
},
|
||||
});
|
||||
|
||||
function handleChange() {
|
||||
emit("update:modelValue", valueHtml.value);
|
||||
}
|
||||
|
||||
// ================= 编辑器配置 =================
|
||||
const editorConfig = {
|
||||
placeholder: props.displayMode === "edit" ? "请输入内容..." : "",
|
||||
readOnly: props.displayMode === "view",
|
||||
MENU_CONF: {},
|
||||
};
|
||||
|
||||
// 图片上传
|
||||
editorConfig.MENU_CONF.uploadImage = {
|
||||
async customBrowseAndUpload(insertFn) {
|
||||
try {
|
||||
const data = await uploadImage();
|
||||
if (!data) return;
|
||||
|
||||
const res = await uploadFile(
|
||||
data.blob,
|
||||
data.fileName,
|
||||
data.fileExt,
|
||||
data.fileSize,
|
||||
0,
|
||||
props.moduleId,
|
||||
props.father,
|
||||
"",
|
||||
);
|
||||
insertFn(res.mx_mapfilename, res.mx_filename, "");
|
||||
} catch (e) {
|
||||
ElMessage.error("图片上传失败");
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 视频上传
|
||||
editorConfig.MENU_CONF.uploadVideo = {
|
||||
async customBrowseAndUpload(insertFn) {
|
||||
try {
|
||||
const data = await uploadVideo();
|
||||
if (!data) return;
|
||||
|
||||
const res = await uploadFile(
|
||||
data.blob,
|
||||
data.fileName,
|
||||
data.fileExt,
|
||||
data.fileSize,
|
||||
0,
|
||||
props.moduleId,
|
||||
props.father,
|
||||
"",
|
||||
);
|
||||
insertFn(res.mx_mapfilename, res.mx_filename, "");
|
||||
} catch (e) {
|
||||
ElMessage.error("视频上传失败");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 销毁编辑器
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value;
|
||||
if (editor) editor.destroy();
|
||||
});
|
||||
|
||||
function handleCreated(editor) {
|
||||
editorRef.value = editor;
|
||||
nextTick(() => {});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="false" class="w-full h-full editor-container">
|
||||
<div class="main">
|
||||
<Toolbar
|
||||
v-if="props.displayMode === 'edit'"
|
||||
class="toolbar"
|
||||
:class="toolbarClass"
|
||||
:editor="editorRef"
|
||||
:default-config="toolbarConfig"
|
||||
/>
|
||||
<Editor
|
||||
key="main"
|
||||
v-model="valueHtml"
|
||||
class="editor"
|
||||
:class="editorClass"
|
||||
:default-config="editorConfig"
|
||||
@on-change="handleChange"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Direcotry v-if="showDirecotry" v-model:content="valueHtml" class="directory" />
|
||||
</div>
|
||||
|
||||
<Preview v-model:visible="showPreview" :content="previewHtml" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
}
|
||||
|
||||
.toolbar-view {
|
||||
}
|
||||
|
||||
.toolbar-edit {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.editor {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* 原来 .editor 内嵌的 :deep */
|
||||
.editor :deep(.w-e-text-container video) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.editor-view {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* 原来 .editor-view 内嵌的 :deep */
|
||||
.editor-view :deep(.w-e-text-container) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.editor-view :deep(.w-e-text-container .w-e-scroll [data-slate-editor]) {
|
||||
overflow-y: auto;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.editor-edit {
|
||||
height: 500px !important;
|
||||
border: 1px solid #ccc;
|
||||
border-top: transparent;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
display: flex;
|
||||
gap: 44px;
|
||||
}
|
||||
|
||||
/* 原来 .editor-container 内嵌的 .directory */
|
||||
.editor-container .directory {
|
||||
width: 200px;
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
/* 原来 .editor-container 内嵌的 .main */
|
||||
.editor-container .main {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="directory-card" :class="{ collapsed }">
|
||||
<!-- 头部 -->
|
||||
<div class="directory-header" @click="toggleCollapse">
|
||||
<div class="directory-header-left">
|
||||
<el-icon>
|
||||
<Folder />
|
||||
</el-icon>
|
||||
<span>目录</span>
|
||||
</div>
|
||||
<el-icon>
|
||||
<ArrowDown v-if="!collapsed" />
|
||||
<ArrowRight v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<el-scrollbar v-show="!collapsed" class="directory-content">
|
||||
<div v-for="item in directory" :key="item.id" class="directory-item" :class="`level-${item.level}`"
|
||||
@click="scrollTo(item.id)">
|
||||
{{ item.text }}
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { Folder, ArrowDown, ArrowRight } from '@element-plus/icons-vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'WangEditorDirectory'
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
content: String,
|
||||
top: Number
|
||||
})
|
||||
|
||||
const directory = ref([])
|
||||
const collapsed = ref(false)
|
||||
|
||||
const toggleCollapse = () => {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
const scrollTo = (id) => {
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
|
||||
if (props.top) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const scrollTop =
|
||||
window.pageYOffset || document.documentElement.scrollTop
|
||||
const top = rect.top + scrollTop - props.top
|
||||
|
||||
window.scrollTo({
|
||||
top,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
} else {
|
||||
el.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function throttle(fn) {
|
||||
let running = false
|
||||
return (...args) => {
|
||||
if (running) return
|
||||
running = true
|
||||
requestAnimationFrame(() => {
|
||||
fn(...args)
|
||||
running = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function generateDirectory() {
|
||||
const root = document.querySelector('.editor')
|
||||
if (!root) return
|
||||
|
||||
const headings = root.querySelectorAll('h1, h2, h3')
|
||||
directory.value = Array.from(headings).map((h, i) => {
|
||||
if (!h.id) h.id = `section-${i + 1}`
|
||||
return {
|
||||
id: h.id,
|
||||
text: h.textContent || '',
|
||||
level: Number(h.tagName[1]),
|
||||
index: i
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateDirectory = throttle(generateDirectory)
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
() => {
|
||||
nextTick(updateDirectory)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.directory-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 折叠态 */
|
||||
.directory-card.collapsed {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.directory-header {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.directory-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.directory-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.directory-item {
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.directory-item:hover {
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.directory-item.level-1 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.directory-item.level-2 {
|
||||
padding-left: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.directory-item.level-3 {
|
||||
padding-left: 40px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
import { Close, FullScreen } from '@element-plus/icons-vue'
|
||||
import { Editor } from '@wangeditor/editor-for-vue'
|
||||
import { ref, shallowRef, watch } from 'vue'
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
// ================= Props =================
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: 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()
|
||||
|
||||
// 同步 visible
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
localVisible.value = val
|
||||
},
|
||||
)
|
||||
|
||||
// 编辑器创建
|
||||
function handleCreated(editor) {
|
||||
editorRef.value = editor
|
||||
if (props.content) {
|
||||
editor.setHtml(props.content)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
function handleCloseDialog() {
|
||||
fullscreen.value = false
|
||||
localVisible.value = false
|
||||
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 打开弹窗
|
||||
function handleOpenDialog() {
|
||||
if (editorRef.value) {
|
||||
editorRef.value.setHtml(props.content || '')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-bind="$attrs"
|
||||
v-model="localVisible"
|
||||
:fullscreen="fullscreen"
|
||||
:show-close="false"
|
||||
@close="handleCloseDialog"
|
||||
@open="handleOpenDialog"
|
||||
>
|
||||
<template #header>
|
||||
<div class="header">
|
||||
<div />
|
||||
<div class="title">
|
||||
预览
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="action" @click="fullscreen = !fullscreen">
|
||||
<el-icon>
|
||||
<FullScreen />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="action" @click="handleCloseDialog">
|
||||
<el-icon>
|
||||
<Close />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="editor-container" :class="[{ 'editor-fullscreen': fullscreen }]">
|
||||
<Editor
|
||||
key="preview"
|
||||
:default-config="editorConfig"
|
||||
style="height: 100%"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.actions .action {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.editor-container.editor-fullscreen {
|
||||
height: calc(100vh - 100px);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user