fix(editor): preserve combining marks in whole-word replacement (#34957)

* fix(editor): preserve combining marks in whole-word replacement

* fix(editor): keep whole-word boundaries after emoji presentation sequences

Resolve a leading combining mark to its base character so VS16 after an
emoji does not count as a word character, treat enclosing marks (keycaps)
as symbols, and drop the redundant hook-level undo/redo test.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-29 15:57:03 -07:00
committed by GitHub
co-authored by OrcaWin
parent e7e3e568e7
commit 896fc04f38
2 changed files with 55 additions and 5 deletions
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { findTextMatchRanges } from './markdown-preview-search'
describe('whole-word search around combining marks', () => {
it.each(['́', 'ि'])('keeps the trailing combining mark %s attached to its word', (mark) => {
const text = `cat${mark} cat ${mark}cat`
expect(findTextMatchRanges(text, 'cat', { wholeWord: true })).toEqual([
{ start: 4 + mark.length, end: 7 + mark.length },
{ start: 8 + 2 * mark.length, end: 11 + 2 * mark.length }
])
})
it('does not match a Devanagari word inside a longer word ending in a vowel sign', () => {
expect(findTextMatchRanges('किताबें किताब', 'किताब', { wholeWord: true })).toEqual([
{ start: 8, end: 13 }
])
})
it('finds a complete decomposed accented word and preserves source code-unit offsets', () => {
const text = 'café cafe caféine'
expect(findTextMatchRanges(text, 'café', { wholeWord: true })).toEqual([{ start: 0, end: 5 }])
expect(findTextMatchRanges(text, 'cafe', { wholeWord: true })).toEqual([{ start: 6, end: 10 }])
expect(findTextMatchRanges(text, 'cafe')).toHaveLength(3)
})
it.each([
['⚠️Warning here', 'Warning'],
['✔️Done', 'Done'],
['1️⃣Step', 'Step']
])('matches a whole word right after an emoji presentation sequence in %s', (text, query) => {
const start = text.indexOf(query)
expect(findTextMatchRanges(text, query, { wholeWord: true })).toEqual([
{ start, end: start + query.length }
])
})
})
+19 -5
View File
@@ -82,8 +82,10 @@ function findCaseInsensitiveMatchRanges(
return matches
}
// Treat Unicode letters/digits/underscore as word characters, matching editor whole-word find boundaries.
const WORD_CHARACTER = /[\p{L}\p{N}_]/u
// Combining marks stay attached to their letters when checking whole-word boundaries.
const WORD_CHARACTER = /[\p{L}\p{M}\p{N}_]/u
const COMBINING_MARK = /\p{M}/u
const ENCLOSING_MARK = /\p{Me}/u
function isWordCharacter(char: string | undefined): boolean {
return char !== undefined && WORD_CHARACTER.test(char)
@@ -113,10 +115,22 @@ function codePointAt(text: string, index: number): string | undefined {
return codePoint === undefined ? undefined : String.fromCodePoint(codePoint)
}
// A leading mark belongs to the preceding base (emoji+VS16 stays a boundary); enclosing marks make a symbol (keycaps).
function isWordGraphemeBefore(text: string, index: number): boolean {
let offset = index
let codePoint = codePointBefore(text, offset)
while (codePoint !== undefined && COMBINING_MARK.test(codePoint)) {
if (ENCLOSING_MARK.test(codePoint)) {
return false
}
offset -= codePoint.length
codePoint = codePointBefore(text, offset)
}
return isWordCharacter(codePoint)
}
function isWholeWordMatch(text: string, start: number, end: number): boolean {
const before = codePointBefore(text, start)
const after = codePointAt(text, end)
return !isWordCharacter(before) && !isWordCharacter(after)
return !isWordGraphemeBefore(text, start) && !isWordCharacter(codePointAt(text, end))
}
function buildLocaleLowercaseIndex(text: string): {