plus-ui/src/components/Editor/index.vue

517 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div>
<el-upload
v-if="type === 'url'"
:action="upload.url"
:before-upload="handleBeforeUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
class="editor-img-uploader"
name="file"
:show-file-list="false"
:headers="upload.headers"
>
<i ref="uploadRef"></i>
</el-upload>
</div>
<div class="editor">
<quill-editor
ref="quillEditorRef"
v-model:content="content"
content-type="html"
:options="options"
:style="styles"
@text-change="(e: any) => $emit('update:modelValue', content)"
/>
</div>
<!-- 图片预览对话框 -->
<el-dialog v-model="previewDialogVisible" title="图片预览" width="90%" append-to-body>
<div class="image-preview-container">
<div class="image-wrapper">
<img
v-if="currentPreviewImage"
:src="currentPreviewImage"
alt="预览图片"
class="preview-image"
@load="handleImageLoad"
/>
</div>
<div v-if="imageUrlList.length > 1" class="image-preview-nav">
<el-button
:disabled="currentImageIndex === 0"
@click="prevImage"
icon="ArrowLeft"
circle
/>
<span class="image-counter">{{ currentImageIndex + 1 }} / {{ imageUrlList.length }}</span>
<el-button
:disabled="currentImageIndex === imageUrlList.length - 1"
@click="nextImage"
icon="ArrowRight"
circle
/>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import '@vueup/vue-quill/dist/vue-quill.snow.css';
import { QuillEditor, Quill } from '@vueup/vue-quill';
import { propTypes } from '@/utils/propTypes';
import { globalHeaders } from '@/utils/request';
import { nextTick, onMounted, computed } from 'vue';
defineEmits(['update:modelValue']);
const props = defineProps({
/* 编辑器的内容 */
modelValue: propTypes.string,
/* 高度 */
height: propTypes.number.def(400),
/* 最小高度 */
minHeight: propTypes.number.def(400),
/* 只读 */
readOnly: propTypes.bool.def(false),
/* 上传文件大小限制(MB) */
fileSize: propTypes.number.def(5),
/* 类型base64格式url格式 */
type: propTypes.string.def('url')
});
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const upload = reactive<UploadOption>({
headers: globalHeaders(),
url: import.meta.env.VITE_APP_BASE_API + '/resource/oss/upload'
});
const quillEditorRef = ref();
const uploadRef = ref<HTMLDivElement>();
// 图片预览相关
const previewDialogVisible = ref(false);
const imageUrlList = ref<string[]>([]);
const currentImageIndex = ref(0);
const currentPreviewImage = computed(() => {
return imageUrlList.value[currentImageIndex.value] || '';
});
// 图片加载处理
const handleImageLoad = (event: Event) => {
const img = event.target as HTMLImageElement;
// 图片加载完成后,确保图片能够正确显示
img.style.display = 'block';
};
const options = computed(() => {
return {
theme: 'snow',
bounds: document.body,
debug: 'warn',
modules: {
// 工具栏配置 - 只读模式下隐藏工具栏
toolbar: props.readOnly ? false : {
container: [
['bold', 'italic', 'underline', 'strike'], // 加粗 斜体 下划线 删除线
['blockquote', 'code-block'], // 引用 代码块
[{ list: 'ordered' }, { list: 'bullet' }], // 有序、无序列表
[{ indent: '-1' }, { indent: '+1' }], // 缩进
[{ size: ['small', false, 'large', 'huge'] }], // 字体大小
[{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题
[{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色
[{ align: [] }], // 对齐方式
['clean'], // 清除文本格式
['link', 'image', 'video'] // 链接、图片、视频
],
handlers: {
image: (value: boolean) => {
if (value) {
// 调用element图片上传
uploadRef.value.click();
} else {
Quill.format('image', true);
}
}
}
}
},
placeholder: props.readOnly ? '' : '请输入内容', // 只读模式下不显示占位符
readOnly: props.readOnly
};
});
const styles = computed(() => {
let style: any = {};
if (props.minHeight) {
style.minHeight = `${props.minHeight}px`;
}
if (props.height) {
style.height = `${props.height}px`;
}
return style;
});
const content = ref('');
watch(
() => props.modelValue,
(v: string) => {
if (v !== content.value) {
content.value = v || '<p></p>';
// 内容更新后,添加图片点击事件
nextTick(() => {
initImageClickEvent();
});
}
},
{ immediate: true }
);
// 监听内容变化,初始化图片点击事件
watch(
() => content.value,
() => {
nextTick(() => {
initImageClickEvent();
});
}
);
// 监听只读模式变化,确保在只读模式下也能预览图片
watch(
() => props.readOnly,
(newVal) => {
// 更新Quill编辑器的只读状态
if (quillEditorRef.value) {
const quill = toRaw(quillEditorRef.value).getQuill();
if (quill) {
quill.enable(!newVal);
// 如果切换到只读模式,移除焦点
if (newVal) {
quill.blur();
}
}
}
// 重新初始化图片点击事件
nextTick(() => {
initImageClickEvent();
});
},
{ immediate: true }
);
// 初始化图片点击事件
const initImageClickEvent = () => {
if (!quillEditorRef.value) return;
const quill = toRaw(quillEditorRef.value).getQuill();
if (!quill || !quill.root) return;
const editorContainer = quill.root;
const images = editorContainer.querySelectorAll('img');
// 移除之前的事件监听器(避免重复绑定)
images.forEach((img: HTMLImageElement) => {
const newImg = img.cloneNode(true) as HTMLImageElement;
img.parentNode?.replaceChild(newImg, img);
});
// 重新绑定事件
const newImages = editorContainer.querySelectorAll('img');
newImages.forEach((img: HTMLImageElement, index: number) => {
// 添加鼠标样式
img.style.cursor = 'pointer';
img.style.transition = 'opacity 0.3s';
// 确保图片可以点击(在只读模式下可能需要)
img.style.pointerEvents = 'auto';
// 添加点击事件 - 使用capture模式确保事件能捕获
const handleImageClick = (e: Event) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// 收集所有图片URL
const urlList: string[] = [];
newImages.forEach((item: HTMLImageElement) => {
if (item.src) {
urlList.push(item.src);
}
});
if (urlList.length > 0) {
imageUrlList.value = urlList;
currentImageIndex.value = index;
previewDialogVisible.value = true;
}
};
// 使用capture模式确保事件能捕获
img.addEventListener('click', handleImageClick, true);
// 添加hover效果
const handleMouseEnter = () => {
img.style.opacity = '0.8';
};
const handleMouseLeave = () => {
img.style.opacity = '1';
};
img.addEventListener('mouseenter', handleMouseEnter);
img.addEventListener('mouseleave', handleMouseLeave);
});
};
// 上一张图片
const prevImage = () => {
if (currentImageIndex.value > 0) {
currentImageIndex.value--;
}
};
// 下一张图片
const nextImage = () => {
if (currentImageIndex.value < imageUrlList.value.length - 1) {
currentImageIndex.value++;
}
};
// 图片上传成功返回图片地址
const handleUploadSuccess = (res: any) => {
// 如果上传成功
if (res.code === 200) {
// 获取富文本实例
let quill = toRaw(quillEditorRef.value).getQuill();
// 获取光标位置
let length = quill.selection.savedRange.index;
// 插入图片res为服务器返回的图片链接地址
quill.insertEmbed(length, 'image', res.data.url);
// 调整光标到最后
quill.setSelection(length + 1);
proxy?.$modal.closeLoading();
// 图片插入后,重新初始化图片点击事件
nextTick(() => {
initImageClickEvent();
});
} else {
proxy?.$modal.msgError('图片插入失败');
proxy?.$modal.closeLoading();
}
};
// 图片上传前拦截
const handleBeforeUpload = (file: any) => {
const type = ['image/jpeg', 'image/jpg', 'image/png', 'image/svg'];
const isJPG = type.includes(file.type);
//检验文件格式
if (!isJPG) {
proxy?.$modal.msgError(`图片格式错误!`);
return false;
}
// 校检文件大小
if (props.fileSize) {
const isLt = file.size / 1024 / 1024 < props.fileSize;
if (!isLt) {
proxy?.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`);
return false;
}
}
proxy?.$modal.loading('正在上传文件,请稍候...');
return true;
};
// 图片失败拦截
const handleUploadError = (err: any) => {
proxy?.$modal.msgError('上传文件失败');
};
// 组件挂载后初始化图片点击事件
onMounted(() => {
nextTick(() => {
initImageClickEvent();
// 如果已经是只读模式,确保图片可以点击并且移除焦点
if (props.readOnly && quillEditorRef.value) {
const quill = toRaw(quillEditorRef.value).getQuill();
if (quill) {
// 确保只读模式下图片事件不被阻止
const editorContainer = quill.root;
if (editorContainer) {
editorContainer.style.pointerEvents = 'auto';
}
// 移除焦点
quill.blur();
}
}
});
});
</script>
<style>
.editor-img-uploader {
display: none;
}
.editor,
.ql-toolbar {
white-space: pre-wrap !important;
line-height: normal !important;
}
.quill-img {
display: none;
}
.ql-snow .ql-tooltip[data-mode='link']::before {
content: '请输入链接地址:';
}
.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
border-right: 0;
content: '保存';
padding-right: 0;
}
.ql-snow .ql-tooltip[data-mode='video']::before {
content: '请输入视频地址:';
}
.ql-snow .ql-picker.ql-size .ql-picker-label::before,
.ql-snow .ql-picker.ql-size .ql-picker-item::before {
content: '14px';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='small']::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='small']::before {
content: '10px';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='large']::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='large']::before {
content: '18px';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='huge']::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='huge']::before {
content: '32px';
}
.ql-snow .ql-picker.ql-header .ql-picker-label::before,
.ql-snow .ql-picker.ql-header .ql-picker-item::before {
content: '文本';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='1']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='1']::before {
content: '标题1';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='2']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='2']::before {
content: '标题2';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='3']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='3']::before {
content: '标题3';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='4']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='4']::before {
content: '标题4';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='5']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='5']::before {
content: '标题5';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='6']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='6']::before {
content: '标题6';
}
.ql-snow .ql-picker.ql-font .ql-picker-label::before,
.ql-snow .ql-picker.ql-font .ql-picker-item::before {
content: '标准字体';
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value='serif']::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value='serif']::before {
content: '衬线字体';
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value='monospace']::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value='monospace']::before {
content: '等宽字体';
}
/* 编辑器中的图片样式 */
.editor :deep(.ql-editor img) {
cursor: pointer;
transition: opacity 0.3s;
pointer-events: auto !important; /* 确保图片可以点击 */
}
.editor :deep(.ql-editor img:hover) {
opacity: 0.8;
}
/* 只读模式下的图片样式 - 确保图片仍然可以点击 */
.editor :deep(.ql-editor.ql-disabled) {
pointer-events: none; /* 禁用整个编辑器的指针事件 */
user-select: none; /* 禁用文本选择 */
}
.editor :deep(.ql-editor.ql-disabled img) {
pointer-events: auto !important; /* 但图片可以点击 */
cursor: pointer;
}
/* 隐藏只读模式下的占位符和光标 */
.editor :deep(.ql-editor.ql-disabled::before) {
display: none !important; /* 隐藏占位符 */
}
.editor :deep(.ql-editor.ql-disabled .ql-cursor) {
display: none !important; /* 隐藏光标 */
}
.editor :deep(.ql-editor.ql-disabled *) {
caret-color: transparent !important; /* 隐藏光标 */
}
.editor :deep(.ql-editor.ql-disabled:focus) {
outline: none !important; /* 移除焦点样式 */
}
/* 图片预览容器样式 */
.image-preview-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
max-height: 80vh;
overflow: auto;
}
.image-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 100%;
flex: 1;
overflow: auto;
}
.preview-image {
max-width: 100%;
max-height: 75vh;
height: auto;
width: auto;
object-fit: contain;
display: block;
margin: 0 auto;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.image-preview-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
margin-top: 20px;
flex-shrink: 0;
}
.image-counter {
font-size: 16px;
color: #606266;
}
</style>