This commit is contained in:
oneao committed 2025-12-11 17:31:05 +08:00
1 parent 68e0785ec6
commit 57c0d98f11
189 files changed
+34329

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,177 @@
<template>
<a-spin :loading="false" class="w-full h-full editor-container">
<div class="main">
<Toolbar :class="toolbarClass" class="toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" v-if="props.displayMode === 'edit'" />
<Editor :class="editorClass" class="editor" @onChange="handleChange" v-model="valueHtml" :defaultConfig="editorConfig" @onCreated="handleCreated" key="main" />
</div>
<Direcotry class="directory" v-model:content="valueHtml" v-if="showDirecotry" />
</a-spin>
<Preview v-model:visible="showPreview" :content="previewHtml" />
</template>
<script setup>
import Preview from "./modules/preview.vue";
import Direcotry from "./modules/directory.vue";
import "@wangeditor/editor/dist/css/style.css"; // 引入 css
import { uploadVideo, uploadImage, uploadToAliyun } from "@/utils/file";
import { Message } from "@arco-design/web-vue";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import { ref, computed, onBeforeUnmount, shallowRef, watch, nextTick, defineExpose, onMounted } from "vue";
import { registerPreviewMenu, toolbarConfig } from "./config";
const props = defineProps({
displayMode: {
type: String,
default: "edit" // 'edit' 表示编辑器模式,'view' 表示只读/阅读模式
},
modelValue: {
type: String,
default: ""
},
showDirecotry: {
type: Boolean,
default: false
}
});
const emit = defineEmits(["update:modelValue"]);
const previewHtml = ref(""); // 只读副本
const showPreview = ref(false);
onMounted(() => {
registerPreviewMenu(() => {
previewHtml.value = valueHtml.value;
showPreview.value = true;
});
});
const toolbarClass = computed(() => {
return props.displayMode === "view" ? "toolbar-view" : "toolbar-edit";
});
const editorClass = computed(() => {
return props.displayMode === "view" ? "editor-view" : "editor-edit";
});
// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef();
defineExpose({
getText: () => {
if (editorRef.value) {
return editorRef.value.getText();
} else {
console.error("Editor not initialized");
return "";
}
}
});
// 内容 HTML
const valueHtml = ref("");
// 目录
watch(
() => props.modelValue,
(newVal) => {
valueHtml.value = newVal;
},
{ immediate: true }
);
const handleChange = () => {
emit("update:modelValue", valueHtml);
};
const editorConfig = {
placeholder: props.displayMode === "edit" ? "请输入内容..." : "",
readOnly: props.displayMode === "view",
MENU_CONF: {} // ✅ 这一步不能漏!
};
editorConfig.MENU_CONF["uploadImage"] = {
// 自定义选择图片
customBrowseAndUpload(insertFn) {
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) {
uploadVideo().then((data) => {
if (data) {
uploadToAliyun(data.blob, data.name, data.size).then((res) => {
insertFn(res.url, res.fileName, "");
});
} else {
Message.error("上传失败,请稍后再试!");
}
});
}
};
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value;
if (editor == null) return;
editor.destroy();
});
const handleCreated = (editor) => {
editorRef.value = editor;
nextTick(() => {});
};
</script>
<style lang="scss" scoped>
.toolbar {
}
.toolbar-view {
}
.toolbar-edit {
border: 1px solid #ccc;
}
.editor {
overflow-y: hidden;
:deep(.w-e-text-container video) {
width: 100% !important;
}
}
.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;
}
}
.editor-edit {
height: 500px !important;
border: 1px solid #ccc;
border-top: transparent;
}
.editor-container {
display: flex;
gap: 44px;
.directory {
width: 200px;
height: 450px;
}
.main {
flex: 1;
}
}
</style>
@@ -0,0 +1,173 @@
<template>
<div class="directory-card" :class="{ collapsed }">
<div class="directory-header" @click="toggleCollapse">
<div class="directory-header-left">
<div class="i-material-symbols:folder-copy-outline"></div>
<span>目录</span>
</div>
</div>
<div 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>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from "vue";
defineOptions({
name: "WangEditorDirecotry"
});
const props = defineProps({
content: {
type: String
},
top: {
type: Number
}
});
const directory = ref([]);
const collapsed = ref(false);
function toggleCollapse() {
collapsed.value = !collapsed.value;
}
function scrollTo(id) {
const el = document.getElementById(id);
if (el) {
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", // 元素滚动到视口顶部
inline: "nearest" // 元素在水平方向上的滚动对齐方式
});
}
}
}
function throttle(fn) {
let isRunning = false;
return function (...args) {
if (!isRunning) {
isRunning = true;
requestAnimationFrame(() => {
fn(...args);
isRunning = false;
});
}
};
}
function doGenerateDirectory() {
const root = document.querySelector(".editor");
if (!root) return;
const headings = Array.from(root.querySelectorAll("h1, h2, h3"));
const toc = headings.map((heading, idx) => {
if (!heading.id) heading.id = `section-${idx + 1}`;
return {
id: heading.id,
text: heading.textContent || "",
level: +heading.tagName[1], // 1 => h1, 2 => h2, 3 => h3
index: idx
};
});
directory.value = toc;
}
const updateDirectory = throttle(doGenerateDirectory, 300);
watch(
() => props.content,
(newContent) => {
if (newContent) {
nextTick(() => {
updateDirectory();
});
}
},
{ immediate: true }
);
</script>
<style scoped lang="scss">
.directory-card {
display: flex;
flex-direction: column;
background-color: #fff;
overflow: hidden;
&.collapsed {
height: 44px !important;
box-shadow: none;
}
}
.directory-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
height: 44px;
font-size: 16px;
font-weight: bold;
border-bottom: 1px solid #e0e0e0;
cursor: pointer;
transition: background-color 0.2s ease;
.directory-header-left {
display: flex;
gap: 8px;
align-items: center;
font-size: 14px;
color: #333;
}
}
.directory-content {
flex: 1;
padding: 1rem;
overflow-y: auto;
word-break: break-all;
.directory-item {
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
color: #333;
&.level-1 {
font-weight: 600;
padding-left: 0;
font-size: 16px;
}
&.level-2 {
padding-left: 20px;
font-size: 14px;
}
&.level-3 {
padding-left: 40px;
font-size: 12px;
}
&:hover {
color: #007aff;
}
}
}
</style>
@@ -0,0 +1,118 @@
<template>
<a-modal v-bind="$attrs" :visible="localVisible" :footer="false" :closable="false" @update:visible="handleVisibleChange" :fullscreen="fullscreen" :width="1100" modal-class="custom-modal">
<template #title>
<div class="header">
<div></div>
<div class="title">预览</div>
<div class="actions">
<div class="action" :class="fullscreen ? 'i-material-symbols:fullscreen-exit' : 'i-material-symbols:fullscreen'" @click="fullscreen = !fullscreen"></div>
<div class="action i-material-symbols:close-rounded" @click="closeModal"></div>
</div>
</div>
</template>
<div :class="fullscreen ? '' : 'editor'">
<Editor class="editorView" :defaultConfig="editorConfig" @onCreated="handleCreated" key="preview" />
</div>
</a-modal>
</template>
<script setup lang="ts">
import "@wangeditor/editor/dist/css/style.css";
import { Editor } from "@wangeditor/editor-for-vue";
import { ref, watch, shallowRef } from "vue";
// 编辑器配置
const editorConfig = {
placeholder: "暂无内容",
readOnly: true,
MENU_CONF: {}
};
// Props 定义
const props = withDefaults(
defineProps<{
content?: string;
visible?: boolean;
}>(),
{
content: "",
visible: false
}
);
const emit = defineEmits(["update:visible"]);
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 }
);
// 编辑器创建回调
const handleCreated = (editor: any) => {
editorRef.value = editor;
};
// 更新 visible 的统一方法
const handleVisibleChange = (val: boolean) => {
localVisible.value = val;
emit("update:visible", val);
};
// 关闭操作
const closeModal = () => {
fullscreen.value = false;
emit("update:visible", false);
handleVisibleChange(false);
};
</script>
<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>