|
@@ -903,31 +903,21 @@ function renderBlockHtml(block, shortMap, longMatch, countFn) {
|
|
|
}
|
|
}
|
|
|
// Runs
|
|
// Runs
|
|
|
if (block.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 包裹
|
|
// 长文本高亮:不在 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 = ' '
|
|
if (!inner) inner = ' '
|
|
@@ -1001,20 +991,108 @@ function escapeAttr(text) {
|
|
|
return text.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')
|
|
return text.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 只替换 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) {
|
|
function darkenColor(hex) {
|