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