diff --git a/src/vs/base/common/strings.ts b/src/vs/base/common/strings.ts index 1c50375d05ea77..d06ebfe892cf84 100644 --- a/src/vs/base/common/strings.ts +++ b/src/vs/base/common/strings.ts @@ -1205,6 +1205,20 @@ function getOffsetBeforeLastEmojiComponent(initialOffset: number, str: string): let resultOffset = iterator.offset; + if (isRegionalIndicator(codePoint)) { + // Regional indicators combine in pairs to form a single flag emoji + // (Unicode UAX #29 GB12/GB13: do not break within an emoji flag + // sequence). If this regional indicator completes such a pair, + // delete both code points together; otherwise it is a lone, + // unpaired indicator and only it should be deleted. + if (isRegionalIndicatorPaired(str, resultOffset)) { + const pairIterator = new CodePointIterator(str, resultOffset); + pairIterator.prevCodePoint(); + resultOffset = pairIterator.offset; + } + return resultOffset; + } + if (resultOffset > 0) { // Skip optional ZWJ code points that combine multiple emojis. // In theory, we should check if that ZWJ actually combines multiple emojis @@ -1222,6 +1236,27 @@ function isEmojiModifier(codePoint: number): boolean { return 0x1F3FB <= codePoint && codePoint <= 0x1F3FF; } +function isRegionalIndicator(codePoint: number): boolean { + return 0x1F1E6 <= codePoint && codePoint <= 0x1F1FF; +} + +/** + * Returns whether the regional indicator ending at `offset` (already counted + * by the caller) is paired, i.e. whether the full contiguous run of regional + * indicators it belongs to has even length. + */ +function isRegionalIndicatorPaired(str: string, offset: number): boolean { + let runLength = 1; + const scan = new CodePointIterator(str, offset); + while (scan.offset > 0) { + if (!isRegionalIndicator(scan.prevCodePoint())) { + break; + } + runLength++; + } + return runLength % 2 === 0; +} + const enum CodePoint { zwj = 0x200D, diff --git a/src/vs/base/test/common/strings.test.ts b/src/vs/base/test/common/strings.test.ts index 538888887317f8..cf12fe58f69a8d 100644 --- a/src/vs/base/test/common/strings.test.ts +++ b/src/vs/base/test/common/strings.test.ts @@ -763,6 +763,51 @@ suite('Strings', () => { assert.ok(strings.multibyteAwareBtoa(new Array(100000).fill('vs').join('')).length > 0); // https://github.com/microsoft/vscode/issues/112013 }); + suite('getLeftDeleteOffset', () => { + function backspaceSteps(str: string): string[] { + const steps: string[] = []; + let s = str; + let offset = s.length; + while (offset > 0) { + const newOffset = strings.getLeftDeleteOffset(offset, s); + s = s.substring(0, newOffset) + s.substring(offset); + offset = newOffset; + steps.push(s); + } + return steps; + } + + test('deletes an emoji modifier sequence as a single unit', () => { + // Skin tone modifier: baby + Fitzpatrick type-5 modifier. + assert.deepStrictEqual(backspaceSteps('\u{1F476}\u{1F3FE}'), ['']); + }); + + test('deletes a ZWJ emoji sequence component by component', () => { + // Family emoji: man ZWJ man ZWJ girl ZWJ girl. + assert.deepStrictEqual( + backspaceSteps('\u{1F468}\u{200D}\u{1F468}\u{200D}\u{1F467}\u{200D}\u{1F467}'), + ['\u{1F468}\u{200D}\u{1F468}\u{200D}\u{1F467}', '\u{1F468}\u{200D}\u{1F468}', '\u{1F468}', ''] + ); + }); + + test('issue: deletes a complete flag emoji as a single unit, without leaving a dangling regional indicator', () => { + // Flag emoji are two regional indicator code points (UAX #29 GB12/GB13); they must not be split. + assert.deepStrictEqual(backspaceSteps('\u{1F1FA}\u{1F1F8}'), ['']); // πŸ‡ΊπŸ‡Έ + }); + + test('issue: deletes adjacent flag emoji one flag at a time, not one indicator at a time', () => { + assert.deepStrictEqual(backspaceSteps('\u{1F1FA}\u{1F1F8}\u{1F1EC}\u{1F1E7}'), ['\u{1F1FA}\u{1F1F8}', '']); // πŸ‡ΊπŸ‡ΈπŸ‡¬πŸ‡§ + }); + + test('deletes a trailing unpaired regional indicator alone, then the completed flag as a unit', () => { + assert.deepStrictEqual(backspaceSteps('\u{1F1FA}\u{1F1F8}\u{1F1EC}'), ['\u{1F1FA}\u{1F1F8}', '']); // πŸ‡ΊπŸ‡ΈπŸ‡¬ + }); + + test('leaves non-emoji text untouched (single code point per backspace)', () => { + assert.deepStrictEqual(backspaceSteps('ab'), ['a', '']); + }); + }); + ensureNoDisposablesAreLeakedInTestSuite(); });