Procházet zdrojové kódy

fix: 短文本高亮支持跨run拆分的文本匹配

- 新增 highlightRunsWithElements:基于纯文本位置匹配
- 构建plainText + charToRun映射,在纯文本中查找匹配
- 按run边界拆分segment,重建HTML保留每个run的样式
- 解决BZ-0092-2024等被Word拆为多个run导致无法高亮的问题
- 自动去重重叠匹配,优先保留长匹配
何文松 před 1 týdnem
rodič
revize
753fd95da1
1 změnil soubory, kde provedl 113 přidání a 35 odebrání
  1. 113 35
      frontend/vue-demo/src/views/Editor.vue

+ 113 - 35
frontend/vue-demo/src/views/Editor.vue

@@ -903,31 +903,21 @@ function renderBlockHtml(block, shortMap, longMatch, countFn) {
   }
   // Runs
   if (block.runs) {
-    let runsHtml = ''
-    for (const run of block.runs) {
-      const text = escapeHtml(run.text)
-      const rs = buildRunStyleStr(run)
-      runsHtml += rs ? `<span style="${rs}">${text}</span>` : text
-    }
-
     // 长文本高亮:不在 runs 层面处理,在外层 block 包裹
-    if (longMatch) {
-      countFn(1)
-    } else if (shortMap.length > 0 && runsHtml.length > 0) {
-      // 短文本高亮:只替换 HTML 标签外的纯文本部分,避免破坏已有标签
-      let count = 0
-      for (const em of shortMap) {
-        const escaped = escapeHtml(em.text)
-        const hl = `<span class="elem-highlight" data-elem-key="${em.elementKey}" data-value-id="${em.valueId || ''}" style="border:1.5px solid ${darkenColor(em.color)};border-radius:3px;padding:0 2px;cursor:pointer;" contenteditable="false" title="${escapeAttr(em.elementName)}">${escaped}</span>`
-        const newHtml = replaceTextOutsideTags(runsHtml, escaped, hl)
-        if (newHtml !== runsHtml) {
-          runsHtml = newHtml
-          count++
-        }
-      }
-      if (count > 0) countFn(count)
+    if (longMatch || shortMap.length === 0) {
+      // 无需短文本高亮,直接渲染
+      for (const run of block.runs) {
+        const text = escapeHtml(run.text)
+        const rs = buildRunStyleStr(run)
+        inner += rs ? `<span style="${rs}">${text}</span>` : text
+      }
+      if (longMatch) countFn(1)
+    } else {
+      // 短文本高亮:基于纯文本位置匹配,支持跨 run 拆分的文本
+      const result = highlightRunsWithElements(block.runs, shortMap)
+      inner += result.html
+      if (result.count > 0) countFn(result.count)
     }
-    inner += runsHtml
   }
 
   if (!inner) inner = '&nbsp;'
@@ -1001,20 +991,108 @@ function escapeAttr(text) {
   return text.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
 }
 
-// 只替换 HTML 标签外部的纯文本中的目标字符串,避免破坏已有标签和属性
-function replaceTextOutsideTags(html, search, replacement) {
-  // 将 HTML 拆分为:标签部分 和 文本部分
-  // 正则匹配所有 HTML 标签(包括自闭合标签)
-  const parts = html.split(/(<[^>]+>)/g)
-  let replaced = false
-  for (let i = 0; i < parts.length; i++) {
-    // 奇数索引是标签,偶数索引是文本
-    if (i % 2 === 0 && parts[i].includes(search)) {
-      parts[i] = parts[i].split(search).join(replacement)
-      replaced = true
+// 基于纯文本位置的短文本高亮,支持跨 run 拆分的文本匹配
+function highlightRunsWithElements(runs, shortMap) {
+  // 1. 构建纯文本和每个字符到 run 的映射
+  let plainText = ''
+  const charToRun = [] // charToRun[i] = { runIdx, offsetInRun }
+  for (let ri = 0; ri < runs.length; ri++) {
+    const t = runs[ri].text || ''
+    for (let ci = 0; ci < t.length; ci++) {
+      charToRun.push({ runIdx: ri, offsetInRun: ci })
+      plainText += t[ci]
     }
   }
-  return replaced ? parts.join('') : html
+
+  // 2. 在纯文本中查找所有要素值的匹配位置
+  const matches = [] // { start, end, em }
+  for (const em of shortMap) {
+    const val = em.text
+    if (!val || val.length < 2) continue
+    let pos = 0
+    while (true) {
+      const idx = plainText.indexOf(val, pos)
+      if (idx < 0) break
+      matches.push({ start: idx, end: idx + val.length, em })
+      pos = idx + val.length
+    }
+  }
+
+  // 3. 去重:按 start 排序,移除被更长匹配覆盖的(已按长度降序排列的 shortMap 保证优先)
+  matches.sort((a, b) => a.start - b.start || b.end - a.end)
+  const filtered = []
+  let lastEnd = -1
+  for (const m of matches) {
+    if (m.start >= lastEnd) {
+      filtered.push(m)
+      lastEnd = m.end
+    }
+  }
+
+  if (filtered.length === 0) {
+    // 无匹配,直接渲染
+    let html = ''
+    for (const run of runs) {
+      const text = escapeHtml(run.text)
+      const rs = buildRunStyleStr(run)
+      html += rs ? `<span style="${rs}">${text}</span>` : text
+    }
+    return { html, count: 0 }
+  }
+
+  // 4. 将纯文本按匹配区间切分为:普通段 + 高亮段
+  const segments = [] // { start, end, em: null|object }
+  let cursor = 0
+  for (const m of filtered) {
+    if (m.start > cursor) {
+      segments.push({ start: cursor, end: m.start, em: null })
+    }
+    segments.push({ start: m.start, end: m.end, em: m.em })
+    cursor = m.end
+  }
+  if (cursor < plainText.length) {
+    segments.push({ start: cursor, end: plainText.length, em: null })
+  }
+
+  // 5. 对每个 segment,按 run 边界拆分并生成 HTML
+  let html = ''
+  for (const seg of segments) {
+    const segChars = charToRun.slice(seg.start, seg.end)
+    // 按 runIdx 分组
+    const groups = []
+    let curGroup = null
+    for (const ch of segChars) {
+      if (!curGroup || curGroup.runIdx !== ch.runIdx) {
+        curGroup = { runIdx: ch.runIdx, startOffset: ch.offsetInRun, endOffset: ch.offsetInRun + 1 }
+        groups.push(curGroup)
+      } else {
+        curGroup.endOffset = ch.offsetInRun + 1
+      }
+    }
+
+    if (seg.em) {
+      // 高亮段:先开 highlight span,内部保留各 run 的样式
+      const em = seg.em
+      html += `<span class="elem-highlight" data-elem-key="${em.elementKey}" data-value-id="${em.valueId || ''}" style="border:1.5px solid ${darkenColor(em.color)};border-radius:3px;padding:0 2px;cursor:pointer;" contenteditable="false" title="${escapeAttr(em.elementName)}">`
+      for (const g of groups) {
+        const run = runs[g.runIdx]
+        const slice = escapeHtml(run.text.substring(g.startOffset, g.endOffset))
+        const rs = buildRunStyleStr(run)
+        html += rs ? `<span style="${rs}">${slice}</span>` : slice
+      }
+      html += '</span>'
+    } else {
+      // 普通段:保留 run 样式
+      for (const g of groups) {
+        const run = runs[g.runIdx]
+        const slice = escapeHtml(run.text.substring(g.startOffset, g.endOffset))
+        const rs = buildRunStyleStr(run)
+        html += rs ? `<span style="${rs}">${slice}</span>` : slice
+      }
+    }
+  }
+
+  return { html, count: filtered.length }
 }
 
 function darkenColor(hex) {