Pārlūkot izejas kodu

赛事专区bug修复

wengan01 2 nedēļas atpakaļ
vecāks
revīzija
3ba714df18
1 mainītis faili ar 136 papildinājumiem un 13 dzēšanām
  1. 136 13
      src/components/Editor/index.vue

+ 136 - 13
src/components/Editor/index.vue

@@ -21,7 +21,7 @@
       content-type="html"
       :options="options"
       :style="styles"
-      @text-change="(e: any) => $emit('update:modelValue', content)"
+      @text-change="handleTextChange"
     />
   </div>
   <!-- 视频插入弹窗:网页链接 / 本地上传 二选一 -->
@@ -70,6 +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);
 
+// 缩进改为内联样式:Quill默认缩进输出class(ql-indent-N),APP端渲染器只认内联样式,
+// 后台加的缩进到APP上会全部丢失(正文贴边、无法自行调整边距)。
+// 这里用padding-left承载缩进层级,编辑器与APP看到的效果一致
+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
 const BlockEmbed = Quill.import('blots/block/embed') as any;
@@ -125,8 +158,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"]', '减少缩进:降低段落缩进层级'],
-  ['button.ql-indent[value="+1"]', '增加缩进:提高段落缩进层级'],
+  ['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', '字体颜色:设置选中文字的颜色'],
@@ -200,7 +233,7 @@ function bindToolbarTips() {
 }
 bindToolbarTips();
 
-defineEmits(['update:modelValue']);
+const emit = defineEmits(['update:modelValue']);
 
 const props = defineProps({
   /* 编辑器的内容 */
@@ -306,11 +339,23 @@ const styles = computed(() => {
 });
 
 const content = ref('');
+// 记录最后一次发给父组件的HTML,用于识别自己输出的"回声":回声不能回写编辑器,
+// 否则敲空格时每次按键都会整篇重建内容(光标跳动、中文输入法被打断)
+let lastEmitted = '';
 watch(
   () => props.modelValue,
   (v: string) => {
-    if (v !== content.value) {
-      content.value = v || '<p></p>';
+    if (v === lastEmitted) return;
+    // 旧数据缩进存的是Quill class(ql-indent-N),先转成内联padding-left;
+    // 再把手工敲的空格/Tab统一成不间断空格,保证不编辑直接保存也能在APP生效
+    const next = stabilizeSpacesHtml(migrateIndentHtml(v || ''));
+    if (next !== content.value) {
+      content.value = next || '<p></p>';
+      // 转换结果同步回父组件表单,避免未编辑直接保存时丢掉空格缩进
+      if (next) {
+        lastEmitted = next;
+        emit('update:modelValue', next);
+      }
     }
   },
   { immediate: true }
@@ -476,6 +521,84 @@ function cleanBackgroundOnly(parent: HTMLElement) {
   });
 }
 
+// 手工空格缩进也要在APP生效:HTML默认折叠行首和连续空格,编辑器靠white-space:pre-wrap
+// 才能原样显示,APP端渲染器按标准HTML折叠,用户自己敲的空格就失效了。
+// 输出时把每行行首空白、行内连续空白换成不间断空格(U+00A0),任何渲染器都原样保留
+const SPACE_RISK_RE = /(^|>)[ \t]|[ \t]{2,}/;
+function stabilizeSpacesHtml(html: string): string {
+  if (!html || !SPACE_RISK_RE.test(html)) return html;
+  let out = html;
+  // 1) 段落行首空白:中间允许隔内联开标签或已转换的不间断空格,多轮收敛
+  const leadRe = /(<(?:p|div|h[1-6]|li|blockquote|pre)\b[^>]*>(?:\u00a0|<(?!br\b|img\b|video\b|\/)[^>]+>)*)([ \t]+)/g;
+  for (let pass = 0; pass < 4; pass++) {
+    const next = out.replace(leadRe, (_m: string, head: string, run: string) => head + '\u00a0'.repeat(run.length));
+    if (next === out) break;
+    out = next;
+  }
+  // 2) 行内连续空格与Tab:按标签切段后只改文本段,不动属性里的空格
+  const parts = out.split(/(<[^>]*>)/);
+  for (let i = 0; i < parts.length; i += 2) {
+    if (parts[i] && /\t| {2,}/.test(parts[i])) {
+      parts[i] = parts[i].replace(/\t/g, '\u00a0\u00a0\u00a0\u00a0').replace(/ {2,}/g, (m) => '\u00a0'.repeat(m.length));
+    }
+  }
+  return parts.join('');
+}
+
+// 内容变化统一出口:先做"空格缩进APP化"转换再发给父组件
+function handleTextChange() {
+  lastEmitted = stabilizeSpacesHtml(content.value);
+  emit('update:modelValue', lastEmitted);
+}
+
+// 历史数据兼容:旧版缩进是Quill class(ql-indent-N),加载时转成内联padding-left
+function migrateIndentHtml(html: string): string {
+  if (!html || html.indexOf('ql-indent-') < 0) return html;
+  return html.replace(/<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (tag: string, name: string, attrs: string) => {
+    const cm = /\sclass\s*=\s*"([^"]*)"/i.exec(attrs) || /\sclass\s*=\s*'([^']*)'/i.exec(attrs);
+    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) {
+      newAttrs = newAttrs.replace(sm[0], ` style="${pad}${sm[1]}"`);
+    } else {
+      newAttrs += ` style="${pad}"`;
+    }
+    return `<${name}${newAttrs}>`;
+  });
+}
+
+// 读缩进层级:兼容内联padding-left与旧版ql-indent-N class
+function readIndentLevel(el: HTMLElement): number {
+  let level = 0;
+  const cm = /ql-indent-(\d+)/.exec(el.getAttribute('class') || '');
+  if (cm) level = parseInt(cm[1], 10) || 0;
+  const pl = el.style.paddingLeft;
+  if (pl) {
+    const n = parseFloat(pl);
+    if (n > 0) level = Math.max(level, Math.round(pl.indexOf('px') >= 0 ? n / (INDENT_EM * 16) : n / INDENT_EM));
+  }
+  return level;
+}
+
+// 插入一个带块级格式的换行符:Quill的缩进/对齐/标题/列表都是"行"格式,只认换行符上的属性
+function insertBlockBreak(delta: Delta, el: HTMLElement, listType: 'ordered' | 'bullet' | null) {
+  const fmt: { [key: string]: any } = {};
+  const hm = /^h([1-6])$/.exec(el.tagName.toLowerCase());
+  if (hm) fmt.header = parseInt(hm[1], 10);
+  if (el.style.textAlign) fmt.align = el.style.textAlign;
+  if (listType) fmt.list = listType;
+  const level = readIndentLevel(el);
+  if (level > 0) fmt.indent = level;
+  delta.insert('\n', Object.keys(fmt).length ? fmt : undefined);
+}
+
 // ✅ 支持列表的 Delta 构建
 function buildDeltaWithListSupport(
   node: Node,
@@ -548,19 +671,19 @@ function buildDeltaWithListSupport(
   for (let i = 0; i < el.childNodes.length; i++) {
     const child = el.childNodes[i];
 
-    if (child.nodeType === Node.TEXT_NODE && isBlock && i === 0) {
-      // 块级元素开头插入换行(除了第一个)
-      if (delta.length() > 0) {
-        delta.insert('\n');
-      }
+    // <br>就是硬换行:直接换成带块级格式的换行符(Quill行内没有br)
+    if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName === 'BR' && isBlock) {
+      insertBlockBreak(delta, el, listType);
+      continue;
     }
 
     buildDeltaWithListSupport(child, delta, currentFormat, newListType);
 
-    // 如果是块级元素且不是最后一个,加换行
+    // 块级元素结尾的换行承载该段的块级格式(缩进/对齐/标题/列表);
+    // 开头不再补换行:结尾换行已起到分段作用,再补会把段间距翻倍
     if (isBlock && i === el.childNodes.length - 1 && (el.nextSibling || el.parentNode !== el.ownerDocument?.body)) {
       if (!delta.ops.length || delta.ops[delta.ops.length - 1].insert !== '\n') {
-        delta.insert('\n');
+        insertBlockBreak(delta, el, listType);
       }
     }
   }