index.vue 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899
  1. <template>
  2. <div>
  3. <el-upload
  4. v-if="type === 'url'"
  5. :action="upload.url"
  6. :before-upload="handleBeforeUpload"
  7. :on-success="handleUploadSuccess"
  8. :on-error="handleUploadError"
  9. class="editor-img-uploader"
  10. name="file"
  11. :show-file-list="false"
  12. :headers="upload.headers"
  13. >
  14. <i ref="uploadRef"></i>
  15. </el-upload>
  16. </div>
  17. <div class="editor">
  18. <quill-editor
  19. ref="quillEditorRef"
  20. v-model:content="content"
  21. content-type="html"
  22. :options="options"
  23. :style="styles"
  24. @text-change="handleTextChange"
  25. />
  26. </div>
  27. <!-- 视频插入弹窗:网页链接 / 本地上传 二选一 -->
  28. <el-dialog v-model="videoDialog.visible" title="插入视频" width="520px" append-to-body>
  29. <el-radio-group v-model="videoDialog.mode">
  30. <el-radio value="url">网页链接</el-radio>
  31. <el-radio value="upload">本地上传</el-radio>
  32. </el-radio-group>
  33. <div v-if="videoDialog.mode === 'url'" style="margin-top: 12px">
  34. <el-input v-model="videoDialog.url" placeholder="请输入视频地址(Embed URL)" clearable />
  35. </div>
  36. <div v-else style="margin-top: 12px">
  37. <el-upload
  38. :action="upload.url"
  39. :headers="upload.headers"
  40. name="file"
  41. accept="video/*"
  42. :show-file-list="false"
  43. :before-upload="handleVideoBeforeUpload"
  44. :on-success="handleVideoUploadSuccess"
  45. :on-error="handleUploadError"
  46. >
  47. <el-button type="primary">选择视频文件</el-button>
  48. </el-upload>
  49. <div v-if="videoDialog.uploadedUrl" style="margin-top: 8px; color: #67c23a">已上传:{{ videoDialog.uploadedName }}</div>
  50. </div>
  51. <template #footer>
  52. <el-button @click="videoDialog.visible = false">取 消</el-button>
  53. <el-button type="primary" @click="confirmInsertVideo">确 定</el-button>
  54. </template>
  55. </el-dialog>
  56. </template>
  57. <script setup lang="ts">
  58. import '@vueup/vue-quill/dist/vue-quill.snow.css';
  59. import { QuillEditor, Quill } from '@vueup/vue-quill';
  60. import { propTypes } from '@/utils/propTypes';
  61. import { globalHeaders } from '@/utils/request';
  62. // 富文本统一输出内联样式:Quill默认输出class名(如ql-size-huge、ql-align-center),
  63. // 只有加载了Quill样式表的页面才能渲染,APP端渲染器不认识会导致字体大小/对齐丢失
  64. const SizeAttributor = Quill.import('attributors/style/size') as any;
  65. SizeAttributor.whitelist = ['10px', '12px', '14px', '16px', '18px', '20px', '24px', '32px'];
  66. Quill.register(SizeAttributor, true);
  67. Quill.register(Quill.import('attributors/style/align') as any, true);
  68. Quill.register(Quill.import('attributors/style/direction') as any, true);
  69. // 缩进改为内联样式:Quill默认缩进输出class(ql-indent-N),APP端渲染器只认内联样式,
  70. // 后台加的缩进到APP上会全部丢失(正文贴边、无法自行调整边距)。
  71. // 这里用padding-left承载缩进层级,编辑器与APP看到的效果一致
  72. const INDENT_EM = 2; // 每级缩进对应的em数
  73. const INDENT_MAX = 8; // 与Quill默认缩进层级上限保持一致
  74. const StyleAttributorBase: any = (Quill.import('parchment') as any).StyleAttributor;
  75. class IndentStyleAttributor extends StyleAttributorBase {
  76. add(node: HTMLElement, value: any) {
  77. // 工具栏按钮和Tab键传的是'+1'/'-1',先换算成绝对层级
  78. if (value === '+1' || value === '-1') {
  79. const cur = this.value(node) || 0;
  80. value = value === '+1' ? cur + 1 : cur - 1;
  81. }
  82. const level = Math.min(Math.max(parseInt(value, 10) || 0, 0), INDENT_MAX);
  83. if (level <= 0) return this.remove(node);
  84. node.style.paddingLeft = `${level * INDENT_EM}em`;
  85. return true;
  86. }
  87. value(node: HTMLElement) {
  88. const raw = node.style ? node.style.paddingLeft : '';
  89. if (!raw) return undefined;
  90. const n = parseFloat(raw);
  91. if (!n || n <= 0) return undefined;
  92. const level = Math.round(raw.indexOf('px') >= 0 ? n / (INDENT_EM * 16) : n / INDENT_EM);
  93. return level > 0 ? level : undefined;
  94. }
  95. remove(node: HTMLElement) {
  96. node.style.paddingLeft = '';
  97. return true;
  98. }
  99. }
  100. Quill.register(new IndentStyleAttributor('indent', 'padding-left', { scope: (Quill.import('parchment') as any).Scope.BLOCK }), true);
  101. // 视频用 <video> 标签插入:Quill默认video格式是iframe嵌入,APP端渲染器对iframe兼容差;
  102. // video标签在WebView原生可播、flutter_html等组件渲染也可扩展支持,兼容性严格优于iframe
  103. const BlockEmbed = Quill.import('blots/block/embed') as any;
  104. class VideoTagBlot extends BlockEmbed {
  105. static blotName = 'videoTag';
  106. static tagName = 'video';
  107. static create(value: string) {
  108. const node = super.create(value);
  109. node.setAttribute('src', value);
  110. node.setAttribute('controls', 'controls');
  111. node.setAttribute('preload', 'metadata');
  112. node.setAttribute('playsinline', 'true');
  113. node.setAttribute('style', 'max-width:100%;');
  114. return node;
  115. }
  116. static value(node: HTMLElement) {
  117. return node.getAttribute('src');
  118. }
  119. }
  120. Quill.register(VideoTagBlot, true);
  121. // 修复列表符号:Quill实际DOM为 ol > li[data-list=bullet/checked/unchecked],
  122. // 而vue-quill自带样式表是旧版规则(圆点只给 ul > li::before,ol li:before 统一走数字计数器),
  123. // 导致无序列表显示成数字编号。这里按 data-list 重写 li::before 的 content。
  124. // 注意:符号只能加在 li::before(旧样式表已为它配好宽度/边距/对齐),
  125. // 不能同时加到 li > .ql-ui::before,否则会出现两个圆点且后者紧贴文字
  126. // 用JS注入<style>而非SFC样式块:模块加载即生效,不依赖样式热更新通道
  127. const LIST_FIX_CSS = `
  128. .editor .ql-editor li[data-list='bullet']::before { content: '\\2022' !important; }
  129. .editor .ql-editor li[data-list='checked']::before { content: '\\2611' !important; color: #777; }
  130. .editor .ql-editor li[data-list='unchecked']::before { content: '\\2610' !important; color: #777; }
  131. .editor .ql-editor li[data-list='bullet'],
  132. .editor .ql-editor li[data-list='checked'],
  133. .editor .ql-editor li[data-list='unchecked'] { counter-increment: none !important; }
  134. `;
  135. const listFixStyle = document.getElementById('ql-list-fix-css') as HTMLStyleElement | null;
  136. if (listFixStyle) {
  137. listFixStyle.textContent = LIST_FIX_CSS;
  138. } else {
  139. const s = document.createElement('style');
  140. s.id = 'ql-list-fix-css';
  141. s.textContent = LIST_FIX_CSS;
  142. document.head.appendChild(s);
  143. }
  144. // 工具栏悬停提示:选择器 -> 提示文案(原生title,鼠标悬停停顿后显示)
  145. const TOOLBAR_TIPS: [string, string][] = [
  146. ['button.ql-bold', '加粗:选中文字加粗'],
  147. ['button.ql-italic', '斜体:选中文字变斜体'],
  148. ['button.ql-underline', '下划线:选中文字加下划线'],
  149. ['button.ql-strike', '删除线:选中文字加删除线'],
  150. ['button.ql-blockquote', '引用:选中段落设为引用'],
  151. ['button.ql-code-block', '代码块:选中内容设为代码块'],
  152. ['button.ql-list[value="ordered"]', '有序列表:选中内容加数字编号'],
  153. ['button.ql-list[value="bullet"]', '无序列表:选中内容加圆点符号'],
  154. ['button.ql-indent[value="-1"]', '减少缩进:降低段落缩进层级(APP端同步生效)'],
  155. ['button.ql-indent[value="+1"]', '增加缩进:提高段落缩进层级(APP端同步生效)'],
  156. ['.ql-picker.ql-size .ql-picker-label', '字体大小:设置选中文字的字号'],
  157. ['.ql-picker.ql-header .ql-picker-label', '标题:切换段落标题级别'],
  158. ['.ql-picker.ql-color .ql-picker-label', '字体颜色:设置选中文字的颜色'],
  159. ['.ql-picker.ql-background .ql-picker-label', '背景颜色:设置选中文字的背景色'],
  160. ['.ql-picker.ql-align .ql-picker-label', '对齐方式:设置段落对齐'],
  161. ['.ql-align .ql-picker-item:not([data-value])', '左对齐(默认)'],
  162. ['.ql-align .ql-picker-item[data-value="center"]', '居中对齐:段落居中(居中请用此按钮)'],
  163. ['.ql-align .ql-picker-item[data-value="right"]', '右对齐:段落靠右'],
  164. ['.ql-align .ql-picker-item[data-value="justify"]', '两端对齐:段落两端对齐'],
  165. ['button.ql-clean', '清除格式:移除选中内容的所有格式'],
  166. ['button.ql-link', '链接:插入超链接'],
  167. ['button.ql-image', '图片:上传并插入图片'],
  168. ['button.ql-video', '视频:网页链接或本地上传插入视频']
  169. ];
  170. /** 工具栏悬停功能提示:自定义气泡,不依赖原生title,也不依赖编辑器挂载时机 */
  171. const TIP_DELAY = 400; // 悬停停顿多久后显示(毫秒)
  172. let tipEl: HTMLDivElement | null = null;
  173. let tipTimer: any = null;
  174. function getTipEl() {
  175. if (!tipEl) {
  176. tipEl = document.createElement('div');
  177. tipEl.style.cssText =
  178. 'position:fixed;z-index:99999;padding:6px 10px;background:rgba(48,49,51,.95);color:#fff;' +
  179. 'font-size:12px;line-height:1.5;border-radius:4px;pointer-events:none;white-space:nowrap;' +
  180. 'transform:translate(-50%,-100%);display:none;';
  181. document.body.appendChild(tipEl);
  182. }
  183. return tipEl;
  184. }
  185. function hideTip() {
  186. if (tipTimer) {
  187. clearTimeout(tipTimer);
  188. tipTimer = null;
  189. }
  190. if (tipEl) tipEl.style.display = 'none';
  191. }
  192. function bindToolbarTips() {
  193. // 挂window上防HMR/多实例重复绑定
  194. if ((window as any).__qlToolbarTipsBound) return;
  195. (window as any).__qlToolbarTipsBound = true;
  196. document.addEventListener('mouseover', (e: MouseEvent) => {
  197. const target = e.target as HTMLElement | null;
  198. if (!target || !target.closest) return;
  199. if (!target.closest('.ql-toolbar')) {
  200. hideTip();
  201. return;
  202. }
  203. const hit = TOOLBAR_TIPS.find(([sel]) => target.closest(sel));
  204. if (!hit) {
  205. hideTip();
  206. return;
  207. }
  208. const anchor = target.closest(hit[0]) as HTMLElement;
  209. if (tipTimer) clearTimeout(tipTimer);
  210. tipTimer = setTimeout(() => {
  211. const el = getTipEl();
  212. el.textContent = hit[1];
  213. const rect = anchor.getBoundingClientRect();
  214. el.style.left = `${rect.left + rect.width / 2}px`;
  215. el.style.top = `${rect.top - 6}px`;
  216. el.style.display = 'block';
  217. }, TIP_DELAY);
  218. });
  219. document.addEventListener('mouseout', hideTip);
  220. document.addEventListener('mousedown', hideTip, true);
  221. window.addEventListener('scroll', hideTip, true);
  222. }
  223. bindToolbarTips();
  224. const emit = defineEmits(['update:modelValue']);
  225. const props = defineProps({
  226. /* 编辑器的内容 */
  227. modelValue: propTypes.string,
  228. /* 高度 */
  229. height: propTypes.number.def(400),
  230. /* 最小高度 */
  231. minHeight: propTypes.number.def(400),
  232. /* 只读 */
  233. readOnly: propTypes.bool.def(false),
  234. /* 上传文件大小限制(MB) */
  235. fileSize: propTypes.number.def(5),
  236. /* 类型(base64格式、url格式) */
  237. type: propTypes.string.def('url')
  238. });
  239. const { proxy } = getCurrentInstance() as ComponentInternalInstance;
  240. const upload = reactive<UploadOption>({
  241. headers: globalHeaders(),
  242. url: import.meta.env.VITE_APP_BASE_API + '/resource/oss/upload'
  243. });
  244. const quillEditorRef = ref();
  245. const uploadRef = ref<HTMLDivElement>();
  246. // 点击图片按钮时记录插入位置(上传弹窗关闭后光标可能丢失)
  247. let pendingImageIndex: number | null = null;
  248. // 点击视频按钮时记录插入位置,并维护视频弹窗状态
  249. let pendingVideoIndex: number | null = null;
  250. const videoDialog = reactive({
  251. visible: false,
  252. mode: 'url' as 'url' | 'upload',
  253. url: '',
  254. uploadedUrl: '',
  255. uploadedName: ''
  256. });
  257. const options = ref<any>({
  258. theme: 'snow',
  259. bounds: document.body,
  260. debug: 'warn',
  261. modules: {
  262. // 工具栏配置
  263. toolbar: {
  264. container: [
  265. ['bold', 'italic', 'underline', 'strike'], // 加粗 斜体 下划线 删除线
  266. ['blockquote', 'code-block'], // 引用 代码块
  267. [{ list: 'ordered' }, { list: 'bullet' }], // 有序、无序列表
  268. [{ indent: '-1' }, { indent: '+1' }], // 缩进
  269. [{ size: ['10px', false, '18px', '32px'] }], // 字体大小(内联样式模式)
  270. [{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题
  271. [{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色
  272. [{ align: [] }], // 对齐方式
  273. ['clean'], // 清除文本格式
  274. ['link', 'image', 'video'] // 链接、图片、视频
  275. ],
  276. handlers: {
  277. image: (value: boolean) => {
  278. if (value) {
  279. // 先记录插入位置,避免上传弹窗关闭后光标丢失
  280. const quill = toRaw(quillEditorRef.value)?.getQuill();
  281. const range = quill?.getSelection();
  282. pendingImageIndex = range ? range.index : Math.max((quill?.getLength() ?? 1) - 1, 0);
  283. // 调用element图片上传
  284. uploadRef.value.click();
  285. } else {
  286. Quill.format('image', true);
  287. }
  288. },
  289. // 视频弹窗:网页链接或本地上传。Quill自带tooltip是绝对定位,会飘出编辑器压住下面表单,
  290. // 且编辑器未聚焦时第一次点击没反应
  291. video: (value: boolean) => {
  292. if (!value) return;
  293. const quill = toRaw(quillEditorRef.value)?.getQuill();
  294. if (!quill) return;
  295. const range = quill.getSelection();
  296. pendingVideoIndex = range ? range.index : Math.max(quill.getLength() - 1, 0);
  297. videoDialog.url = '';
  298. videoDialog.uploadedUrl = '';
  299. videoDialog.uploadedName = '';
  300. videoDialog.mode = 'url';
  301. videoDialog.visible = true;
  302. }
  303. }
  304. },
  305. clipboard: {
  306. matchVisual: false, // 关闭视觉换行
  307. matchers: [] // 清空所有匹配器
  308. }
  309. },
  310. placeholder: '请输入内容',
  311. readOnly: props.readOnly
  312. });
  313. const styles = computed(() => {
  314. const style: any = {};
  315. if (props.minHeight) {
  316. style.minHeight = `${props.minHeight}px`;
  317. }
  318. if (props.height) {
  319. style.height = `${props.height}px`;
  320. }
  321. return style;
  322. });
  323. const content = ref('');
  324. // 记录最后一次发给父组件的HTML,用于识别自己输出的"回声":回声不能回写编辑器,
  325. // 否则敲空格时每次按键都会整篇重建内容(光标跳动、中文输入法被打断)
  326. let lastEmitted = '';
  327. watch(
  328. () => props.modelValue,
  329. (v: string) => {
  330. if (v === lastEmitted) return;
  331. // 旧数据缩进存的是Quill class(ql-indent-N),先转成内联padding-left;
  332. // 再把手工敲的空格/Tab统一成不间断空格,保证不编辑直接保存也能在APP生效。
  333. // 转换在watch渲染期同步执行,一旦对畸形/超大内容抛错会连累整个Editor挂载失败、
  334. // 弹窗打不开(表现为点"修改"没反应),所以异常时降级为原始内容,绝不阻断渲染
  335. let next: string;
  336. try {
  337. next = stabilizeSpacesHtml(migrateIndentHtml(v || ''));
  338. } catch (e) {
  339. console.error('[Editor] 内容转换失败,降级为原始内容:', e);
  340. next = v || '';
  341. }
  342. if (next !== content.value) {
  343. content.value = next || '<p></p>';
  344. // 转换结果同步回父组件表单,避免未编辑直接保存时丢掉空格缩进;
  345. // 延迟到nextTick再发:setup/首帧渲染阶段同步emit改父表单,可能和弹窗首次渲染打架
  346. if (next) {
  347. lastEmitted = next;
  348. nextTick(() => emit('update:modelValue', next));
  349. }
  350. }
  351. },
  352. { immediate: true }
  353. );
  354. // 图片上传成功返回图片地址
  355. const handleUploadSuccess = (res: any) => {
  356. // 如果上传成功
  357. if (res.code === 200) {
  358. // 获取富文本实例
  359. const quill = toRaw(quillEditorRef.value).getQuill();
  360. // 用点击图片按钮时记录的位置,为空则退回当前光标或末尾;
  361. // 直接读 selection.savedRange 在编辑器未聚焦时是 null 会报错,导致第一次上传没反应
  362. const range = quill.getSelection();
  363. const index = pendingImageIndex ?? range?.index ?? Math.max(quill.getLength() - 1, 0);
  364. pendingImageIndex = null;
  365. // 插入图片,res为服务器返回的图片链接地址
  366. quill.insertEmbed(index, 'image', res.data.url);
  367. // 光标放到图片后
  368. quill.setSelection(index + 1);
  369. proxy?.$modal.closeLoading();
  370. } else {
  371. proxy?.$modal.msgError('图片插入失败');
  372. proxy?.$modal.closeLoading();
  373. }
  374. };
  375. // 图片上传前拦截
  376. const handleBeforeUpload = (file: any) => {
  377. const type = ['image/jpeg', 'image/jpg', 'image/png', 'image/svg'];
  378. const isJPG = type.includes(file.type);
  379. //检验文件格式
  380. if (!isJPG) {
  381. proxy?.$modal.msgError(`图片格式错误!`);
  382. return false;
  383. }
  384. // 校检文件大小
  385. if (props.fileSize) {
  386. const isLt = file.size / 1024 / 1024 < props.fileSize;
  387. if (!isLt) {
  388. proxy?.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`);
  389. return false;
  390. }
  391. }
  392. proxy?.$modal.loading('正在上传文件,请稍候...');
  393. return true;
  394. };
  395. // 图片/视频上传失败拦截
  396. const handleUploadError = (err: any) => {
  397. proxy?.$modal.msgError('上传文件失败');
  398. proxy?.$modal.closeLoading();
  399. };
  400. // 视频上传前拦截:类型 + 2GB上限(与后端multipart及视频内容页对齐)
  401. const handleVideoBeforeUpload = (file: any) => {
  402. const isVideo = file.type?.startsWith('video/') || /\.(mp4|mov|m4v|webm|mkv|avi)$/i.test(file.name);
  403. if (!isVideo) {
  404. proxy?.$modal.msgError('请选择视频文件!');
  405. return false;
  406. }
  407. const sizeMB = file.size / 1024 / 1024;
  408. if (sizeMB >= 2048) {
  409. proxy?.$modal.msgError('视频文件大小不能超过2GB');
  410. return false;
  411. }
  412. proxy?.$modal.loading('正在上传视频,请稍候...');
  413. return true;
  414. };
  415. // 视频上传成功:先只记录地址,点确定时才插入正文
  416. const handleVideoUploadSuccess = (res: any, file: any) => {
  417. proxy?.$modal.closeLoading();
  418. if (res.code === 200) {
  419. videoDialog.uploadedUrl = res.data.url;
  420. videoDialog.uploadedName = file?.name || '视频';
  421. } else {
  422. proxy?.$modal.msgError('视频上传失败');
  423. }
  424. };
  425. // 确定插入视频:按所选方式取地址,插到点按钮时记录的位置
  426. const confirmInsertVideo = async () => {
  427. const src = (videoDialog.mode === 'url' ? videoDialog.url : videoDialog.uploadedUrl).trim();
  428. if (!src) {
  429. proxy?.$modal.msgWarning(videoDialog.mode === 'url' ? '请输入视频地址' : '请先上传视频文件');
  430. return;
  431. }
  432. if (videoDialog.mode === 'url') {
  433. // <video>只认视频文件直链;抖音/哔哩等网页地址塞进去是黑屏,提前提醒
  434. let pathname = src;
  435. try {
  436. pathname = new URL(src).pathname;
  437. } catch {
  438. /* 不是标准URL就按原串校验 */
  439. }
  440. if (!/\.(mp4|m4v|mov|webm|mkv|avi|flv)$/i.test(pathname)) {
  441. try {
  442. await proxy?.$modal.confirm('该链接不带视频文件扩展名(.mp4等)。网页链接(抖音、哔哩等)在正文中无法播放,建议下载后本地上传。仍要插入吗?');
  443. } catch {
  444. return;
  445. }
  446. }
  447. }
  448. const quill = toRaw(quillEditorRef.value)?.getQuill();
  449. if (!quill) return;
  450. const index = pendingVideoIndex ?? Math.max(quill.getLength() - 1, 0);
  451. pendingVideoIndex = null;
  452. quill.insertEmbed(index, 'videoTag', src);
  453. quill.setSelection(index + 1, 0);
  454. videoDialog.visible = false;
  455. };
  456. import { onMounted, nextTick } from 'vue';
  457. import Delta from 'quill-delta';
  458. onMounted(async () => {
  459. await nextTick();
  460. const quill = quillEditorRef.value?.getQuill();
  461. if (!quill) return;
  462. // 修复:在Dialog中点击工具栏时防止失焦导致格式不生效
  463. const toolbar = quillEditorRef.value?.$el?.querySelector('.ql-toolbar');
  464. if (toolbar) {
  465. toolbar.addEventListener('mousedown', (e: Event) => {
  466. e.preventDefault();
  467. });
  468. }
  469. // 清空默认行为
  470. quill.clipboard.matchers = [];
  471. quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node: HTMLElement, delta: Delta) => {
  472. const tempDiv = document.createElement('div');
  473. tempDiv.innerHTML = node.outerHTML;
  474. // ✅ 只清除 background 相关样式,保留 color
  475. cleanBackgroundOnly(tempDiv);
  476. // 构建 Delta(支持格式 + 列表)
  477. const newDelta = new Delta();
  478. buildDeltaWithListSupport(tempDiv, newDelta, {}, null);
  479. return newDelta;
  480. });
  481. });
  482. // ✅ 只清除 background,保留 color/font-size 等
  483. function cleanBackgroundOnly(parent: HTMLElement) {
  484. if (parent.style) {
  485. parent.style.backgroundColor = '';
  486. parent.style.background = '';
  487. parent.style.color = '';
  488. parent.style.backgroundImage = '';
  489. parent.style.backgroundPosition = '';
  490. parent.style.backgroundRepeat = '';
  491. parent.style.backgroundSize = '';
  492. parent.style.backgroundAttachment = '';
  493. // ✅ 不动 color、font、size 等
  494. }
  495. Array.from(parent.children).forEach((child) => {
  496. if (child instanceof HTMLElement) {
  497. cleanBackgroundOnly(child);
  498. }
  499. });
  500. }
  501. // 手工空格缩进也要在APP生效:HTML默认折叠行首和连续空格,编辑器靠white-space:pre-wrap
  502. // 才能原样显示,APP端渲染器按标准HTML折叠,用户自己敲的空格就失效了。
  503. // 输出时把每行行首空白、行内连续空白换成不间断空格(U+00A0),任何渲染器都原样保留
  504. const SPACE_RISK_RE = /(^|>)[ \t]|[ \t]{2,}/;
  505. function stabilizeSpacesHtml(html: string): string {
  506. if (!html || !SPACE_RISK_RE.test(html)) return html;
  507. let out = html;
  508. // 1) 段落行首空白:中间允许隔内联开标签或已转换的不间断空格,多轮收敛
  509. const leadRe = /(<(?:p|div|h[1-6]|li|blockquote|pre)\b[^>]*>(?:\u00a0|<(?!br\b|img\b|video\b|\/)[^>]+>)*)([ \t]+)/g;
  510. for (let pass = 0; pass < 4; pass++) {
  511. const next = out.replace(leadRe, (_m: string, head: string, run: string) => head + '\u00a0'.repeat(run.length));
  512. if (next === out) break;
  513. out = next;
  514. }
  515. // 2) 行内连续空格与Tab:按标签切段后只改文本段,不动属性里的空格
  516. const parts = out.split(/(<[^>]*>)/);
  517. for (let i = 0; i < parts.length; i += 2) {
  518. if (parts[i] && /\t| {2,}/.test(parts[i])) {
  519. parts[i] = parts[i].replace(/\t/g, '\u00a0\u00a0\u00a0\u00a0').replace(/ {2,}/g, (m) => '\u00a0'.repeat(m.length));
  520. }
  521. }
  522. return parts.join('');
  523. }
  524. // 内容变化统一出口:先做"空格缩进APP化"转换再发给父组件
  525. function handleTextChange() {
  526. // 同watch:转换抛错时降级为原始内容,保证编辑/保存链路不因转换异常中断
  527. try {
  528. lastEmitted = stabilizeSpacesHtml(content.value);
  529. } catch (e) {
  530. console.error('[Editor] 内容转换失败,降级为原始内容:', e);
  531. lastEmitted = content.value;
  532. }
  533. emit('update:modelValue', lastEmitted);
  534. }
  535. // 历史数据兼容:旧版缩进是Quill class(ql-indent-N),加载时转成内联padding-left
  536. function migrateIndentHtml(html: string): string {
  537. if (!html || html.indexOf('ql-indent-') < 0) return html;
  538. return html.replace(/<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (tag: string, name: string, attrs: string) => {
  539. const cm = /\sclass\s*=\s*"([^"]*)"/i.exec(attrs) || /\sclass\s*=\s*'([^']*)'/i.exec(attrs);
  540. if (!cm) return tag;
  541. const im = /ql-indent-(\d+)/.exec(cm[1]);
  542. if (!im) return tag;
  543. const level = Math.min(parseInt(im[1], 10) || 0, INDENT_MAX);
  544. if (level < 1) return tag;
  545. const restClass = cm[1].replace(/ql-indent-\d+/g, '').replace(/\s+/g, ' ').trim();
  546. let newAttrs = attrs.replace(cm[0], restClass ? ` class="${restClass}"` : '');
  547. const pad = `padding-left: ${level * INDENT_EM}em;`;
  548. const sm = /\sstyle\s*=\s*"([^"]*)"/i.exec(newAttrs);
  549. if (sm) {
  550. newAttrs = newAttrs.replace(sm[0], ` style="${pad}${sm[1]}"`);
  551. } else {
  552. newAttrs += ` style="${pad}"`;
  553. }
  554. return `<${name}${newAttrs}>`;
  555. });
  556. }
  557. // 读缩进层级:兼容内联padding-left与旧版ql-indent-N class
  558. function readIndentLevel(el: HTMLElement): number {
  559. let level = 0;
  560. const cm = /ql-indent-(\d+)/.exec(el.getAttribute('class') || '');
  561. if (cm) level = parseInt(cm[1], 10) || 0;
  562. const pl = el.style.paddingLeft;
  563. if (pl) {
  564. const n = parseFloat(pl);
  565. if (n > 0) level = Math.max(level, Math.round(pl.indexOf('px') >= 0 ? n / (INDENT_EM * 16) : n / INDENT_EM));
  566. }
  567. return level;
  568. }
  569. // 插入一个带块级格式的换行符:Quill的缩进/对齐/标题/列表都是"行"格式,只认换行符上的属性
  570. function insertBlockBreak(delta: Delta, el: HTMLElement, listType: 'ordered' | 'bullet' | null) {
  571. const fmt: { [key: string]: any } = {};
  572. const hm = /^h([1-6])$/.exec(el.tagName.toLowerCase());
  573. if (hm) fmt.header = parseInt(hm[1], 10);
  574. if (el.style.textAlign) fmt.align = el.style.textAlign;
  575. if (listType) fmt.list = listType;
  576. const level = readIndentLevel(el);
  577. if (level > 0) fmt.indent = level;
  578. delta.insert('\n', Object.keys(fmt).length ? fmt : undefined);
  579. }
  580. // ✅ 支持列表的 Delta 构建
  581. function buildDeltaWithListSupport(
  582. node: Node,
  583. delta: Delta,
  584. formatStack: { [key: string]: any },
  585. listType: 'ordered' | 'bullet' | null // 当前是否在列表中
  586. ) {
  587. if (node.nodeType === Node.TEXT_NODE) {
  588. const text = node.textContent || '';
  589. if (text.trim() || text === ' ') {
  590. delta.insert(text, formatStack);
  591. }
  592. return;
  593. }
  594. if (node.nodeType !== Node.ELEMENT_NODE) return;
  595. const el = node as HTMLElement;
  596. const tagName = el.tagName.toLowerCase();
  597. const currentFormat = { ...formatStack };
  598. let newListType: 'ordered' | 'bullet' | null = listType;
  599. // 处理列表开始
  600. if (tagName === 'ol') {
  601. newListType = 'ordered';
  602. }
  603. if (tagName === 'ul') {
  604. newListType = 'bullet';
  605. }
  606. // 处理列表项
  607. if (tagName === 'li') {
  608. if (listType) {
  609. // 标记为列表项
  610. currentFormat.list = listType;
  611. }
  612. // 如果 li 有嵌套 ol/ul,子项可能改变类型,但这里简化处理
  613. }
  614. // 添加内联格式
  615. if (tagName === 'strong' || tagName === 'b') {
  616. currentFormat.bold = true;
  617. }
  618. if (tagName === 'em' || tagName === 'i') {
  619. currentFormat.italic = true;
  620. }
  621. if (tagName === 'u' || el.style.textDecoration === 'underline') {
  622. currentFormat.underline = true;
  623. }
  624. if (tagName === 's' || tagName === 'strike') {
  625. currentFormat.strike = true;
  626. }
  627. // ✅ 保留颜色(关键!)
  628. if (el.style.color) {
  629. currentFormat.color = el.style.color;
  630. }
  631. if (el.style.fontSize) {
  632. currentFormat.size = el.style.fontSize; // 注意:Quill 的 size 是 small/large/huge 或值
  633. }
  634. if (el.style.fontFamily) {
  635. currentFormat.font = el.style.fontFamily.split(',')[0].trim().replace(/['"]/g, '');
  636. }
  637. // 是否是块级元素(需要换行)
  638. const isBlock = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'br'].includes(tagName);
  639. // 遍历子节点
  640. for (let i = 0; i < el.childNodes.length; i++) {
  641. const child = el.childNodes[i];
  642. // <br>就是硬换行:直接换成带块级格式的换行符(Quill行内没有br)
  643. if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName === 'BR' && isBlock) {
  644. insertBlockBreak(delta, el, listType);
  645. continue;
  646. }
  647. buildDeltaWithListSupport(child, delta, currentFormat, newListType);
  648. // 块级元素结尾的换行承载该段的块级格式(缩进/对齐/标题/列表);
  649. // 开头不再补换行:结尾换行已起到分段作用,再补会把段间距翻倍
  650. if (isBlock && i === el.childNodes.length - 1 && (el.nextSibling || el.parentNode !== el.ownerDocument?.body)) {
  651. if (!delta.ops.length || delta.ops[delta.ops.length - 1].insert !== '\n') {
  652. insertBlockBreak(delta, el, listType);
  653. }
  654. }
  655. }
  656. }
  657. /*
  658. onMounted(async () => {
  659. await nextTick();
  660. const quill = quillEditorRef.value?.getQuill();
  661. if (!quill) return;
  662. // ✅ 1. 移除所有默认的 matcher,避免干扰
  663. quill.clipboard.matchers = [];
  664. // ✅ 2. 添加自定义 matcher:对所有元素节点,只提取 innerText
  665. quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node: HTMLElement, delta: Delta) => {
  666. // 获取纯文本
  667. const text = node.innerText || node.textContent || '';
  668. // 返回一个新的 Delta,只包含纯文本,无任何格式
  669. return new Delta().insert(text);
  670. });
  671. // ✅ 3. 特别处理根节点是纯文本的情况(比如从记事本复制)
  672. quill.clipboard.addMatcher(Node.TEXT_NODE, (node: Text, delta: Delta) => {
  673. return new Delta().insert(node.data || '');
  674. });
  675. // ✅ 4. 可选:如果你希望保留换行,可以不做处理,Quill 会自动处理 p/br
  676. // 如果你想更激进地清理,也可以在这里统一处理
  677. console.log('[Clipboard] Custom matcher set, only plain text will be pasted.');
  678. });
  679. import { onMounted } from 'vue';
  680. onMounted(() => {
  681. const quill = quillEditorRef.value.getQuill();
  682. debugger;
  683. // ✅ 2. 手动监听 paste 事件,完全由你控制
  684. quill.root.addEventListener('paste', async (e) => {
  685. e.preventDefault(); // ✅ 阻止浏览器默认行为
  686. const clipboardData = e.clipboardData || (e as any).originalEvent.clipboardData;
  687. const html = clipboardData.getData('text/html');
  688. const text = clipboardData.getData('text/plain');
  689. // 使用 HTML 优先,否则用纯文本
  690. const tempDiv = document.createElement('div');
  691. tempDiv.innerHTML = html || text;
  692. // 清理所有样式和 class
  693. const walk = (node: Node) => {
  694. if (node.nodeType === Node.ELEMENT_NODE) {
  695. const el = node as HTMLElement;
  696. el.style.cssText = '';
  697. el.removeAttribute('class');
  698. el.removeAttribute('style');
  699. // 特别清理 span 的内联样式
  700. if (el.tagName === 'SPAN') {
  701. el.style.color = '';
  702. el.style.backgroundColor = '';
  703. el.style.fontWeight = '';
  704. el.style.fontStyle = '';
  705. el.style.textDecoration = '';
  706. }
  707. Array.from(el.children).forEach((child) => walk(child));
  708. }
  709. };
  710. Array.from(tempDiv.childNodes).forEach(walk);
  711. const cleanHtml = tempDiv.innerHTML;
  712. const range = quill.getSelection();
  713. const index = range ? range.index : 0;
  714. // ✅ 调试:打印关键信息
  715. console.log('[Paste Debug]', { html, text, cleanHtml, index });
  716. // ✅ 插入清理后的内容
  717. quill.clipboard.dangerouslyPasteHTML(index, cleanHtml);
  718. // 可选:将光标移到末尾
  719. // setTimeout(() => {
  720. // const length = quill.getLength();
  721. // quill.setSelection(length, 0);
  722. // }, 10);
  723. });
  724. });*/
  725. </script>
  726. <style>
  727. .editor-img-uploader {
  728. display: none;
  729. }
  730. .editor,
  731. .ql-toolbar {
  732. white-space: pre-wrap !important;
  733. line-height: normal !important;
  734. }
  735. .quill-img {
  736. display: none;
  737. }
  738. .ql-snow .ql-tooltip[data-mode='link']::before {
  739. content: '请输入链接地址:';
  740. }
  741. .ql-snow .ql-tooltip.ql-editing a.ql-action::after {
  742. border-right: 0;
  743. content: '保存';
  744. padding-right: 0;
  745. }
  746. .ql-snow .ql-tooltip[data-mode='video']::before {
  747. content: '请输入视频地址:';
  748. }
  749. .ql-snow .ql-picker.ql-size .ql-picker-label::before,
  750. .ql-snow .ql-picker.ql-size .ql-picker-item::before {
  751. content: '14px';
  752. }
  753. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='small']::before,
  754. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='small']::before {
  755. content: '10px';
  756. }
  757. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='large']::before,
  758. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='large']::before {
  759. content: '18px';
  760. }
  761. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='huge']::before,
  762. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='huge']::before {
  763. content: '32px';
  764. }
  765. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='10px']::before,
  766. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='10px']::before {
  767. content: '10px';
  768. }
  769. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='18px']::before,
  770. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='18px']::before {
  771. content: '18px';
  772. }
  773. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='32px']::before,
  774. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='32px']::before {
  775. content: '32px';
  776. }
  777. .ql-snow .ql-picker.ql-header .ql-picker-label::before,
  778. .ql-snow .ql-picker.ql-header .ql-picker-item::before {
  779. content: '文本';
  780. }
  781. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='1']::before,
  782. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='1']::before {
  783. content: '标题1';
  784. }
  785. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='2']::before,
  786. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='2']::before {
  787. content: '标题2';
  788. }
  789. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='3']::before,
  790. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='3']::before {
  791. content: '标题3';
  792. }
  793. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='4']::before,
  794. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='4']::before {
  795. content: '标题4';
  796. }
  797. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='5']::before,
  798. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='5']::before {
  799. content: '标题5';
  800. }
  801. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='6']::before,
  802. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='6']::before {
  803. content: '标题6';
  804. }
  805. .ql-snow .ql-picker.ql-font .ql-picker-label::before,
  806. .ql-snow .ql-picker.ql-font .ql-picker-item::before {
  807. content: '标准字体';
  808. }
  809. .ql-snow .ql-picker.ql-font .ql-picker-label[data-value='serif']::before,
  810. .ql-snow .ql-picker.ql-font .ql-picker-item[data-value='serif']::before {
  811. content: '衬线字体';
  812. }
  813. .ql-snow .ql-picker.ql-font .ql-picker-label[data-value='monospace']::before,
  814. .ql-snow .ql-picker.ql-font .ql-picker-item[data-value='monospace']::before {
  815. content: '等宽字体';
  816. }
  817. .editor .ql-editor {
  818. white-space: pre-wrap; /* 强制换行 */
  819. word-break: break-all; /* 防止长单词或URL溢出 */
  820. }
  821. .editor .ql-editor img,
  822. .editor .ql-editor video {
  823. max-width: 100%; /* 自适应编辑器宽度,不溢出 */
  824. }
  825. .editor .ql-editor video {
  826. margin: 8px 0;
  827. }
  828. </style>