From 498249e83bceaf56bf07606231b4a935cfacd2d7 Mon Sep 17 00:00:00 2001 From: Zihao Xu Date: Mon, 19 Jan 2026 23:46:42 -0800 Subject: [PATCH] fix: handle overlapping avatar names in highlightAvatarNames (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use single-pass regex replacement instead of multiple replaceAll calls. This prevents shorter names from matching inside already-replaced longer names. For example, with names '张三' and '张三丰', the text '张三丰是大师' now correctly highlights only '张三丰', not '张三' within it. --- web/src/__tests__/utils/eventHelper.test.ts | 53 ++++++++++++++++++++- web/src/utils/eventHelper.ts | 31 +++++++----- 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/web/src/__tests__/utils/eventHelper.test.ts b/web/src/__tests__/utils/eventHelper.test.ts index d119529..1071f74 100644 --- a/web/src/__tests__/utils/eventHelper.test.ts +++ b/web/src/__tests__/utils/eventHelper.test.ts @@ -180,8 +180,7 @@ describe('eventHelper', () => { expect(result).toContain('<script>') }) - // TODO: Fix highlightAvatarNames to handle overlapping names properly. - it.skip('should match longer names first to avoid partial matches', () => { + it('should match longer names first to avoid partial matches', () => { const colorMap = new Map([ ['张三', { id: 'zhangsan', color: 'hsl(100, 70%, 65%)' }], ['张三丰', { id: 'zhangsanfeng', color: 'hsl(200, 70%, 65%)' }], @@ -196,5 +195,55 @@ describe('eventHelper', () => { const matches = result.match(/hsl\(100/g) expect(matches).toBeNull() }) + + it('should highlight multiple occurrences of the same name', () => { + const colorMap = new Map([ + ['张三丰', { id: 'zhangsanfeng', color: 'hsl(200, 70%, 65%)' }], + ['李白', { id: 'libai', color: 'hsl(300, 70%, 65%)' }], + ]) + const text = '张三丰和李白聊天,张三丰说了个笑话,李白笑了' + + const result = highlightAvatarNames(text, colorMap) + + // 张三丰 appears twice. + const zhangMatches = result.match(/hsl\(200/g) + expect(zhangMatches).toHaveLength(2) + // 李白 appears twice. + const liMatches = result.match(/hsl\(300/g) + expect(liMatches).toHaveLength(2) + }) + + it('should handle both overlapping names appearing in text', () => { + const colorMap = new Map([ + ['张三', { id: 'zhangsan', color: 'hsl(100, 70%, 65%)' }], + ['张三丰', { id: 'zhangsanfeng', color: 'hsl(200, 70%, 65%)' }], + ]) + const text = '张三丰和张三是朋友' + + const result = highlightAvatarNames(text, colorMap) + + // 张三丰 should be highlighted with hsl(200). + const zhangfengMatches = result.match(/hsl\(200/g) + expect(zhangfengMatches).toHaveLength(1) + // 张三 should be highlighted with hsl(100) exactly once (not inside 张三丰). + const zhangsanMatches = result.match(/hsl\(100/g) + expect(zhangsanMatches).toHaveLength(1) + }) + + it('should escape regex special characters in names', () => { + const colorMap = new Map([ + ['张三(test)', { id: 'zhangsan', color: 'hsl(100, 70%, 65%)' }], + ['李白[1]', { id: 'libai', color: 'hsl(200, 70%, 65%)' }], + ]) + const text = '张三(test)和李白[1]见面了' + + const result = highlightAvatarNames(text, colorMap) + + // Both names should be highlighted despite having regex special chars. + expect(result).toContain('hsl(100, 70%, 65%)') + expect(result).toContain('hsl(200, 70%, 65%)') + expect(result).toContain('data-avatar-id="zhangsan"') + expect(result).toContain('data-avatar-id="libai"') + }) }) }) diff --git a/web/src/utils/eventHelper.ts b/web/src/utils/eventHelper.ts index 3e91e37..cc5015b 100644 --- a/web/src/utils/eventHelper.ts +++ b/web/src/utils/eventHelper.ts @@ -121,6 +121,13 @@ const HTML_ESCAPE_MAP: Record = { /** * 高亮文本中的角色名,返回 HTML 字符串。 * 生成的 span 带有 data-avatar-id 属性,可用于点击跳转。 + * + * 实现说明: + * - 使用单次正则替换(而非多次 replaceAll),避免重叠名字问题。 + * - 例如 "张三" 和 "张三丰",多次 replaceAll 会导致 "张三丰" 中的 "张三" 被错误匹配。 + * - 单次正则 /(张三丰|张三)/g 配合长名字优先排序,正则引擎匹配 "张三丰" 后会跳过这3个字符, + * 不会再回头匹配 "张三"。 + * - /g flag 确保所有出现都被替换(一次扫描,callback 被调用多次)。 */ export function highlightAvatarNames( text: string, @@ -128,18 +135,20 @@ export function highlightAvatarNames( ): string { if (!text || colorMap.size === 0) return text; - // 按名字长度倒序排列,避免部分匹配(如 "张三" 匹配到 "张三丰")。 + // 按名字长度倒序排列,确保正则中长名字在前,优先匹配。 const names = [...colorMap.keys()].sort((a, b) => b.length - a.length); - let result = text; - for (const name of names) { - const info = colorMap.get(name)!; - const escaped = name.replace(/[&<>"']/g, c => HTML_ESCAPE_MAP[c] || c); - result = result.replaceAll( - name, - `${escaped}` - ); - } - return result; + // 转义正则特殊字符。 + const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + // 构建单一正则: /(name1|name2|name3)/g + // 正则引擎从左到右扫描,匹配成功后消费该位置,不会被更短的名字重复匹配。 + const pattern = new RegExp(names.map(escapeRegex).join('|'), 'g'); + + return text.replace(pattern, (match) => { + const info = colorMap.get(match)!; + const escaped = match.replace(/[&<>"']/g, c => HTML_ESCAPE_MAP[c] || c); + return `${escaped}`; + }); }