| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929 |
- <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="handleTextChange"
- />
- </div>
- <!-- 视频插入弹窗:网页链接 / 本地上传 二选一 -->
- <el-dialog v-model="videoDialog.visible" title="插入视频" width="520px" append-to-body>
- <el-radio-group v-model="videoDialog.mode">
- <el-radio value="url">网页链接</el-radio>
- <el-radio value="upload">本地上传</el-radio>
- </el-radio-group>
- <div v-if="videoDialog.mode === 'url'" style="margin-top: 12px">
- <el-input v-model="videoDialog.url" placeholder="请输入视频地址(Embed URL)" clearable />
- </div>
- <div v-else style="margin-top: 12px">
- <el-upload
- :action="upload.url"
- :headers="upload.headers"
- name="file"
- accept="video/*"
- :show-file-list="false"
- :before-upload="handleVideoBeforeUpload"
- :on-success="handleVideoUploadSuccess"
- :on-error="handleUploadError"
- >
- <el-button type="primary">选择视频文件</el-button>
- </el-upload>
- <div v-if="videoDialog.uploadedUrl" style="margin-top: 8px; color: #67c23a">已上传:{{ videoDialog.uploadedName }}</div>
- </div>
- <template #footer>
- <el-button @click="videoDialog.visible = false">取 消</el-button>
- <el-button type="primary" @click="confirmInsertVideo">确 定</el-button>
- </template>
- </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';
- // 富文本统一输出内联样式:Quill默认输出class名(如ql-size-huge、ql-align-center),
- // 只有加载了Quill样式表的页面才能渲染,APP端渲染器不认识会导致字体大小/对齐丢失
- const SizeAttributor = Quill.import('attributors/style/size') as any;
- SizeAttributor.whitelist = ['10px', '12px', '14px', '16px', '18px', '20px', '24px', '32px'];
- Quill.register(SizeAttributor, true);
- Quill.register(Quill.import('attributors/style/align') as any, true);
- Quill.register(Quill.import('attributors/style/direction') as any, true);
- // 缩进改为"行首不间断空格"(首行缩进):曾用内联padding-left,但APP端渲染器把
- // padding-left作用到整段(所有行整体平移),不符合中文首行缩进观感。
- // U+00A0任何渲染器都不折叠、且只影响首行,该方案APP端零改动。
- // 缩进按钮不再走Quill的indent格式(会输出ql-indent class),实现见toolbar handlers.indent
- const INDENT_SPACES = 2; // 每级缩进的不间断空格数(即首行缩进2字符)
- const INDENT_MAX = 8; // 缩进层级上限
- const NBSP = '\u00a0';
- // 增加/减少缩进:对选区每一行行首插入或移除一级不间断空格(首行缩进,APP端原样显示)
- function applyIndent(direction: string) {
- const quill = toRaw(quillEditorRef.value)?.getQuill();
- if (!quill) return;
- const range = quill.getSelection();
- if (!range) return;
- const lines = quill.getLines(range);
- if (!lines || !lines.length) return;
- // 从后往前处理:前行插入/删除文本会让后行的下标移位
- for (let i = lines.length - 1; i >= 0; i--) {
- const idx = (lines[i] as any).offset(quill.scroll);
- const head = quill.getText(idx, INDENT_MAX * INDENT_SPACES);
- let level = 0;
- while (level < INDENT_MAX && head.startsWith(NBSP.repeat((level + 1) * INDENT_SPACES))) level++;
- if (direction === '+1') {
- if (level < INDENT_MAX) quill.insertText(idx, NBSP.repeat(INDENT_SPACES), 'user');
- } else if (level > 0) {
- quill.deleteText(idx, INDENT_SPACES, 'user');
- } else if (head.startsWith(NBSP)) {
- // 行首是单个不间断空格(手工敲的)时也允许退掉
- quill.deleteText(idx, 1, 'user');
- }
- }
- }
- // 视频用 <video> 标签插入:Quill默认video格式是iframe嵌入,APP端渲染器对iframe兼容差;
- // video标签在WebView原生可播、flutter_html等组件渲染也可扩展支持,兼容性严格优于iframe
- const BlockEmbed = Quill.import('blots/block/embed') as any;
- class VideoTagBlot extends BlockEmbed {
- static blotName = 'videoTag';
- static tagName = 'video';
- static create(value: string) {
- const node = super.create(value);
- node.setAttribute('src', value);
- node.setAttribute('controls', 'controls');
- node.setAttribute('preload', 'metadata');
- node.setAttribute('playsinline', 'true');
- node.setAttribute('style', 'max-width:100%;');
- return node;
- }
- static value(node: HTMLElement) {
- return node.getAttribute('src');
- }
- }
- Quill.register(VideoTagBlot, true);
- // 修复列表符号:Quill实际DOM为 ol > li[data-list=bullet/checked/unchecked],
- // 而vue-quill自带样式表是旧版规则(圆点只给 ul > li::before,ol li:before 统一走数字计数器),
- // 导致无序列表显示成数字编号。这里按 data-list 重写 li::before 的 content。
- // 注意:符号只能加在 li::before(旧样式表已为它配好宽度/边距/对齐),
- // 不能同时加到 li > .ql-ui::before,否则会出现两个圆点且后者紧贴文字
- // 用JS注入<style>而非SFC样式块:模块加载即生效,不依赖样式热更新通道
- const LIST_FIX_CSS = `
- .editor .ql-editor li[data-list='bullet']::before { content: '\\2022' !important; }
- .editor .ql-editor li[data-list='checked']::before { content: '\\2611' !important; color: #777; }
- .editor .ql-editor li[data-list='unchecked']::before { content: '\\2610' !important; color: #777; }
- .editor .ql-editor li[data-list='bullet'],
- .editor .ql-editor li[data-list='checked'],
- .editor .ql-editor li[data-list='unchecked'] { counter-increment: none !important; }
- `;
- const listFixStyle = document.getElementById('ql-list-fix-css') as HTMLStyleElement | null;
- if (listFixStyle) {
- listFixStyle.textContent = LIST_FIX_CSS;
- } else {
- const s = document.createElement('style');
- s.id = 'ql-list-fix-css';
- s.textContent = LIST_FIX_CSS;
- document.head.appendChild(s);
- }
- // 工具栏悬停提示:选择器 -> 提示文案(原生title,鼠标悬停停顿后显示)
- const TOOLBAR_TIPS: [string, string][] = [
- ['button.ql-bold', '加粗:选中文字加粗'],
- ['button.ql-italic', '斜体:选中文字变斜体'],
- ['button.ql-underline', '下划线:选中文字加下划线'],
- ['button.ql-strike', '删除线:选中文字加删除线'],
- ['button.ql-blockquote', '引用:选中段落设为引用'],
- ['button.ql-code-block', '代码块:选中内容设为代码块'],
- ['button.ql-list[value="ordered"]', '有序列表:选中内容加数字编号'],
- ['button.ql-list[value="bullet"]', '无序列表:选中内容加圆点符号'],
- ['button.ql-indent[value="-1"]', '减少缩进:减少段落首行缩进(APP端同步生效)'],
- ['button.ql-indent[value="+1"]', '增加缩进:段落首行缩进2字符(APP端同步生效)'],
- ['.ql-picker.ql-size .ql-picker-label', '字体大小:设置选中文字的字号'],
- ['.ql-picker.ql-header .ql-picker-label', '标题:切换段落标题级别'],
- ['.ql-picker.ql-color .ql-picker-label', '字体颜色:设置选中文字的颜色'],
- ['.ql-picker.ql-background .ql-picker-label', '背景颜色:设置选中文字的背景色'],
- ['.ql-picker.ql-align .ql-picker-label', '对齐方式:设置段落对齐'],
- ['.ql-align .ql-picker-item:not([data-value])', '左对齐(默认)'],
- ['.ql-align .ql-picker-item[data-value="center"]', '居中对齐:段落居中(居中请用此按钮)'],
- ['.ql-align .ql-picker-item[data-value="right"]', '右对齐:段落靠右'],
- ['.ql-align .ql-picker-item[data-value="justify"]', '两端对齐:段落两端对齐'],
- ['button.ql-clean', '清除格式:移除选中内容的所有格式'],
- ['button.ql-link', '链接:插入超链接'],
- ['button.ql-image', '图片:上传并插入图片'],
- ['button.ql-video', '视频:网页链接或本地上传插入视频']
- ];
- /** 工具栏悬停功能提示:自定义气泡,不依赖原生title,也不依赖编辑器挂载时机 */
- const TIP_DELAY = 400; // 悬停停顿多久后显示(毫秒)
- let tipEl: HTMLDivElement | null = null;
- let tipTimer: any = null;
- function getTipEl() {
- if (!tipEl) {
- tipEl = document.createElement('div');
- tipEl.style.cssText =
- 'position:fixed;z-index:99999;padding:6px 10px;background:rgba(48,49,51,.95);color:#fff;' +
- 'font-size:12px;line-height:1.5;border-radius:4px;pointer-events:none;white-space:nowrap;' +
- 'transform:translate(-50%,-100%);display:none;';
- document.body.appendChild(tipEl);
- }
- return tipEl;
- }
- function hideTip() {
- if (tipTimer) {
- clearTimeout(tipTimer);
- tipTimer = null;
- }
- if (tipEl) tipEl.style.display = 'none';
- }
- function bindToolbarTips() {
- // 挂window上防HMR/多实例重复绑定
- if ((window as any).__qlToolbarTipsBound) return;
- (window as any).__qlToolbarTipsBound = true;
- document.addEventListener('mouseover', (e: MouseEvent) => {
- const target = e.target as HTMLElement | null;
- if (!target || !target.closest) return;
- if (!target.closest('.ql-toolbar')) {
- hideTip();
- return;
- }
- const hit = TOOLBAR_TIPS.find(([sel]) => target.closest(sel));
- if (!hit) {
- hideTip();
- return;
- }
- const anchor = target.closest(hit[0]) as HTMLElement;
- if (tipTimer) clearTimeout(tipTimer);
- tipTimer = setTimeout(() => {
- const el = getTipEl();
- el.textContent = hit[1];
- const rect = anchor.getBoundingClientRect();
- el.style.left = `${rect.left + rect.width / 2}px`;
- el.style.top = `${rect.top - 6}px`;
- el.style.display = 'block';
- }, TIP_DELAY);
- });
- document.addEventListener('mouseout', hideTip);
- document.addEventListener('mousedown', hideTip, true);
- window.addEventListener('scroll', hideTip, true);
- }
- bindToolbarTips();
- const emit = 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>();
- // 点击图片按钮时记录插入位置(上传弹窗关闭后光标可能丢失)
- let pendingImageIndex: number | null = null;
- // 点击视频按钮时记录插入位置,并维护视频弹窗状态
- let pendingVideoIndex: number | null = null;
- const videoDialog = reactive({
- visible: false,
- mode: 'url' as 'url' | 'upload',
- url: '',
- uploadedUrl: '',
- uploadedName: ''
- });
- const options = ref<any>({
- theme: 'snow',
- bounds: document.body,
- debug: 'warn',
- modules: {
- // 工具栏配置
- toolbar: {
- container: [
- ['bold', 'italic', 'underline', 'strike'], // 加粗 斜体 下划线 删除线
- ['blockquote', 'code-block'], // 引用 代码块
- [{ list: 'ordered' }, { list: 'bullet' }], // 有序、无序列表
- [{ indent: '-1' }, { indent: '+1' }], // 缩进
- [{ size: ['10px', false, '18px', '32px'] }], // 字体大小(内联样式模式)
- [{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题
- [{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色
- [{ align: [] }], // 对齐方式
- ['clean'], // 清除文本格式
- ['link', 'image', 'video'] // 链接、图片、视频
- ],
- handlers: {
- image: (value: boolean) => {
- if (value) {
- // 先记录插入位置,避免上传弹窗关闭后光标丢失
- const quill = toRaw(quillEditorRef.value)?.getQuill();
- const range = quill?.getSelection();
- pendingImageIndex = range ? range.index : Math.max((quill?.getLength() ?? 1) - 1, 0);
- // 调用element图片上传
- uploadRef.value.click();
- } else {
- Quill.format('image', true);
- }
- },
- // 视频弹窗:网页链接或本地上传。Quill自带tooltip是绝对定位,会飘出编辑器压住下面表单,
- // 且编辑器未聚焦时第一次点击没反应
- video: (value: boolean) => {
- if (!value) return;
- const quill = toRaw(quillEditorRef.value)?.getQuill();
- if (!quill) return;
- const range = quill.getSelection();
- pendingVideoIndex = range ? range.index : Math.max(quill.getLength() - 1, 0);
- videoDialog.url = '';
- videoDialog.uploadedUrl = '';
- videoDialog.uploadedName = '';
- videoDialog.mode = 'url';
- videoDialog.visible = true;
- },
- // 缩进按钮:行首不间断空格实现首行缩进,不用Quill的indent格式(会输出ql-indent class,APP不认识)
- indent: (value: string) => {
- applyIndent(value);
- }
- }
- },
- clipboard: {
- matchVisual: false, // 关闭视觉换行
- matchers: [] // 清空所有匹配器
- }
- },
- placeholder: '请输入内容',
- readOnly: props.readOnly
- });
- const styles = computed(() => {
- const style: any = {};
- if (props.minHeight) {
- style.minHeight = `${props.minHeight}px`;
- }
- if (props.height) {
- style.height = `${props.height}px`;
- }
- return style;
- });
- const content = ref('');
- // 记录最后一次发给父组件的HTML,用于识别自己输出的"回声":回声不能回写编辑器,
- // 否则敲空格时每次按键都会整篇重建内容(光标跳动、中文输入法被打断)
- let lastEmitted = '';
- watch(
- () => props.modelValue,
- (v: string) => {
- if (v === lastEmitted) return;
- // 旧数据缩进存的是Quill class(ql-indent-N)或上一版内联padding-left,统一转成行首不间断空格(首行缩进);
- // 再把手工敲的空格/Tab统一成不间断空格,保证不编辑直接保存也能在APP生效。
- // 转换在watch渲染期同步执行,一旦对畸形/超大内容抛错会连累整个Editor挂载失败、
- // 弹窗打不开(表现为点"修改"没反应),所以异常时降级为原始内容,绝不阻断渲染
- let next: string;
- try {
- next = stabilizeSpacesHtml(migrateIndentHtml(v || ''));
- } catch (e) {
- console.error('[Editor] 内容转换失败,降级为原始内容:', e);
- next = v || '';
- }
- if (next !== content.value) {
- content.value = next || '<p></p>';
- // 转换结果同步回父组件表单,避免未编辑直接保存时丢掉空格缩进;
- // 延迟到nextTick再发:setup/首帧渲染阶段同步emit改父表单,可能和弹窗首次渲染打架
- if (next) {
- lastEmitted = next;
- nextTick(() => emit('update:modelValue', next));
- }
- }
- },
- { immediate: true }
- );
- // 图片上传成功返回图片地址
- const handleUploadSuccess = (res: any) => {
- // 如果上传成功
- if (res.code === 200) {
- // 获取富文本实例
- const quill = toRaw(quillEditorRef.value).getQuill();
- // 用点击图片按钮时记录的位置,为空则退回当前光标或末尾;
- // 直接读 selection.savedRange 在编辑器未聚焦时是 null 会报错,导致第一次上传没反应
- const range = quill.getSelection();
- const index = pendingImageIndex ?? range?.index ?? Math.max(quill.getLength() - 1, 0);
- pendingImageIndex = null;
- // 插入图片,res为服务器返回的图片链接地址
- quill.insertEmbed(index, 'image', res.data.url);
- // 光标放到图片后
- quill.setSelection(index + 1);
- proxy?.$modal.closeLoading();
- } 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('上传文件失败');
- proxy?.$modal.closeLoading();
- };
- // 视频上传前拦截:类型 + 2GB上限(与后端multipart及视频内容页对齐)
- const handleVideoBeforeUpload = (file: any) => {
- const isVideo = file.type?.startsWith('video/') || /\.(mp4|mov|m4v|webm|mkv|avi)$/i.test(file.name);
- if (!isVideo) {
- proxy?.$modal.msgError('请选择视频文件!');
- return false;
- }
- const sizeMB = file.size / 1024 / 1024;
- if (sizeMB >= 2048) {
- proxy?.$modal.msgError('视频文件大小不能超过2GB');
- return false;
- }
- proxy?.$modal.loading('正在上传视频,请稍候...');
- return true;
- };
- // 视频上传成功:先只记录地址,点确定时才插入正文
- const handleVideoUploadSuccess = (res: any, file: any) => {
- proxy?.$modal.closeLoading();
- if (res.code === 200) {
- videoDialog.uploadedUrl = res.data.url;
- videoDialog.uploadedName = file?.name || '视频';
- } else {
- proxy?.$modal.msgError('视频上传失败');
- }
- };
- // 确定插入视频:按所选方式取地址,插到点按钮时记录的位置
- const confirmInsertVideo = async () => {
- const src = (videoDialog.mode === 'url' ? videoDialog.url : videoDialog.uploadedUrl).trim();
- if (!src) {
- proxy?.$modal.msgWarning(videoDialog.mode === 'url' ? '请输入视频地址' : '请先上传视频文件');
- return;
- }
- if (videoDialog.mode === 'url') {
- // <video>只认视频文件直链;抖音/哔哩等网页地址塞进去是黑屏,提前提醒
- let pathname = src;
- try {
- pathname = new URL(src).pathname;
- } catch {
- /* 不是标准URL就按原串校验 */
- }
- if (!/\.(mp4|m4v|mov|webm|mkv|avi|flv)$/i.test(pathname)) {
- try {
- await proxy?.$modal.confirm('该链接不带视频文件扩展名(.mp4等)。网页链接(抖音、哔哩等)在正文中无法播放,建议下载后本地上传。仍要插入吗?');
- } catch {
- return;
- }
- }
- }
- const quill = toRaw(quillEditorRef.value)?.getQuill();
- if (!quill) return;
- const index = pendingVideoIndex ?? Math.max(quill.getLength() - 1, 0);
- pendingVideoIndex = null;
- quill.insertEmbed(index, 'videoTag', src);
- quill.setSelection(index + 1, 0);
- videoDialog.visible = false;
- };
- import { onMounted, nextTick } from 'vue';
- import Delta from 'quill-delta';
- onMounted(async () => {
- await nextTick();
- const quill = quillEditorRef.value?.getQuill();
- if (!quill) return;
- // 修复:在Dialog中点击工具栏时防止失焦导致格式不生效
- const toolbar = quillEditorRef.value?.$el?.querySelector('.ql-toolbar');
- if (toolbar) {
- toolbar.addEventListener('mousedown', (e: Event) => {
- e.preventDefault();
- });
- }
- // 清空默认行为
- quill.clipboard.matchers = [];
- quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node: HTMLElement, delta: Delta) => {
- const tempDiv = document.createElement('div');
- tempDiv.innerHTML = node.outerHTML;
- // ✅ 只清除 background 相关样式,保留 color
- cleanBackgroundOnly(tempDiv);
- // 构建 Delta(支持格式 + 列表)
- const newDelta = new Delta();
- buildDeltaWithListSupport(tempDiv, newDelta, {}, null);
- return newDelta;
- });
- });
- // ✅ 只清除 background,保留 color/font-size 等
- function cleanBackgroundOnly(parent: HTMLElement) {
- if (parent.style) {
- parent.style.backgroundColor = '';
- parent.style.background = '';
- parent.style.color = '';
- parent.style.backgroundImage = '';
- parent.style.backgroundPosition = '';
- parent.style.backgroundRepeat = '';
- parent.style.backgroundSize = '';
- parent.style.backgroundAttachment = '';
- // ✅ 不动 color、font、size 等
- }
- Array.from(parent.children).forEach((child) => {
- if (child instanceof HTMLElement) {
- cleanBackgroundOnly(child);
- }
- });
- }
- // 手工空格缩进也要在APP生效:HTML默认折叠行首和连续空格,编辑器靠white-space:pre-wrap
- // 才能原样显示,APP端渲染器按标准HTML折叠,用户自己敲的空格就失效了。
- // 输出时把每行行首空白、行内连续空白换成不间断空格(U+00A0),任何渲染器都原样保留
- const SPACE_RISK_RE = /(^|>)[ \t]|[ \t]{2,}/;
- function stabilizeSpacesHtml(html: string): string {
- if (!html || !SPACE_RISK_RE.test(html)) return html;
- let out = html;
- // 1) 段落行首空白:中间允许隔内联开标签或已转换的不间断空格,多轮收敛
- const leadRe = /(<(?:p|div|h[1-6]|li|blockquote|pre)\b[^>]*>(?:\u00a0|<(?!br\b|img\b|video\b|\/)[^>]+>)*)([ \t]+)/g;
- for (let pass = 0; pass < 4; pass++) {
- const next = out.replace(leadRe, (_m: string, head: string, run: string) => head + '\u00a0'.repeat(run.length));
- if (next === out) break;
- out = next;
- }
- // 2) 行内连续空格与Tab:按标签切段后只改文本段,不动属性里的空格
- const parts = out.split(/(<[^>]*>)/);
- for (let i = 0; i < parts.length; i += 2) {
- if (parts[i] && /\t| {2,}/.test(parts[i])) {
- parts[i] = parts[i].replace(/\t/g, '\u00a0\u00a0\u00a0\u00a0').replace(/ {2,}/g, (m) => '\u00a0'.repeat(m.length));
- }
- }
- return parts.join('');
- }
- // 内容变化统一出口:先做"空格缩进APP化"转换再发给父组件
- function handleTextChange() {
- // 同watch:转换抛错时降级为原始内容,保证编辑/保存链路不因转换异常中断
- try {
- lastEmitted = stabilizeSpacesHtml(content.value);
- } catch (e) {
- console.error('[Editor] 内容转换失败,降级为原始内容:', e);
- lastEmitted = content.value;
- }
- emit('update:modelValue', lastEmitted);
- }
- // 历史数据兼容:旧缩进(Quill class ql-indent-N / 上一版内联padding-left)转成行首不间断空格;
- // 幂等:转换后标记被移除,再次进入不会重复叠加缩进
- function migrateIndentHtml(html: string): string {
- if (!html || (html.indexOf('ql-indent-') < 0 && html.toLowerCase().indexOf('padding-left') < 0)) return html;
- return html.replace(/<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (tag: string, name: string, attrs: string) => {
- if (!/^(?:p|div|h[1-6]|li|blockquote|pre)$/i.test(name)) return tag;
- let level = 0;
- let newAttrs = attrs;
- const cm = /\sclass\s*=\s*"([^"]*)"/i.exec(attrs) || /\sclass\s*=\s*'([^']*)'/i.exec(attrs);
- if (cm) {
- const im = /ql-indent-(\d+)/.exec(cm[1]);
- if (im) {
- level = Math.min(parseInt(im[1], 10) || 0, INDENT_MAX);
- const restClass = cm[1]
- .replace(/ql-indent-\d+/g, '')
- .replace(/\s+/g, ' ')
- .trim();
- newAttrs = newAttrs.replace(cm[0], restClass ? ` class="${restClass}"` : '');
- }
- }
- const sm = /\sstyle\s*=\s*"([^"]*)"/i.exec(newAttrs) || /\sstyle\s*=\s*'([^']*)'/i.exec(newAttrs);
- if (sm) {
- const pm = /padding-left\s*:\s*([^;"]+)/i.exec(sm[1]);
- if (pm) {
- const raw = pm[1].trim();
- const n = parseFloat(raw);
- if (n > 0) {
- const lv = Math.round(raw.toLowerCase().indexOf('px') >= 0 ? n / (INDENT_SPACES * 16) : n / INDENT_SPACES);
- level = Math.max(level, Math.min(lv, INDENT_MAX));
- }
- const restStyle = sm[1]
- .replace(/padding-left\s*:\s*[^;"]+;?/gi, '')
- .replace(/\s+/g, ' ')
- .replace(/^;+/, '')
- .trim();
- newAttrs = newAttrs.replace(sm[0], restStyle ? ` style="${restStyle}"` : '');
- }
- }
- if (level < 1) return tag;
- return `<${name}${newAttrs}>` + NBSP.repeat(level * INDENT_SPACES);
- });
- }
- // 读缩进层级:兼容内联padding-left与旧版ql-indent-N class(粘贴内容用,转成行首不间断空格)
- function readIndentLevel(el: HTMLElement): number {
- let level = 0;
- const cm = /ql-indent-(\d+)/.exec(el.getAttribute('class') || '');
- if (cm) level = parseInt(cm[1], 10) || 0;
- const pl = el.style.paddingLeft;
- if (pl) {
- const n = parseFloat(pl);
- if (n > 0) level = Math.max(level, Math.round(pl.indexOf('px') >= 0 ? n / (INDENT_SPACES * 16) : n / INDENT_SPACES));
- }
- return level;
- }
- // 插入一个带块级格式的换行符:Quill的对齐/标题/列表都是"行"格式,只认换行符上的属性
- function insertBlockBreak(delta: Delta, el: HTMLElement, listType: 'ordered' | 'bullet' | null) {
- const fmt: { [key: string]: any } = {};
- const hm = /^h([1-6])$/.exec(el.tagName.toLowerCase());
- if (hm) fmt.header = parseInt(hm[1], 10);
- if (el.style.textAlign) fmt.align = el.style.textAlign;
- if (listType) fmt.list = listType;
- delta.insert('\n', Object.keys(fmt).length ? fmt : undefined);
- }
- // ✅ 支持列表的 Delta 构建
- function buildDeltaWithListSupport(
- node: Node,
- delta: Delta,
- formatStack: { [key: string]: any },
- listType: 'ordered' | 'bullet' | null // 当前是否在列表中
- ) {
- if (node.nodeType === Node.TEXT_NODE) {
- const text = node.textContent || '';
- if (text.trim() || text === ' ') {
- delta.insert(text, formatStack);
- }
- return;
- }
- if (node.nodeType !== Node.ELEMENT_NODE) return;
- const el = node as HTMLElement;
- const tagName = el.tagName.toLowerCase();
- const currentFormat = { ...formatStack };
- let newListType: 'ordered' | 'bullet' | null = listType;
- // 处理列表开始
- if (tagName === 'ol') {
- newListType = 'ordered';
- }
- if (tagName === 'ul') {
- newListType = 'bullet';
- }
- // 处理列表项
- if (tagName === 'li') {
- if (listType) {
- // 标记为列表项
- currentFormat.list = listType;
- }
- // 如果 li 有嵌套 ol/ul,子项可能改变类型,但这里简化处理
- }
- // 添加内联格式
- if (tagName === 'strong' || tagName === 'b') {
- currentFormat.bold = true;
- }
- if (tagName === 'em' || tagName === 'i') {
- currentFormat.italic = true;
- }
- if (tagName === 'u' || el.style.textDecoration === 'underline') {
- currentFormat.underline = true;
- }
- if (tagName === 's' || tagName === 'strike') {
- currentFormat.strike = true;
- }
- // ✅ 保留颜色(关键!)
- if (el.style.color) {
- currentFormat.color = el.style.color;
- }
- if (el.style.fontSize) {
- currentFormat.size = el.style.fontSize; // 注意:Quill 的 size 是 small/large/huge 或值
- }
- if (el.style.fontFamily) {
- currentFormat.font = el.style.fontFamily.split(',')[0].trim().replace(/['"]/g, '');
- }
- // 是否是块级元素(需要换行)
- const isBlock = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'br'].includes(tagName);
- // 粘贴内容里的旧缩进(padding-left/ql-indent class)转成行首不间断空格,与编辑器缩进按钮同一方案
- if (isBlock && tagName !== 'br') {
- const indentLevel = readIndentLevel(el);
- if (indentLevel > 0) {
- delta.insert(NBSP.repeat(indentLevel * INDENT_SPACES));
- }
- }
- // 遍历子节点
- for (let i = 0; i < el.childNodes.length; i++) {
- const child = el.childNodes[i];
- // <br>就是硬换行:直接换成带块级格式的换行符(Quill行内没有br)
- if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName === 'BR' && isBlock) {
- insertBlockBreak(delta, el, listType);
- continue;
- }
- buildDeltaWithListSupport(child, delta, currentFormat, newListType);
- // 块级元素结尾的换行承载该段的块级格式(缩进/对齐/标题/列表);
- // 开头不再补换行:结尾换行已起到分段作用,再补会把段间距翻倍
- if (isBlock && i === el.childNodes.length - 1 && (el.nextSibling || el.parentNode !== el.ownerDocument?.body)) {
- if (!delta.ops.length || delta.ops[delta.ops.length - 1].insert !== '\n') {
- insertBlockBreak(delta, el, listType);
- }
- }
- }
- }
- /*
- onMounted(async () => {
- await nextTick();
- const quill = quillEditorRef.value?.getQuill();
- if (!quill) return;
- // ✅ 1. 移除所有默认的 matcher,避免干扰
- quill.clipboard.matchers = [];
- // ✅ 2. 添加自定义 matcher:对所有元素节点,只提取 innerText
- quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node: HTMLElement, delta: Delta) => {
- // 获取纯文本
- const text = node.innerText || node.textContent || '';
- // 返回一个新的 Delta,只包含纯文本,无任何格式
- return new Delta().insert(text);
- });
- // ✅ 3. 特别处理根节点是纯文本的情况(比如从记事本复制)
- quill.clipboard.addMatcher(Node.TEXT_NODE, (node: Text, delta: Delta) => {
- return new Delta().insert(node.data || '');
- });
- // ✅ 4. 可选:如果你希望保留换行,可以不做处理,Quill 会自动处理 p/br
- // 如果你想更激进地清理,也可以在这里统一处理
- console.log('[Clipboard] Custom matcher set, only plain text will be pasted.');
- });
- import { onMounted } from 'vue';
- onMounted(() => {
- const quill = quillEditorRef.value.getQuill();
- debugger;
- // ✅ 2. 手动监听 paste 事件,完全由你控制
- quill.root.addEventListener('paste', async (e) => {
- e.preventDefault(); // ✅ 阻止浏览器默认行为
- const clipboardData = e.clipboardData || (e as any).originalEvent.clipboardData;
- const html = clipboardData.getData('text/html');
- const text = clipboardData.getData('text/plain');
- // 使用 HTML 优先,否则用纯文本
- const tempDiv = document.createElement('div');
- tempDiv.innerHTML = html || text;
- // 清理所有样式和 class
- const walk = (node: Node) => {
- if (node.nodeType === Node.ELEMENT_NODE) {
- const el = node as HTMLElement;
- el.style.cssText = '';
- el.removeAttribute('class');
- el.removeAttribute('style');
- // 特别清理 span 的内联样式
- if (el.tagName === 'SPAN') {
- el.style.color = '';
- el.style.backgroundColor = '';
- el.style.fontWeight = '';
- el.style.fontStyle = '';
- el.style.textDecoration = '';
- }
- Array.from(el.children).forEach((child) => walk(child));
- }
- };
- Array.from(tempDiv.childNodes).forEach(walk);
- const cleanHtml = tempDiv.innerHTML;
- const range = quill.getSelection();
- const index = range ? range.index : 0;
- // ✅ 调试:打印关键信息
- console.log('[Paste Debug]', { html, text, cleanHtml, index });
- // ✅ 插入清理后的内容
- quill.clipboard.dangerouslyPasteHTML(index, cleanHtml);
- // 可选:将光标移到末尾
- // setTimeout(() => {
- // const length = quill.getLength();
- // quill.setSelection(length, 0);
- // }, 10);
- });
- });*/
- </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-size .ql-picker-label[data-value='10px']::before,
- .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='10px']::before {
- content: '10px';
- }
- .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='18px']::before,
- .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='18px']::before {
- content: '18px';
- }
- .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='32px']::before,
- .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='32px']::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 .ql-editor {
- white-space: pre-wrap; /* 强制换行 */
- word-break: break-all; /* 防止长单词或URL溢出 */
- }
- .editor .ql-editor img,
- .editor .ql-editor video {
- max-width: 100%; /* 自适应编辑器宽度,不溢出 */
- }
- .editor .ql-editor video {
- margin: 8px 0;
- }
- </style>
|