Skip to content

fix: treat Indic conjunct clusters as a single grapheme - #3136

Merged
christianhg merged 2 commits into
mainfrom
gb9c-indic-conjunct-graphemes
Aug 24, 2026
Merged

fix: treat Indic conjunct clusters as a single grapheme#3136
christianhg merged 2 commits into
mainfrom
gb9c-indic-conjunct-graphemes

Conversation

@christianhg

@christianhg christianhg commented Aug 21, 2026

Copy link
Copy Markdown
Member

Moving the caret across an Indic conjunct cluster, for example Devanagari क्ष (consonant + virama + consonant), stopped inside the cluster: the grapheme walker in engine/utils/string.ts implements the UAX #29 rules through GB11 and GB12/13 but predates rule GB9c, which keeps conjunct clusters together. Browsers render the cluster as one glyph, so arrowing over it took multiple key presses and character positions disagreed with what the user sees.

This ports the GB9c implementation from upstream Slate (ianstormtaylor/slate#6074): the InCB Consonant/Extend/Linker codepoint classes, a GB9c entry in the non-boundary pair table, and a deferred check that only suppresses the boundary when the left context actually contains a conjunct linker. The ported logic and character tables are identical to upstream's, verified codepoint by codepoint; the tables are composed into RegExps from shared class strings instead of upstream's inline literals, so each table ships in the bundle once (roughly 6 KB of Unicode data instead of 18).

Two deletion behaviors follow from the new boundary. Forward delete at the start of a cluster now removes the whole cluster instead of only the leading consonant and virama; a browser test pins it and fails without the fix. Backspace keeps removing one code unit per press: the whole-cluster delete composes with the compound-script reinsert in delete-internal.ts. That reinsert was keyed to script ranges that miss Gujarati, Tai Tham, Balinese, Sundanese, Javanese, and Meetei Mayek, all of which GB9c now glues, so the regex additionally matches those scripts' BMP conjunct linkers (every GB9c cluster contains its linker). Browser tests pin the backspace parity for Devanagari (in-range script) and Gujarati (linker-keyed, red without the regex extension). The supplementary-plane linkers (Kharoshthi, Chakma, Kawi, and kin) are left out deliberately, since the reinsert slices code units and would split a surrogate pair; backspace on those scripts removes the whole cluster, and nothing pins them.

The grapheme walker itself is pinned by unit tests adapted from upstream's samples, asserted in both LTR and RTL directions, red without the fix. A small test-only commit rides along: it pins that backspacing a ZWJ emoji sequence removes the whole cluster (emoji are outside the compound-script reinsert), pre-existing behavior that nothing guarded.


Note

Medium Risk
Changes grapheme walking and character-delete for Indic scripts, so caret and Backspace/Delete behavior can regress if GB9c tables or the compound-script reinsert are wrong. Not auth or data-handling, but it is core text-editing.

Overview
Caret movement and character positions now treat Indic conjunct clusters (consonant + virama + consonant, e.g. Devanagari क्ष) as one grapheme, matching browser rendering and Unicode UAX #29 GB9c.

getCharacterDistance gains InCB Consonant/Extend/Linker classes, a GB9c non-boundary pair, and a deferred left-context check so the caret no longer lands inside a cluster. Forward Delete at the start of a cluster removes the whole cluster. Backspace still peels one code unit at a time via the compound-script reinsert in applyDelete; that regex now also matches BMP conjunct linkers for scripts the old ranges missed (e.g. Gujarati). Supplementary-plane linkers are left out so reinsert does not split surrogates.

Unit tests cover GB9c in LTR/RTL; browser tests pin Indic/Gujarati backspace, forward delete of a conjunct, and whole-cluster backspace of ZWJ emoji.

Reviewed by Cursor Bugbot for commit dac0376. Bugbot is set up for automated code reviews on this repo. Configure here.

@changeset-bot

changeset-bot Bot commented Aug 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dac0376

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@portabletext/editor Patch
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-dnd Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-list-index Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link Patch
@portabletext/plugin-sdk-value Patch
@portabletext/plugin-table Patch
@portabletext/plugin-typeahead-picker Patch
@portabletext/plugin-typography Patch
@portabletext/toolbar Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
portable-text-editor-documentation Ready Ready Preview Aug 24, 2026 11:34am
portable-text-example-basic Ready Ready Preview Aug 24, 2026 11:34am
portable-text-playground Ready Ready Preview Aug 24, 2026 11:34am

Request Review

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (586c65a3)

@portabletext/editor

Metric Value vs main (586c65a)
Internal (raw) 814.3 KB +18.3 KB, +2.3%
Internal (gzip) 180.3 KB +3.0 KB, +1.7%
Bundled (raw) 1.43 MB +18.3 KB, +1.3%
Bundled (gzip) 350.0 KB +3.5 KB, +1.0%
Import time 76ms -2ms, -2.3%

@portabletext/editor/behaviors

Metric Value vs main (586c65a)
Internal (raw) 4.2 KB -
Internal (gzip) 1.4 KB -
Bundled (raw) 3.9 KB -
Bundled (gzip) 1.3 KB -
Import time 2ms -0ms, -1.7%

@portabletext/editor/plugins

Metric Value vs main (586c65a)
Internal (raw) 5.2 KB -
Internal (gzip) 1.8 KB -
Bundled (raw) 5.0 KB -
Bundled (gzip) 1.7 KB -
Import time 6ms -0ms, -1.7%

@portabletext/editor/selectors

Metric Value vs main (586c65a)
Internal (raw) 96.4 KB -
Internal (gzip) 22.3 KB -
Bundled (raw) 92.5 KB -
Bundled (gzip) 21.0 KB -
Import time 6ms +0ms, +3.9%

@portabletext/editor/traversal

Metric Value vs main (586c65a)
Internal (raw) 40.7 KB -
Internal (gzip) 10.6 KB -
Bundled (raw) 41.1 KB -
Bundled (gzip) 10.6 KB -
Import time 5ms -0ms, -1.0%

@portabletext/editor/utils

Metric Value vs main (586c65a)
Internal (raw) 34.1 KB -
Internal (gzip) 8.7 KB -
Bundled (raw) 32.5 KB -
Bundled (gzip) 8.5 KB -
Import time 5ms +0ms, +1.1%

🗺️ . · ./behaviors · ./plugins · ./selectors · ./traversal · ./utils · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — @portabletext/markdown

Compared against main (586c65a3)

Metric Value vs main (586c65a)
Internal (raw) 53.0 KB -
Internal (gzip) 11.6 KB -
Bundled (raw) 351.8 KB -
Bundled (gzip) 99.4 KB -
Import time 29ms -1ms, -4.7%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c4713c2. Configure here.

Comment thread packages/editor/src/engine/utils/string.ts
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Bundle Stats

Warning

1 significant change.

@portabletext/editor

🔴 @portabletext/editor (export)
Gzip: 251.5 KB, up 3.0 KB (1.2%)
Raw: 1.08 MB, up 7.5 KB (0.7%)
Import: 67 ms, down 0 ms (0.5%)

All scenario measurements (7)

🗺️ @portabletext/editor / @portabletext/editor · @portabletext/editor / @portabletext/editor/behaviors · @portabletext/editor / @portabletext/editor/plugins · @portabletext/editor / @portabletext/editor/selectors · @portabletext/editor / @portabletext/editor/traversal · @portabletext/editor / @portabletext/editor/utils · @portabletext/markdown / @portabletext/markdown · Artifacts

Scenario Kind Bundle (raw / gzip) Gzip change Import time Import change
🔴 @portabletext/editor / @portabletext/editor export 1.08 MB / 251.5 KB +3.0 KB, +1.2% 67 ms -0 ms, -0.5%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms +0 ms, +3.0%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 7 ms +0 ms, +1.1%
⚪ @portabletext/editor / @portabletext/editor/selectors export 93.4 KB / 21.3 KB None 8 ms +0 ms, +0.1%
⚪ @portabletext/editor / @portabletext/editor/traversal export 41.4 KB / 10.7 KB None 6 ms +0 ms, +0.5%
⚪ @portabletext/editor / @portabletext/editor/utils export 32.8 KB / 8.6 KB None 6 ms +0 ms, +0.6%
⚪ @portabletext/markdown / @portabletext/markdown export 272.2 KB / 79.6 KB None 37 ms +0 ms, +0.1%

Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time.

`getCharacterDistance` implemented the Unicode UAX 29 grapheme rules
through GB11 and GB12/13 but predated GB9c, so an Indic conjunct
cluster (consonant + virama + consonant, e.g. Devanagari क्ष) was split
at the virama: caret movement and character positions treated the
cluster as two characters where browsers render one glyph.

Port the GB9c implementation from upstream Slate
(ianstormtaylor/slate#6074): `InCB` Consonant/Extend/Linker codepoint
classes, a GB9c entry in `NonBoundaryPairs`, and a deferred
`endsWithConjunctLinker` check that only suppresses the boundary when
the left context actually contains a conjunct linker. The character
tables are identical to upstream's; they are composed into `RegExp`s
from shared class strings instead of upstream's inline literals, so
each table ships once (~6 KB of bundled Unicode data instead of ~18).

Every consumer of `getCharacterDistance` (caret movement, positions,
deletion units) now steps over the full cluster. Forward delete at the
start of a cluster now removes the whole cluster. Backspace stays at
one code unit per press: the whole-cluster delete composes with the
compound-script reinsert in `delete-internal.ts`. That reinsert was
keyed to script ranges that miss Gujarati, Tai Tham, Balinese,
Sundanese, Javanese, and Meetei Mayek, which GB9c now glues, so
`COMPOUND_SCRIPT_REGEX` additionally matches those scripts' BMP
conjunct linkers (every GB9c cluster contains its linker). The
supplementary-plane linkers are left out deliberately: the reinsert
slices code units and would split a surrogate pair, so backspace on
those scripts removes the whole cluster.

Pinned by unit tests adapted from upstream (LTR and RTL, red on the
pre-GB9c code) and browser tests: forward delete over a cluster (red
pre-GB9c) and backspace one-code-unit parity for Devanagari (in-range
script) and Gujarati (linker-keyed, red without the regex extension).
Backspace on an Indic conjunct cluster removes one code unit at a time
(the compound-script reinsert in `delete-internal.ts`), but that
override is keyed to a hardcoded set of script ranges. Pin that a ZWJ
emoji sequence, which matches none of them, is removed whole by a
single backspace.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant