index.vue 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884
  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. const next = stabilizeSpacesHtml(migrateIndentHtml(v || ''));
  334. if (next !== content.value) {
  335. content.value = next || '<p></p>';
  336. // 转换结果同步回父组件表单,避免未编辑直接保存时丢掉空格缩进
  337. if (next) {
  338. lastEmitted = next;
  339. emit('update:modelValue', next);
  340. }
  341. }
  342. },
  343. { immediate: true }
  344. );
  345. // 图片上传成功返回图片地址
  346. const handleUploadSuccess = (res: any) => {
  347. // 如果上传成功
  348. if (res.code === 200) {
  349. // 获取富文本实例
  350. const quill = toRaw(quillEditorRef.value).getQuill();
  351. // 用点击图片按钮时记录的位置,为空则退回当前光标或末尾;
  352. // 直接读 selection.savedRange 在编辑器未聚焦时是 null 会报错,导致第一次上传没反应
  353. const range = quill.getSelection();
  354. const index = pendingImageIndex ?? range?.index ?? Math.max(quill.getLength() - 1, 0);
  355. pendingImageIndex = null;
  356. // 插入图片,res为服务器返回的图片链接地址
  357. quill.insertEmbed(index, 'image', res.data.url);
  358. // 光标放到图片后
  359. quill.setSelection(index + 1);
  360. proxy?.$modal.closeLoading();
  361. } else {
  362. proxy?.$modal.msgError('图片插入失败');
  363. proxy?.$modal.closeLoading();
  364. }
  365. };
  366. // 图片上传前拦截
  367. const handleBeforeUpload = (file: any) => {
  368. const type = ['image/jpeg', 'image/jpg', 'image/png', 'image/svg'];
  369. const isJPG = type.includes(file.type);
  370. //检验文件格式
  371. if (!isJPG) {
  372. proxy?.$modal.msgError(`图片格式错误!`);
  373. return false;
  374. }
  375. // 校检文件大小
  376. if (props.fileSize) {
  377. const isLt = file.size / 1024 / 1024 < props.fileSize;
  378. if (!isLt) {
  379. proxy?.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`);
  380. return false;
  381. }
  382. }
  383. proxy?.$modal.loading('正在上传文件,请稍候...');
  384. return true;
  385. };
  386. // 图片/视频上传失败拦截
  387. const handleUploadError = (err: any) => {
  388. proxy?.$modal.msgError('上传文件失败');
  389. proxy?.$modal.closeLoading();
  390. };
  391. // 视频上传前拦截:类型 + 2GB上限(与后端multipart及视频内容页对齐)
  392. const handleVideoBeforeUpload = (file: any) => {
  393. const isVideo = file.type?.startsWith('video/') || /\.(mp4|mov|m4v|webm|mkv|avi)$/i.test(file.name);
  394. if (!isVideo) {
  395. proxy?.$modal.msgError('请选择视频文件!');
  396. return false;
  397. }
  398. const sizeMB = file.size / 1024 / 1024;
  399. if (sizeMB >= 2048) {
  400. proxy?.$modal.msgError('视频文件大小不能超过2GB');
  401. return false;
  402. }
  403. proxy?.$modal.loading('正在上传视频,请稍候...');
  404. return true;
  405. };
  406. // 视频上传成功:先只记录地址,点确定时才插入正文
  407. const handleVideoUploadSuccess = (res: any, file: any) => {
  408. proxy?.$modal.closeLoading();
  409. if (res.code === 200) {
  410. videoDialog.uploadedUrl = res.data.url;
  411. videoDialog.uploadedName = file?.name || '视频';
  412. } else {
  413. proxy?.$modal.msgError('视频上传失败');
  414. }
  415. };
  416. // 确定插入视频:按所选方式取地址,插到点按钮时记录的位置
  417. const confirmInsertVideo = async () => {
  418. const src = (videoDialog.mode === 'url' ? videoDialog.url : videoDialog.uploadedUrl).trim();
  419. if (!src) {
  420. proxy?.$modal.msgWarning(videoDialog.mode === 'url' ? '请输入视频地址' : '请先上传视频文件');
  421. return;
  422. }
  423. if (videoDialog.mode === 'url') {
  424. // <video>只认视频文件直链;抖音/哔哩等网页地址塞进去是黑屏,提前提醒
  425. let pathname = src;
  426. try {
  427. pathname = new URL(src).pathname;
  428. } catch {
  429. /* 不是标准URL就按原串校验 */
  430. }
  431. if (!/\.(mp4|m4v|mov|webm|mkv|avi|flv)$/i.test(pathname)) {
  432. try {
  433. await proxy?.$modal.confirm('该链接不带视频文件扩展名(.mp4等)。网页链接(抖音、哔哩等)在正文中无法播放,建议下载后本地上传。仍要插入吗?');
  434. } catch {
  435. return;
  436. }
  437. }
  438. }
  439. const quill = toRaw(quillEditorRef.value)?.getQuill();
  440. if (!quill) return;
  441. const index = pendingVideoIndex ?? Math.max(quill.getLength() - 1, 0);
  442. pendingVideoIndex = null;
  443. quill.insertEmbed(index, 'videoTag', src);
  444. quill.setSelection(index + 1, 0);
  445. videoDialog.visible = false;
  446. };
  447. import { onMounted, nextTick } from 'vue';
  448. import Delta from 'quill-delta';
  449. onMounted(async () => {
  450. await nextTick();
  451. const quill = quillEditorRef.value?.getQuill();
  452. if (!quill) return;
  453. // 修复:在Dialog中点击工具栏时防止失焦导致格式不生效
  454. const toolbar = quillEditorRef.value?.$el?.querySelector('.ql-toolbar');
  455. if (toolbar) {
  456. toolbar.addEventListener('mousedown', (e: Event) => {
  457. e.preventDefault();
  458. });
  459. }
  460. // 清空默认行为
  461. quill.clipboard.matchers = [];
  462. quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node: HTMLElement, delta: Delta) => {
  463. const tempDiv = document.createElement('div');
  464. tempDiv.innerHTML = node.outerHTML;
  465. // ✅ 只清除 background 相关样式,保留 color
  466. cleanBackgroundOnly(tempDiv);
  467. // 构建 Delta(支持格式 + 列表)
  468. const newDelta = new Delta();
  469. buildDeltaWithListSupport(tempDiv, newDelta, {}, null);
  470. return newDelta;
  471. });
  472. });
  473. // ✅ 只清除 background,保留 color/font-size 等
  474. function cleanBackgroundOnly(parent: HTMLElement) {
  475. if (parent.style) {
  476. parent.style.backgroundColor = '';
  477. parent.style.background = '';
  478. parent.style.color = '';
  479. parent.style.backgroundImage = '';
  480. parent.style.backgroundPosition = '';
  481. parent.style.backgroundRepeat = '';
  482. parent.style.backgroundSize = '';
  483. parent.style.backgroundAttachment = '';
  484. // ✅ 不动 color、font、size 等
  485. }
  486. Array.from(parent.children).forEach((child) => {
  487. if (child instanceof HTMLElement) {
  488. cleanBackgroundOnly(child);
  489. }
  490. });
  491. }
  492. // 手工空格缩进也要在APP生效:HTML默认折叠行首和连续空格,编辑器靠white-space:pre-wrap
  493. // 才能原样显示,APP端渲染器按标准HTML折叠,用户自己敲的空格就失效了。
  494. // 输出时把每行行首空白、行内连续空白换成不间断空格(U+00A0),任何渲染器都原样保留
  495. const SPACE_RISK_RE = /(^|>)[ \t]|[ \t]{2,}/;
  496. function stabilizeSpacesHtml(html: string): string {
  497. if (!html || !SPACE_RISK_RE.test(html)) return html;
  498. let out = html;
  499. // 1) 段落行首空白:中间允许隔内联开标签或已转换的不间断空格,多轮收敛
  500. const leadRe = /(<(?:p|div|h[1-6]|li|blockquote|pre)\b[^>]*>(?:\u00a0|<(?!br\b|img\b|video\b|\/)[^>]+>)*)([ \t]+)/g;
  501. for (let pass = 0; pass < 4; pass++) {
  502. const next = out.replace(leadRe, (_m: string, head: string, run: string) => head + '\u00a0'.repeat(run.length));
  503. if (next === out) break;
  504. out = next;
  505. }
  506. // 2) 行内连续空格与Tab:按标签切段后只改文本段,不动属性里的空格
  507. const parts = out.split(/(<[^>]*>)/);
  508. for (let i = 0; i < parts.length; i += 2) {
  509. if (parts[i] && /\t| {2,}/.test(parts[i])) {
  510. parts[i] = parts[i].replace(/\t/g, '\u00a0\u00a0\u00a0\u00a0').replace(/ {2,}/g, (m) => '\u00a0'.repeat(m.length));
  511. }
  512. }
  513. return parts.join('');
  514. }
  515. // 内容变化统一出口:先做"空格缩进APP化"转换再发给父组件
  516. function handleTextChange() {
  517. lastEmitted = stabilizeSpacesHtml(content.value);
  518. emit('update:modelValue', lastEmitted);
  519. }
  520. // 历史数据兼容:旧版缩进是Quill class(ql-indent-N),加载时转成内联padding-left
  521. function migrateIndentHtml(html: string): string {
  522. if (!html || html.indexOf('ql-indent-') < 0) return html;
  523. return html.replace(/<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (tag: string, name: string, attrs: string) => {
  524. const cm = /\sclass\s*=\s*"([^"]*)"/i.exec(attrs) || /\sclass\s*=\s*'([^']*)'/i.exec(attrs);
  525. if (!cm) return tag;
  526. const im = /ql-indent-(\d+)/.exec(cm[1]);
  527. if (!im) return tag;
  528. const level = Math.min(parseInt(im[1], 10) || 0, INDENT_MAX);
  529. if (level < 1) return tag;
  530. const restClass = cm[1].replace(/ql-indent-\d+/g, '').replace(/\s+/g, ' ').trim();
  531. let newAttrs = attrs.replace(cm[0], restClass ? ` class="${restClass}"` : '');
  532. const pad = `padding-left: ${level * INDENT_EM}em;`;
  533. const sm = /\sstyle\s*=\s*"([^"]*)"/i.exec(newAttrs);
  534. if (sm) {
  535. newAttrs = newAttrs.replace(sm[0], ` style="${pad}${sm[1]}"`);
  536. } else {
  537. newAttrs += ` style="${pad}"`;
  538. }
  539. return `<${name}${newAttrs}>`;
  540. });
  541. }
  542. // 读缩进层级:兼容内联padding-left与旧版ql-indent-N class
  543. function readIndentLevel(el: HTMLElement): number {
  544. let level = 0;
  545. const cm = /ql-indent-(\d+)/.exec(el.getAttribute('class') || '');
  546. if (cm) level = parseInt(cm[1], 10) || 0;
  547. const pl = el.style.paddingLeft;
  548. if (pl) {
  549. const n = parseFloat(pl);
  550. if (n > 0) level = Math.max(level, Math.round(pl.indexOf('px') >= 0 ? n / (INDENT_EM * 16) : n / INDENT_EM));
  551. }
  552. return level;
  553. }
  554. // 插入一个带块级格式的换行符:Quill的缩进/对齐/标题/列表都是"行"格式,只认换行符上的属性
  555. function insertBlockBreak(delta: Delta, el: HTMLElement, listType: 'ordered' | 'bullet' | null) {
  556. const fmt: { [key: string]: any } = {};
  557. const hm = /^h([1-6])$/.exec(el.tagName.toLowerCase());
  558. if (hm) fmt.header = parseInt(hm[1], 10);
  559. if (el.style.textAlign) fmt.align = el.style.textAlign;
  560. if (listType) fmt.list = listType;
  561. const level = readIndentLevel(el);
  562. if (level > 0) fmt.indent = level;
  563. delta.insert('\n', Object.keys(fmt).length ? fmt : undefined);
  564. }
  565. // ✅ 支持列表的 Delta 构建
  566. function buildDeltaWithListSupport(
  567. node: Node,
  568. delta: Delta,
  569. formatStack: { [key: string]: any },
  570. listType: 'ordered' | 'bullet' | null // 当前是否在列表中
  571. ) {
  572. if (node.nodeType === Node.TEXT_NODE) {
  573. const text = node.textContent || '';
  574. if (text.trim() || text === ' ') {
  575. delta.insert(text, formatStack);
  576. }
  577. return;
  578. }
  579. if (node.nodeType !== Node.ELEMENT_NODE) return;
  580. const el = node as HTMLElement;
  581. const tagName = el.tagName.toLowerCase();
  582. const currentFormat = { ...formatStack };
  583. let newListType: 'ordered' | 'bullet' | null = listType;
  584. // 处理列表开始
  585. if (tagName === 'ol') {
  586. newListType = 'ordered';
  587. }
  588. if (tagName === 'ul') {
  589. newListType = 'bullet';
  590. }
  591. // 处理列表项
  592. if (tagName === 'li') {
  593. if (listType) {
  594. // 标记为列表项
  595. currentFormat.list = listType;
  596. }
  597. // 如果 li 有嵌套 ol/ul,子项可能改变类型,但这里简化处理
  598. }
  599. // 添加内联格式
  600. if (tagName === 'strong' || tagName === 'b') {
  601. currentFormat.bold = true;
  602. }
  603. if (tagName === 'em' || tagName === 'i') {
  604. currentFormat.italic = true;
  605. }
  606. if (tagName === 'u' || el.style.textDecoration === 'underline') {
  607. currentFormat.underline = true;
  608. }
  609. if (tagName === 's' || tagName === 'strike') {
  610. currentFormat.strike = true;
  611. }
  612. // ✅ 保留颜色(关键!)
  613. if (el.style.color) {
  614. currentFormat.color = el.style.color;
  615. }
  616. if (el.style.fontSize) {
  617. currentFormat.size = el.style.fontSize; // 注意:Quill 的 size 是 small/large/huge 或值
  618. }
  619. if (el.style.fontFamily) {
  620. currentFormat.font = el.style.fontFamily.split(',')[0].trim().replace(/['"]/g, '');
  621. }
  622. // 是否是块级元素(需要换行)
  623. const isBlock = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'br'].includes(tagName);
  624. // 遍历子节点
  625. for (let i = 0; i < el.childNodes.length; i++) {
  626. const child = el.childNodes[i];
  627. // <br>就是硬换行:直接换成带块级格式的换行符(Quill行内没有br)
  628. if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName === 'BR' && isBlock) {
  629. insertBlockBreak(delta, el, listType);
  630. continue;
  631. }
  632. buildDeltaWithListSupport(child, delta, currentFormat, newListType);
  633. // 块级元素结尾的换行承载该段的块级格式(缩进/对齐/标题/列表);
  634. // 开头不再补换行:结尾换行已起到分段作用,再补会把段间距翻倍
  635. if (isBlock && i === el.childNodes.length - 1 && (el.nextSibling || el.parentNode !== el.ownerDocument?.body)) {
  636. if (!delta.ops.length || delta.ops[delta.ops.length - 1].insert !== '\n') {
  637. insertBlockBreak(delta, el, listType);
  638. }
  639. }
  640. }
  641. }
  642. /*
  643. onMounted(async () => {
  644. await nextTick();
  645. const quill = quillEditorRef.value?.getQuill();
  646. if (!quill) return;
  647. // ✅ 1. 移除所有默认的 matcher,避免干扰
  648. quill.clipboard.matchers = [];
  649. // ✅ 2. 添加自定义 matcher:对所有元素节点,只提取 innerText
  650. quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node: HTMLElement, delta: Delta) => {
  651. // 获取纯文本
  652. const text = node.innerText || node.textContent || '';
  653. // 返回一个新的 Delta,只包含纯文本,无任何格式
  654. return new Delta().insert(text);
  655. });
  656. // ✅ 3. 特别处理根节点是纯文本的情况(比如从记事本复制)
  657. quill.clipboard.addMatcher(Node.TEXT_NODE, (node: Text, delta: Delta) => {
  658. return new Delta().insert(node.data || '');
  659. });
  660. // ✅ 4. 可选:如果你希望保留换行,可以不做处理,Quill 会自动处理 p/br
  661. // 如果你想更激进地清理,也可以在这里统一处理
  662. console.log('[Clipboard] Custom matcher set, only plain text will be pasted.');
  663. });
  664. import { onMounted } from 'vue';
  665. onMounted(() => {
  666. const quill = quillEditorRef.value.getQuill();
  667. debugger;
  668. // ✅ 2. 手动监听 paste 事件,完全由你控制
  669. quill.root.addEventListener('paste', async (e) => {
  670. e.preventDefault(); // ✅ 阻止浏览器默认行为
  671. const clipboardData = e.clipboardData || (e as any).originalEvent.clipboardData;
  672. const html = clipboardData.getData('text/html');
  673. const text = clipboardData.getData('text/plain');
  674. // 使用 HTML 优先,否则用纯文本
  675. const tempDiv = document.createElement('div');
  676. tempDiv.innerHTML = html || text;
  677. // 清理所有样式和 class
  678. const walk = (node: Node) => {
  679. if (node.nodeType === Node.ELEMENT_NODE) {
  680. const el = node as HTMLElement;
  681. el.style.cssText = '';
  682. el.removeAttribute('class');
  683. el.removeAttribute('style');
  684. // 特别清理 span 的内联样式
  685. if (el.tagName === 'SPAN') {
  686. el.style.color = '';
  687. el.style.backgroundColor = '';
  688. el.style.fontWeight = '';
  689. el.style.fontStyle = '';
  690. el.style.textDecoration = '';
  691. }
  692. Array.from(el.children).forEach((child) => walk(child));
  693. }
  694. };
  695. Array.from(tempDiv.childNodes).forEach(walk);
  696. const cleanHtml = tempDiv.innerHTML;
  697. const range = quill.getSelection();
  698. const index = range ? range.index : 0;
  699. // ✅ 调试:打印关键信息
  700. console.log('[Paste Debug]', { html, text, cleanHtml, index });
  701. // ✅ 插入清理后的内容
  702. quill.clipboard.dangerouslyPasteHTML(index, cleanHtml);
  703. // 可选:将光标移到末尾
  704. // setTimeout(() => {
  705. // const length = quill.getLength();
  706. // quill.setSelection(length, 0);
  707. // }, 10);
  708. });
  709. });*/
  710. </script>
  711. <style>
  712. .editor-img-uploader {
  713. display: none;
  714. }
  715. .editor,
  716. .ql-toolbar {
  717. white-space: pre-wrap !important;
  718. line-height: normal !important;
  719. }
  720. .quill-img {
  721. display: none;
  722. }
  723. .ql-snow .ql-tooltip[data-mode='link']::before {
  724. content: '请输入链接地址:';
  725. }
  726. .ql-snow .ql-tooltip.ql-editing a.ql-action::after {
  727. border-right: 0;
  728. content: '保存';
  729. padding-right: 0;
  730. }
  731. .ql-snow .ql-tooltip[data-mode='video']::before {
  732. content: '请输入视频地址:';
  733. }
  734. .ql-snow .ql-picker.ql-size .ql-picker-label::before,
  735. .ql-snow .ql-picker.ql-size .ql-picker-item::before {
  736. content: '14px';
  737. }
  738. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='small']::before,
  739. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='small']::before {
  740. content: '10px';
  741. }
  742. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='large']::before,
  743. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='large']::before {
  744. content: '18px';
  745. }
  746. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='huge']::before,
  747. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='huge']::before {
  748. content: '32px';
  749. }
  750. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='10px']::before,
  751. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='10px']::before {
  752. content: '10px';
  753. }
  754. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='18px']::before,
  755. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='18px']::before {
  756. content: '18px';
  757. }
  758. .ql-snow .ql-picker.ql-size .ql-picker-label[data-value='32px']::before,
  759. .ql-snow .ql-picker.ql-size .ql-picker-item[data-value='32px']::before {
  760. content: '32px';
  761. }
  762. .ql-snow .ql-picker.ql-header .ql-picker-label::before,
  763. .ql-snow .ql-picker.ql-header .ql-picker-item::before {
  764. content: '文本';
  765. }
  766. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='1']::before,
  767. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='1']::before {
  768. content: '标题1';
  769. }
  770. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='2']::before,
  771. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='2']::before {
  772. content: '标题2';
  773. }
  774. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='3']::before,
  775. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='3']::before {
  776. content: '标题3';
  777. }
  778. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='4']::before,
  779. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='4']::before {
  780. content: '标题4';
  781. }
  782. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='5']::before,
  783. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='5']::before {
  784. content: '标题5';
  785. }
  786. .ql-snow .ql-picker.ql-header .ql-picker-label[data-value='6']::before,
  787. .ql-snow .ql-picker.ql-header .ql-picker-item[data-value='6']::before {
  788. content: '标题6';
  789. }
  790. .ql-snow .ql-picker.ql-font .ql-picker-label::before,
  791. .ql-snow .ql-picker.ql-font .ql-picker-item::before {
  792. content: '标准字体';
  793. }
  794. .ql-snow .ql-picker.ql-font .ql-picker-label[data-value='serif']::before,
  795. .ql-snow .ql-picker.ql-font .ql-picker-item[data-value='serif']::before {
  796. content: '衬线字体';
  797. }
  798. .ql-snow .ql-picker.ql-font .ql-picker-label[data-value='monospace']::before,
  799. .ql-snow .ql-picker.ql-font .ql-picker-item[data-value='monospace']::before {
  800. content: '等宽字体';
  801. }
  802. .editor .ql-editor {
  803. white-space: pre-wrap; /* 强制换行 */
  804. word-break: break-all; /* 防止长单词或URL溢出 */
  805. }
  806. .editor .ql-editor img,
  807. .editor .ql-editor video {
  808. max-width: 100%; /* 自适应编辑器宽度,不溢出 */
  809. }
  810. .editor .ql-editor video {
  811. margin: 8px 0;
  812. }
  813. </style>