index.vue 35 KB

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