fix: handle overlapping avatar names in highlightAvatarNames (#77)

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.
This commit is contained in:
Zihao Xu
2026-01-19 23:46:42 -08:00
committed by GitHub
parent d4b9b7303d
commit 498249e83b
2 changed files with 71 additions and 13 deletions

View File

@@ -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<string, AvatarColorInfo>([
['张三', { 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<string, AvatarColorInfo>([
['张三丰', { 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<string, AvatarColorInfo>([
['张三', { 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<string, AvatarColorInfo>([
['张三(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"')
})
})
})

View File

@@ -121,6 +121,13 @@ const HTML_ESCAPE_MAP: Record<string, string> = {
/**
* 高亮文本中的角色名,返回 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,
`<span class="clickable-avatar" data-avatar-id="${info.id}" style="color:${info.color};cursor:pointer">${escaped}</span>`
);
}
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 `<span class="clickable-avatar" data-avatar-id="${info.id}" style="color:${info.color};cursor:pointer">${escaped}</span>`;
});
}