Jelajahi Sumber

feat: 长文本/表格要素高亮 + 避免嵌套

- 填充全部47个要素值(从原文提取段落和表格内容)
- 高亮逻辑重构:分为长文本和短文本两类
  - 长文本(paragraph/table):按行匹配block,整段高亮+左边框
  - 短文本(text):行内子串匹配高亮+下划线
  - 表格类:outline边框高亮
- 嵌套防护:被长文本覆盖的block内不再单独高亮短文本
- 点击高亮区域支持长文本/表格/短文本三种类型
何文松 1 Minggu lalu
induk
melakukan
84ffa196b5
1 mengubah file dengan 125 tambahan dan 25 penghapusan
  1. 125 25
      frontend/vue-demo/src/views/Editor.vue

+ 125 - 25
frontend/vue-demo/src/views/Editor.vue

@@ -739,50 +739,90 @@ function stripValueKeyPrefix(valueElementKey) {
   return idx >= 0 ? valueElementKey.substring(idx + 1) : valueElementKey
 }
 
-// 构建要素值映射表,用于在文档中高亮要素值文本
+// 构建要素值映射表,分为长文本和短文本两类
 function buildElementValueMap() {
-  const map = []
+  const longTexts = []   // paragraph/table 类型的长文本要素
+  const shortTexts = []  // text 类型的短文本要素
   const colors = [
     '#fff3cd', '#cce5ff', '#d4edda', '#f8d7da', '#e2d5f1',
     '#d1ecf1', '#ffeeba', '#c3e6cb', '#f5c6cb', '#d6d8db'
   ]
   let colorIdx = 0
   for (const elem of elements.value) {
-    // 值的 elementKey 带项目前缀,需要匹配
     const elemValues = values.value.filter(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
+    const elemType = elem.elementType || 'text'
     for (const val of elemValues) {
       const text = val.valueText
-      if (text && text.length >= 2) {
-        map.push({
-          text,
-          elementKey: elem.elementKey,
-          fullElementKey: val.elementKey,  // 带前缀,用于 API 调用
-          elementName: elem.elementName,
-          valueId: val.valueId,
-          color: colors[colorIdx % colors.length]
-        })
+      if (!text || text.length < 2) continue
+      const entry = {
+        text,
+        elementKey: elem.elementKey,
+        fullElementKey: val.elementKey,
+        elementName: elem.elementName,
+        valueId: val.valueId,
+        elemType,
+        color: colors[colorIdx % colors.length]
+      }
+      // paragraph/table 类型或多行文本视为长文本
+      if (elemType === 'paragraph' || elemType === 'table' || text.includes('\n') || text.length > 100) {
+        longTexts.push(entry)
+      } else {
+        shortTexts.push(entry)
       }
     }
     colorIdx++
   }
-  // 按文本长度降序排列,优先匹配长文本
-  map.sort((a, b) => b.text.length - a.text.length)
-  return map
+  // 长文本按长度降序
+  longTexts.sort((a, b) => b.text.length - a.text.length)
+  // 短文本按长度降序
+  shortTexts.sort((a, b) => b.text.length - a.text.length)
+  return { longTexts, shortTexts }
 }
 
 // 将文档 blocks 渲染为 HTML 字符串(含要素高亮)
 function renderDocHtml() {
   if (!docContent.value?.blocks) { docHtml.value = ''; return }
   const blocks = docContent.value.blocks
-  const elemMap = highlightEnabled.value ? buildElementValueMap() : []
+  const { longTexts, shortTexts } = highlightEnabled.value ? buildElementValueMap() : { longTexts: [], shortTexts: [] }
   let highlightCount = 0
   const parts = []
 
+  // 预处理:为每个长文本要素,将其 valueText 按行拆分为句子集合
+  // 用于判断某个 block 的文本是否属于某个长文本要素
+  const longTextLines = longTexts.map(lt => ({
+    ...lt,
+    lines: new Set(lt.text.split('\n').map(l => l.trim()).filter(l => l.length > 0))
+  }))
+
+  // 收集被长文本高亮覆盖的 block IDs,这些 block 内的短文本不再单独高亮
+  const longHighlightedBlockIds = new Set()
+
+  // 第一遍:确定哪些 block 属于长文本要素
+  for (const block of blocks) {
+    const blockText = getBlockPlainText(block)
+    if (!blockText) continue
+    for (const lt of longTextLines) {
+      if (lt.lines.has(blockText)) {
+        longHighlightedBlockIds.add(block.id)
+        break
+      }
+    }
+  }
+
+  // 第二遍:渲染
   for (const block of blocks) {
     if (block.type === 'table') {
-      parts.push(renderTableHtml(block))
+      // 检查表格是否属于某个长文本要素
+      const tableMatch = findTableLongTextMatch(block, longTextLines)
+      parts.push(renderTableHtml(block, tableMatch))
+      if (tableMatch) highlightCount++
     } else {
-      parts.push(renderBlockHtml(block, elemMap, (n) => { highlightCount += n }))
+      const blockText = getBlockPlainText(block)
+      // 检查是否属于某个长文本要素
+      const longMatch = findBlockLongTextMatch(blockText, longTextLines)
+      // 如果此 block 被长文本覆盖,只做长文本高亮,不做短文本高亮
+      const shortMap = longMatch ? [] : shortTexts
+      parts.push(renderBlockHtml(block, shortMap, longMatch, (n) => { highlightCount += n }))
     }
   }
 
@@ -790,7 +830,32 @@ function renderDocHtml() {
   docHtml.value = parts.join('')
 }
 
-function renderBlockHtml(block, elemMap, countFn) {
+// 获取 block 的纯文本
+function getBlockPlainText(block) {
+  if (!block.runs) return ''
+  return block.runs.map(r => r.text).join('').trim()
+}
+
+// 查找 block 文本是否匹配某个长文本要素
+function findBlockLongTextMatch(blockText, longTextLines) {
+  if (!blockText) return null
+  for (const lt of longTextLines) {
+    if (lt.lines.has(blockText)) return lt
+  }
+  return null
+}
+
+// 查找表格是否匹配某个长文本要素(通过表格第一行文本匹配)
+function findTableLongTextMatch(block, longTextLines) {
+  if (!block.table?.data?.length) return null
+  const firstRowText = block.table.data[0].map(c => c.text).join(' | ')
+  for (const lt of longTextLines) {
+    if (lt.text.includes(firstRowText)) return lt
+  }
+  return null
+}
+
+function renderBlockHtml(block, shortMap, longMatch, countFn) {
   const tag = getBlockTag(block.type)
   const cls = `doc-block doc-${block.type}`
   const styleStr = buildStyleStr(block.style)
@@ -815,10 +880,15 @@ function renderBlockHtml(block, elemMap, countFn) {
       const rs = buildRunStyleStr(run)
       runsHtml += rs ? `<span style="${rs}">${text}</span>` : text
     }
-    // 注入要素高亮
-    if (elemMap.length > 0 && runsHtml.length > 0) {
+
+    // 长文本高亮:整个 block 内容包裹
+    if (longMatch) {
+      runsHtml = `<span class="elem-highlight elem-highlight-long" data-elem-key="${longMatch.elementKey}" data-value-id="${longMatch.valueId || ''}" style="background:${longMatch.color};border-left:3px solid ${darkenColor(longMatch.color)};padding-left:4px;cursor:pointer;display:inline;" contenteditable="true" title="${escapeAttr(longMatch.elementName)}">${runsHtml}</span>`
+      countFn(1)
+    } else if (shortMap.length > 0 && runsHtml.length > 0) {
+      // 短文本高亮:在 runsHtml 中查找并替换
       let count = 0
-      for (const em of elemMap) {
+      for (const em of shortMap) {
         const escaped = escapeHtml(em.text)
         if (runsHtml.includes(escaped)) {
           const hl = `<span class="elem-highlight" data-elem-key="${em.elementKey}" data-value-id="${em.valueId || ''}" style="background:${em.color};border-bottom:2px solid ${darkenColor(em.color)};cursor:pointer;border-radius:2px;padding:0 1px;" contenteditable="false" title="${escapeAttr(em.elementName)}: ${escapeAttr(em.text)}">${escaped}</span>`
@@ -835,10 +905,13 @@ function renderBlockHtml(block, elemMap, countFn) {
   return `<${tag} class="${cls}"${styleAttr} data-block-id="${block.id}">${inner}</${tag}>`
 }
 
-function renderTableHtml(block) {
+function renderTableHtml(block, longMatch) {
   const t = block.table
   if (!t?.data) return ''
-  let html = `<table class="doc-table" data-block-id="${block.id}">`
+  const hlClass = longMatch ? ' elem-highlight-table' : ''
+  const hlAttr = longMatch ? ` data-elem-key="${longMatch.elementKey}" data-value-id="${longMatch.valueId || ''}"` : ''
+  const hlStyle = longMatch ? ` style="outline:2px solid ${darkenColor(longMatch.color)};outline-offset:2px;background:${longMatch.color}22;"` : ''
+  let html = `<table class="doc-table${hlClass}" data-block-id="${block.id}"${hlAttr}${hlStyle}>`
   for (let ri = 0; ri < t.data.length; ri++) {
     html += '<tr>'
     for (const cell of t.data[ri]) {
@@ -848,6 +921,9 @@ function renderTableHtml(block) {
     html += '</tr>'
   }
   html += '</table>'
+  if (longMatch) {
+    html = `<div class="elem-highlight-wrap" data-elem-key="${longMatch.elementKey}" data-value-id="${longMatch.valueId || ''}" title="${escapeAttr(longMatch.elementName)}" style="cursor:pointer;">${html}</div>`
+  }
   return html
 }
 
@@ -916,7 +992,7 @@ function onDocInput() {
 
 // 点击文档中的高亮要素
 function onDocClick(e) {
-  const target = e.target.closest('.elem-highlight')
+  const target = e.target.closest('.elem-highlight') || e.target.closest('.elem-highlight-wrap') || e.target.closest('.elem-highlight-table')
   if (!target) {
     highlightPopover.visible = false
     return
@@ -3571,5 +3647,29 @@ onMounted(async () => {
     box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15), 0 0 0 2px rgba(24, 144, 255, 0.2);
   }
 }
+
+// 长文本高亮样式
+.elem-highlight-long {
+  display: inline !important;
+  border-radius: 2px;
+  transition: background 0.2s;
+}
+
+.elem-highlight-wrap {
+  position: relative;
+
+  &:hover {
+    outline: 2px solid #1890ff !important;
+    outline-offset: 2px;
+  }
+}
+
+.elem-highlight-table {
+  cursor: pointer;
+
+  &:hover {
+    outline-color: #1890ff !important;
+  }
+}
 </style>