From 55e817725547ebd03d51a1b5ff04cc5b580698c3 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 27 Oct 2026 20:53:36 +0000 Subject: [PATCH] Fix emoji bug on the demo website --- docs/advanced-ts.md | 18 ++++++++++++++++++ examples/website/src/index.ts | 21 ++++++++++++--------- reconcile-js/src/core.ts | 8 +++++++- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/advanced-ts.md b/docs/advanced-ts.md index c6d6957..6ffb09f 100644 --- a/docs/advanced-ts.md +++ b/docs/advanced-ts.md @@ -128,6 +128,24 @@ console.log(result.cursors); // [{ id: 2, position: 0 }, { id: 1, position: 3 }] > The `cursors` list is sorted by character position (not IDs). +Input and output cursor positions count Unicode scalars (code points), starting +at zero and allowing a position just after the last scalar. They do not count +UTF-16 code units or grapheme clusters. For example, the position after `😀` is +`1`, although JavaScript's `'😀'.length` is `2`. + +DOM `selectionStart` and `selectionEnd` use UTF-16 code units. Convert both +endpoints before passing a textarea selection to the library: + +```typescript +const edited = { + text: textarea.value, + cursors: [ + { id: 1, position: [...textarea.value.slice(0, textarea.selectionStart)].length }, + { id: 2, position: [...textarea.value.slice(0, textarea.selectionEnd)].length }, + ], +} satisfies TextWithOptionalCursors; +``` + ## Generic Helpers and Inference The exported merge functions are intentionally small: they merge strings, or diff --git a/examples/website/src/index.ts b/examples/website/src/index.ts index 54c4081..33f4fd7 100644 --- a/examples/website/src/index.ts +++ b/examples/website/src/index.ts @@ -1,5 +1,5 @@ import { reconcileWithHistory } from 'reconcile-text'; -import type { BuiltinTokenizer } from 'reconcile-text'; +import type { BuiltinTokenizer, CursorPosition } from 'reconcile-text'; import './style.scss'; const originalTextArea = document.getElementById('original') as HTMLTextAreaElement; @@ -157,19 +157,22 @@ function getCursorsFromActiveTextArea() { let rightCursors = undefined; if (activeElement === leftTextArea) { - leftCursors = [ - { id: 1, position: leftTextArea.selectionStart }, - { id: 2, position: leftTextArea.selectionEnd }, - ]; + leftCursors = getSelectionCursors(leftTextArea); } else if (activeElement === rightTextArea) { - rightCursors = [ - { id: 1, position: rightTextArea.selectionStart }, - { id: 2, position: rightTextArea.selectionEnd }, - ]; + rightCursors = getSelectionCursors(rightTextArea); } return { leftCursors, rightCursors }; } +function getSelectionCursors(textarea: HTMLTextAreaElement): CursorPosition[] { + const text = textarea.value; + // DOM selections count UTF-16 code units; reconcile counts Unicode scalars. + return [ + { id: 1, position: [...text.slice(0, textarea.selectionStart)].length }, + { id: 2, position: [...text.slice(0, textarea.selectionEnd)].length }, + ]; +} + function createSelectionOverlay(isLeft: boolean, isSelection: boolean): HTMLSpanElement { const caretSpan = document.createElement('span'); caretSpan.className = `selection-caret selection-caret-${isLeft ? 'left' : 'right'}`; diff --git a/reconcile-js/src/core.ts b/reconcile-js/src/core.ts index 21754ba..ddc0713 100644 --- a/reconcile-js/src/core.ts +++ b/reconcile-js/src/core.ts @@ -136,7 +136,13 @@ export interface CursorPosition { /** Unique identifier for the cursor (can be any number, must be unique within the document) */ id: number; - /** Character position in the text, 0-based index from the beginning of the document */ + /** + * Zero-based offset in Unicode scalars (code points), from 0 through + * `[...text].length` inclusive. Applies to both input and output cursors. + * These are not UTF-16 code units or grapheme clusters: `😀` counts as one. + * Convert DOM selectionStart/selectionEnd offsets before passing them in: + * `[...text.slice(0, offset)].length`. + */ position: number; }