From 896fc04f38fec9b85d3743b0325fbcbbac06aec3 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 29 Oct 2026 15:57:03 -0700 Subject: [PATCH] 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> --- ...own-preview-search-word-boundaries.test.ts | 36 +++++++++++++++++++ src/renderer/src/lib/text-match-ranges.ts | 24 ++++++++++--- 2 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/components/editor/markdown-preview-search-word-boundaries.test.ts diff --git a/src/renderer/src/components/editor/markdown-preview-search-word-boundaries.test.ts b/src/renderer/src/components/editor/markdown-preview-search-word-boundaries.test.ts new file mode 100644 index 00000000000..cf3b6666e36 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-search-word-boundaries.test.ts @@ -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 } + ]) + }) +}) diff --git a/src/renderer/src/lib/text-match-ranges.ts b/src/renderer/src/lib/text-match-ranges.ts index 41ef71534c1..b1395de64f3 100644 --- a/src/renderer/src/lib/text-match-ranges.ts +++ b/src/renderer/src/lib/text-match-ranges.ts @@ -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): {