From 2b6ddb20d56f7990669c0382854333018b14a315 Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Mon, 13 Jul 2026 13:19:31 +0530 Subject: [PATCH] fix(editor): account for NFC boundary composition in insert offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor.modifyText builds newText from prefix + insert + suffix and hands it to Cursor.fromText, which NFC-normalizes the whole string. But the new cursor offset was computed as startOffset + insertString.normalize('NFC').length, normalizing the insert in isolation. When the inserted text begins with a combining mark that composes with the last character of the prefix (e.g. "e" + U+0301 -> "é"), the normalized newText is one UTF-16 unit shorter than that formula assumes, so the returned offset overshoots by one and the cursor lands past the following text — the next keystroke then edits the wrong spot. Measure the normalized prefix-plus-insert instead, so cross-boundary composition is accounted for. Reduces to the previous behavior whenever no boundary composition occurs (plain ASCII, astral emoji, insert at start). --- src/utils/Cursor.nfc.test.ts | 31 +++++++++++++++++++++++++++++++ src/utils/Cursor.ts | 16 +++++++++++----- 2 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 src/utils/Cursor.nfc.test.ts diff --git a/src/utils/Cursor.nfc.test.ts b/src/utils/Cursor.nfc.test.ts new file mode 100644 index 0000000000..776c882952 --- /dev/null +++ b/src/utils/Cursor.nfc.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' +import { Cursor } from './Cursor.js' + +describe('Cursor.insert NFC boundary offset', () => { + test('places the cursor correctly when the insert composes with the prefix', () => { + // Cursor sits between "e" and "X". Inserting a combining acute accent + // composes with the "e" into a single "é", so the normalized text is "éX" + // (length 2) and the cursor belongs at offset 1 (before "X"). The old code + // measured the insert in isolation and landed at offset 2 (after "X"). + const c = Cursor.fromText('eX', 80, 1) + expect(c.text).toBe('eX') + + const after = c.insert('́') + expect(after.text).toBe('éX') + expect(after.offset).toBe(1) + }) + + test('non-composing inserts still advance by the inserted length', () => { + const c = Cursor.fromText('abcX', 80, 3) // between "abc" and "X" + const after = c.insert('de') + expect(after.text).toBe('abcdeX') + expect(after.offset).toBe(5) + }) + + test('astral-plane insert advances by its UTF-16 unit count', () => { + const c = Cursor.fromText('X', 80, 0) + const after = c.insert('😀') // 2 UTF-16 code units + expect(after.text).toBe('😀X') + expect(after.offset).toBe(2) + }) +}) diff --git a/src/utils/Cursor.ts b/src/utils/Cursor.ts index 4622ea0acc..660bf1a359 100644 --- a/src/utils/Cursor.ts +++ b/src/utils/Cursor.ts @@ -875,11 +875,17 @@ export class Cursor { insertString + this.text.slice(endOffset) - return Cursor.fromText( - newText, - this.columns, - startOffset + insertString.normalize('NFC').length, - ) + // Cursor.fromText NFC-normalizes the whole newText, so compute the new + // offset from the normalized prefix-plus-insert rather than normalizing + // insertString in isolation. Otherwise a combining mark that composes with + // the last character of the prefix (e.g. "e" + U+0301 -> "é") shortens the + // normalized text by one unit that this offset doesn't account for, landing + // the cursor one position too far (past following text). + const newOffset = ( + this.text.slice(0, startOffset) + insertString + ).normalize('NFC').length + + return Cursor.fromText(newText, this.columns, newOffset) } insert(insertString: string): Cursor {