Browse Source

旅游推荐优化

wengan01 1 week ago
parent
commit
d82a720ba7
1 changed files with 96 additions and 85 deletions
  1. 96 85
      src/components/Editor/index.vue

+ 96 - 85
src/components/Editor/index.vue

@@ -70,38 +70,39 @@ Quill.register(SizeAttributor, true);
 Quill.register(Quill.import('attributors/style/align') as any, true);
 Quill.register(Quill.import('attributors/style/direction') as any, true);
 
-// 缩进改为"行首不间断空格"(首行缩进):曾用内联padding-left,但APP端渲染器把
-// padding-left作用到整段(所有行整体平移),不符合中文首行缩进观感。
-// U+00A0任何渲染器都不折叠、且只影响首行,该方案APP端零改动。
-// 缩进按钮不再走Quill的indent格式(会输出ql-indent class),实现见toolbar handlers.indent
-const INDENT_SPACES = 2; // 每级缩进的不间断空格数(即首行缩进2字符)
-const INDENT_MAX = 8; // 缩进层级上限
-const NBSP = '\u00a0';
-
-// 增加/减少缩进:对选区每一行行首插入或移除一级不间断空格(首行缩进,APP端原样显示)
-function applyIndent(direction: string) {
-  const quill = toRaw(quillEditorRef.value)?.getQuill();
-  if (!quill) return;
-  const range = quill.getSelection();
-  if (!range) return;
-  const lines = quill.getLines(range);
-  if (!lines || !lines.length) return;
-  // 从后往前处理:前行插入/删除文本会让后行的下标移位
-  for (let i = lines.length - 1; i >= 0; i--) {
-    const idx = (lines[i] as any).offset(quill.scroll);
-    const head = quill.getText(idx, INDENT_MAX * INDENT_SPACES);
-    let level = 0;
-    while (level < INDENT_MAX && head.startsWith(NBSP.repeat((level + 1) * INDENT_SPACES))) level++;
-    if (direction === '+1') {
-      if (level < INDENT_MAX) quill.insertText(idx, NBSP.repeat(INDENT_SPACES), 'user');
-    } else if (level > 0) {
-      quill.deleteText(idx, INDENT_SPACES, 'user');
-    } else if (head.startsWith(NBSP)) {
-      // 行首是单个不间断空格(手工敲的)时也允许退掉
-      quill.deleteText(idx, 1, 'user');
+// 缩进回退为内联padding-left(整段平移):字符式(行首U+00A0)首行缩进会被APP端渲染组件
+// 裁剪文本节点首尾空白时裁掉,到不了屏幕;padding-left是APP唯一能渲染的缩进样式,
+// 且编辑器显示与APP完全一致,两端所见即所得。
+// Quill默认缩进输出class(ql-indent-N),这里用自定义attributor把缩进层级承载为padding-left
+const INDENT_EM = 2; // 每级缩进对应的em数
+const INDENT_MAX = 8; // 与Quill默认缩进层级上限保持一致
+const StyleAttributorBase: any = (Quill.import('parchment') as any).StyleAttributor;
+class IndentStyleAttributor extends StyleAttributorBase {
+  add(node: HTMLElement, value: any) {
+    // 工具栏按钮和Tab键传的是'+1'/'-1',先换算成绝对层级
+    if (value === '+1' || value === '-1') {
+      const cur = this.value(node) || 0;
+      value = value === '+1' ? cur + 1 : cur - 1;
     }
+    const level = Math.min(Math.max(parseInt(value, 10) || 0, 0), INDENT_MAX);
+    if (level <= 0) return this.remove(node);
+    node.style.paddingLeft = `${level * INDENT_EM}em`;
+    return true;
+  }
+  value(node: HTMLElement) {
+    const raw = node.style ? node.style.paddingLeft : '';
+    if (!raw) return undefined;
+    const n = parseFloat(raw);
+    if (!n || n <= 0) return undefined;
+    const level = Math.round(raw.indexOf('px') >= 0 ? n / (INDENT_EM * 16) : n / INDENT_EM);
+    return level > 0 ? level : undefined;
+  }
+  remove(node: HTMLElement) {
+    node.style.paddingLeft = '';
+    return true;
   }
 }
+Quill.register(new IndentStyleAttributor('indent', 'padding-left', { scope: (Quill.import('parchment') as any).Scope.BLOCK }), true);
 
 // 视频用 <video> 标签插入:Quill默认video格式是iframe嵌入,APP端渲染器对iframe兼容差;
 // video标签在WebView原生可播、flutter_html等组件渲染也可扩展支持,兼容性严格优于iframe
@@ -158,8 +159,8 @@ const TOOLBAR_TIPS: [string, string][] = [
   ['button.ql-code-block', '代码块:选中内容设为代码块'],
   ['button.ql-list[value="ordered"]', '有序列表:选中内容加数字编号'],
   ['button.ql-list[value="bullet"]', '无序列表:选中内容加圆点符号'],
-  ['button.ql-indent[value="-1"]', '减少缩进:减少段落首行缩进(APP端同步生效)'],
-  ['button.ql-indent[value="+1"]', '增加缩进:段落首行缩进2字符(APP端同步生效)'],
+  ['button.ql-indent[value="-1"]', '减少缩进:降低段落缩进层级(APP端同步生效)'],
+  ['button.ql-indent[value="+1"]', '增加缩进:提高段落缩进层级(APP端同步生效)'],
   ['.ql-picker.ql-size .ql-picker-label', '字体大小:设置选中文字的字号'],
   ['.ql-picker.ql-header .ql-picker-label', '标题:切换段落标题级别'],
   ['.ql-picker.ql-color .ql-picker-label', '字体颜色:设置选中文字的颜色'],
@@ -315,10 +316,6 @@ const options = ref<any>({
           videoDialog.uploadedName = '';
           videoDialog.mode = 'url';
           videoDialog.visible = true;
-        },
-        // 缩进按钮:行首不间断空格实现首行缩进,不用Quill的indent格式(会输出ql-indent class,APP不认识)
-        indent: (value: string) => {
-          applyIndent(value);
         }
       }
     },
@@ -350,13 +347,13 @@ watch(
   () => props.modelValue,
   (v: string) => {
     if (v === lastEmitted) return;
-    // 旧数据缩进存的是Quill class(ql-indent-N)或上一版内联padding-left,统一转成行首不间断空格(首行缩进);
-    // 再把手工敲的空格/Tab统一成不间断空格,保证不编辑直接保存也能在APP生效。
+    // 旧数据缩进可能是Quill class(ql-indent-N)或上一版行首不间断空格,统一归一到padding-left内联样式;
+    // 再把手工敲的行内空格统一成不间断空格,保证不编辑直接保存也能在APP生效。
     // 转换在watch渲染期同步执行,一旦对畸形/超大内容抛错会连累整个Editor挂载失败、
     // 弹窗打不开(表现为点"修改"没反应),所以异常时降级为原始内容,绝不阻断渲染
     let next: string;
     try {
-      next = stabilizeSpacesHtml(migrateIndentHtml(v || ''));
+      next = spacesToPaddingHtml(stabilizeSpacesHtml(migrateIndentHtml(v || '')));
     } catch (e) {
       console.error('[Editor] 内容转换失败,降级为原始内容:', e);
       next = v || '';
@@ -558,11 +555,47 @@ function stabilizeSpacesHtml(html: string): string {
   return parts.join('');
 }
 
+// 行首空白缩进归一到padding-left:APP端渲染组件会裁掉文本节点首尾空白(U+00A0也被裁),
+// 字符式缩进到不了屏幕;块级标签行首的连续空白(2字符=1级)在这里并入padding-left内联样式,
+// 与缩进按钮同一载体,APP端才能渲染出来
+const LEAD_RUN_RE = /(<(?:p|div|h[1-6]|li|blockquote|pre)\b[^>]*>(?:<(?!br\b|img\b|video\b|\/)[^>]+>)*)([ \t\u00a0]{2,})/gi;
+function spacesToPaddingHtml(html: string): string {
+  if (!html || html.indexOf('>') < 0) return html;
+  return html.replace(LEAD_RUN_RE, (m: string, head: string, run: string) => {
+    const level = Math.floor(run.length / 2);
+    if (level < 1) return m;
+    return addPaddingLevel(head, level);
+  });
+}
+
+// 给块级开标签叠加padding-left层级:已有padding-left时累加,上限INDENT_MAX
+function addPaddingLevel(tag: string, addLevel: number): string {
+  const sm = /\sstyle\s*=\s*"([^"]*)"/i.exec(tag) || /\sstyle\s*=\s*'([^']*)'/i.exec(tag);
+  let level = addLevel;
+  if (sm) {
+    const pm = /padding-left\s*:\s*([^;"]+)/i.exec(sm[1]);
+    if (pm) {
+      const raw = pm[1].trim();
+      const n = parseFloat(raw);
+      if (n > 0) level += Math.round(raw.toLowerCase().indexOf('px') >= 0 ? n / (INDENT_EM * 16) : n / INDENT_EM);
+    }
+    level = Math.min(level, INDENT_MAX);
+    const rest = sm[1]
+      .replace(/padding-left\s*:\s*[^;"]+;?/gi, '')
+      .replace(/\s+/g, ' ')
+      .replace(/^;+/, '')
+      .trim();
+    return tag.replace(sm[0], ` style="padding-left: ${level * INDENT_EM}em;${rest ? ` ${rest}` : ''}"`);
+  }
+  level = Math.min(level, INDENT_MAX);
+  return tag.slice(0, -1) + ` style="padding-left: ${level * INDENT_EM}em;">`;
+}
+
 // 内容变化统一出口:先做"空格缩进APP化"转换再发给父组件
 function handleTextChange() {
   // 同watch:转换抛错时降级为原始内容,保证编辑/保存链路不因转换异常中断
   try {
-    lastEmitted = stabilizeSpacesHtml(content.value);
+    lastEmitted = spacesToPaddingHtml(stabilizeSpacesHtml(content.value));
   } catch (e) {
     console.error('[Editor] 内容转换失败,降级为原始内容:', e);
     lastEmitted = content.value;
@@ -570,50 +603,34 @@ function handleTextChange() {
   emit('update:modelValue', lastEmitted);
 }
 
-// 历史数据兼容:旧缩进(Quill class ql-indent-N / 上一版内联padding-left)转成行首不间断空格;
-// 幂等:转换后标记被移除,再次进入不会重复叠加缩进
+// 历史数据兼容:旧版缩进是Quill class(ql-indent-N),加载时转成内联padding-left;
+// 上一版行首不间断空格式缩进由spacesToPaddingHtml归一
 function migrateIndentHtml(html: string): string {
-  if (!html || (html.indexOf('ql-indent-') < 0 && html.toLowerCase().indexOf('padding-left') < 0)) return html;
+  if (!html || html.indexOf('ql-indent-') < 0) return html;
   return html.replace(/<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (tag: string, name: string, attrs: string) => {
-    if (!/^(?:p|div|h[1-6]|li|blockquote|pre)$/i.test(name)) return tag;
-    let level = 0;
-    let newAttrs = attrs;
     const cm = /\sclass\s*=\s*"([^"]*)"/i.exec(attrs) || /\sclass\s*=\s*'([^']*)'/i.exec(attrs);
-    if (cm) {
-      const im = /ql-indent-(\d+)/.exec(cm[1]);
-      if (im) {
-        level = Math.min(parseInt(im[1], 10) || 0, INDENT_MAX);
-        const restClass = cm[1]
-          .replace(/ql-indent-\d+/g, '')
-          .replace(/\s+/g, ' ')
-          .trim();
-        newAttrs = newAttrs.replace(cm[0], restClass ? ` class="${restClass}"` : '');
-      }
-    }
-    const sm = /\sstyle\s*=\s*"([^"]*)"/i.exec(newAttrs) || /\sstyle\s*=\s*'([^']*)'/i.exec(newAttrs);
+    if (!cm) return tag;
+    const im = /ql-indent-(\d+)/.exec(cm[1]);
+    if (!im) return tag;
+    const level = Math.min(parseInt(im[1], 10) || 0, INDENT_MAX);
+    if (level < 1) return tag;
+    const restClass = cm[1]
+      .replace(/ql-indent-\d+/g, '')
+      .replace(/\s+/g, ' ')
+      .trim();
+    let newAttrs = attrs.replace(cm[0], restClass ? ` class="${restClass}"` : '');
+    const pad = `padding-left: ${level * INDENT_EM}em;`;
+    const sm = /\sstyle\s*=\s*"([^"]*)"/i.exec(newAttrs);
     if (sm) {
-      const pm = /padding-left\s*:\s*([^;"]+)/i.exec(sm[1]);
-      if (pm) {
-        const raw = pm[1].trim();
-        const n = parseFloat(raw);
-        if (n > 0) {
-          const lv = Math.round(raw.toLowerCase().indexOf('px') >= 0 ? n / (INDENT_SPACES * 16) : n / INDENT_SPACES);
-          level = Math.max(level, Math.min(lv, INDENT_MAX));
-        }
-        const restStyle = sm[1]
-          .replace(/padding-left\s*:\s*[^;"]+;?/gi, '')
-          .replace(/\s+/g, ' ')
-          .replace(/^;+/, '')
-          .trim();
-        newAttrs = newAttrs.replace(sm[0], restStyle ? ` style="${restStyle}"` : '');
-      }
+      newAttrs = newAttrs.replace(sm[0], ` style="${pad}${sm[1]}"`);
+    } else {
+      newAttrs += ` style="${pad}"`;
     }
-    if (level < 1) return tag;
-    return `<${name}${newAttrs}>` + NBSP.repeat(level * INDENT_SPACES);
+    return `<${name}${newAttrs}>`;
   });
 }
 
-// 读缩进层级:兼容内联padding-left与旧版ql-indent-N class(粘贴内容用,转成行首不间断空格)
+// 读缩进层级:兼容内联padding-left与旧版ql-indent-N class(粘贴内容用)
 function readIndentLevel(el: HTMLElement): number {
   let level = 0;
   const cm = /ql-indent-(\d+)/.exec(el.getAttribute('class') || '');
@@ -621,18 +638,20 @@ function readIndentLevel(el: HTMLElement): number {
   const pl = el.style.paddingLeft;
   if (pl) {
     const n = parseFloat(pl);
-    if (n > 0) level = Math.max(level, Math.round(pl.indexOf('px') >= 0 ? n / (INDENT_SPACES * 16) : n / INDENT_SPACES));
+    if (n > 0) level = Math.max(level, Math.round(pl.indexOf('px') >= 0 ? n / (INDENT_EM * 16) : n / INDENT_EM));
   }
   return level;
 }
 
-// 插入一个带块级格式的换行符:Quill的对齐/标题/列表都是"行"格式,只认换行符上的属性
+// 插入一个带块级格式的换行符:Quill的缩进/对齐/标题/列表都是"行"格式,只认换行符上的属性
 function insertBlockBreak(delta: Delta, el: HTMLElement, listType: 'ordered' | 'bullet' | null) {
   const fmt: { [key: string]: any } = {};
   const hm = /^h([1-6])$/.exec(el.tagName.toLowerCase());
   if (hm) fmt.header = parseInt(hm[1], 10);
   if (el.style.textAlign) fmt.align = el.style.textAlign;
   if (listType) fmt.list = listType;
+  const level = readIndentLevel(el);
+  if (level > 0) fmt.indent = level;
   delta.insert('\n', Object.keys(fmt).length ? fmt : undefined);
 }
 
@@ -704,14 +723,6 @@ function buildDeltaWithListSupport(
   // 是否是块级元素(需要换行)
   const isBlock = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'br'].includes(tagName);
 
-  // 粘贴内容里的旧缩进(padding-left/ql-indent class)转成行首不间断空格,与编辑器缩进按钮同一方案
-  if (isBlock && tagName !== 'br') {
-    const indentLevel = readIndentLevel(el);
-    if (indentLevel > 0) {
-      delta.insert(NBSP.repeat(indentLevel * INDENT_SPACES));
-    }
-  }
-
   // 遍历子节点
   for (let i = 0; i < el.childNodes.length; i++) {
     const child = el.childNodes[i];