Skip to content

feat: add measureRanges to EnrichedTextInput - #825

Open
NateIsern wants to merge 1 commit into
software-mansion:mainfrom
NateIsern:feat/measure-text-ranges
Open

NateIsern wants to merge 1 commit into
software-mansion:mainfrom
NateIsern:feat/measure-text-ranges

Conversation

@NateIsern

Copy link
Copy Markdown

Summary

Adds ref.measureRanges(ranges) to EnrichedTextInput. It reports where ranges of the text being edited are drawn, so apps can draw overlays over the input. The use case is collaborative editing: other users' carets and selections, and highlights of commented passages, drawn while the text is being edited. Today the input only exposes the measurements of the view as a whole, so there is no way to know where a character is.

measureRanges: (ranges: ReadonlyArray<{ start: number; end: number }>) => Promise<TextRect[][]>;
interface TextRect { x: number; y: number; width: number; height: number }
  • Returns one array per requested range, in order: a rect for each line the range is drawn on, or one zero-width rect at the caret position when start === end.
  • Rects are relative to the input's top-left corner, in points (dp on Android), and include the input's padding and its current scroll offset.
  • Ranges use the same indexes as setSelection, i.e. the visible text without the internal zero-width spaces.
  • TextRange and TextRect are exported.

Implementation

  • Spec / JS: a requestRangeRects(requestId, ranges: Int32[]) command (ranges flattened as [start0, end0, …]) answered by an onRequestRangeRectsResult event carrying a flat { range, x, y, width, height }[]. It follows the existing requestHTML / onRequestHtmlResult / getHTML pattern: a requestId → promise map, rejected on unmount.
  • iOS (EnrichedTextInputView.mm): maps visible to actual indexes with the existing getActualIndex:, then walks the line fragments of the text view's NSLayoutManager. Each rect takes the glyphs' horizontal extent from boundingRectForGlyphRange: and the line's vertical extent from the line fragment's used rect, without the line break that ends a line. Rects are offset by textContainerInset and converted to the component view with convertRect:toView:, which covers the content offset. A collapsed range uses caretRectForPosition:.
  • Android (EnrichedTextInputView.kt, new OnRequestRangeRectsResultEvent): maps indexes with the existing getActualIndex, then for each line uses Layout.getPrimaryHorizontal, getLineTop and getLineBottom. At a soft wrap it uses getLineRight/getLineLeft, because the end offset there is also the start of the next line. The view's offsets are totalPaddingLeft/Top - scrollX/Y, and values are converted with PixelUtil.toDIPFromPixel.
  • Web (src/web/utils/measureRanges.ts): maps indexes with nativePosToTiptapPos, then measures each text node of the range with a DOM Range.getClientRects(), so the boxes of block and mark elements are not reported. Inline images use their node's box, and a collapsed range uses view.coordsAtPos. Rects are relative to the .eti-editor element.

One thing I noticed but did not change: on iOS, onChangeSelection reports raw textStorage indexes, while setSelection (and this method) take visible indexes. Android reports visible indexes for both. They differ only when the text contains internal zero-width spaces, such as empty list or quote lines.

Test Plan

  • Web: added .playwright/tests/measureRanges.spec.ts with a /test-measure-ranges screen in example-web. It checks result order, the zero-width caret rect, one rect per wrapped line, and a range across paragraphs. As a check that doesn't depend on the implementation, it clicks the centre of each measured rect and asserts that the caret lands inside the measured range. Passing locally (Chromium, Linux), together with the existing setSelection spec.
  • Android: the example app (DevScreen) now draws an orange overlay over the current selection or caret using measureRanges. I built a release APK of the example (new arch, x86_64) and ran it on an Android 16 emulator (API 36.1, Pixel 6 profile). I checked that the overlay covers the native selection highlight exactly for a single word, a selection that wraps across lines, and a selection across paragraphs. I also checked a word after empty block-quote lines, which contain zero-width spaces, to confirm the index mapping, and that the caret rect matches the native caret.
  • iOS: not built or run. I don't have access to macOS or Xcode. The iOS code is unverified beyond clang-format and a review against the codegen-generated EventEmitters.h and RCTComponentViewHelpers.h (struct OnRequestRangeRectsResultRects, requestRangeRects:ranges:). It needs a run on a simulator. To verify, open the example's DevScreen, type text that wraps, then select a word, select all, and check that the orange overlay matches the native selection.
  • yarn lint, yarn typecheck, yarn lint-clang, yarn test and the Android ktlint lintVerify pass.

Screenshots / Videos

Android emulator: the orange measureRanges overlay sits on top of the native (deep sky blue) selection, which together look teal. Select all over a wrapped line plus a second paragraph gives three rects, one per line, tight to the glyphs. I can attach screenshots if useful.

Compatibility

OS Implemented
iOS ✅ (untested)
Android ✅
Web ✅

Checklist

  • E2E tests are passing (web spec run locally; native Maestro flows not run)
  • Required E2E tests have been added (if applicable) (web Playwright spec)

Adds `ref.measureRanges(ranges)`, which resolves with the rects the given
ranges of text are drawn in: one per line, or a zero-width caret rect for a
collapsed range, relative to the input's top-left corner. It lets apps draw
overlays over the text being edited, such as other users' cursors and
selections or comment highlights.

Ranges use the same indexes as setSelection. Natively it is a
`requestRangeRects` command answered by an `onRequestRangeRectsResult` event,
following requestHTML/getHTML. iOS measures with the text view's
NSLayoutManager and caretRectForPosition:, Android with the EditText's Layout,
web with ProseMirror's DOM ranges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved response-delivery and geometry errors can leave measurement promises pending or return incorrect overlays.

4 open issues
What changed in this PR

Adds EnrichedTextInput.measureRanges() across web, Android, and iOS so apps can position selection, caret, and annotation overlays.

Changes:

  • Adds measurement implementations, native messaging, and exported geometry types.
  • Documents the API and adds a native overlay demo.
  • Adds web measurement tests and a dedicated test screen.
File Description
src/​web/​utils/​measureRanges.ts Measures DOM text and caret rectangles.
src/​web/​EnrichedTextInput.tsx Exposes web measurement method.
src/​types.ts Defines public types and API contract.
src/​spec/​EnrichedTextInputNativeComponent.ts Defines measurement command and response event.
src/​native/​EnrichedTextInput.tsx Manages measurement requests and promises.
src/​index.tsx Exports geometry types.
src/​index.native.tsx Exports native geometry types.
ios/​EnrichedTextInputView.mm Implements iOS measurement.
docs/​docs/​api-reference/​enriched-text-input.md Documents measurement usage.
apps/​example/​src/​screens/​DevScreen.tsx Demonstrates selection overlays.
apps/​example-web/​src/​testScreens/​TestMeasureRanges.tsx Adds measurement test controls.
apps/​example-web/​src/​RouteSelector.tsx Registers the test route.
android/​src/​main/​java/​com/​swmansion/​enriched/​textinput/​events/​OnRequestRangeRectsResultEvent.kt Serializes measurement responses.
android/​src/​main/​java/​com/​swmansion/​enriched/​textinput/​EnrichedTextInputViewManager.kt Connects measurement commands and events.
android/​src/​main/​java/​com/​swmansion/​enriched/​textinput/​EnrichedTextInputView.kt Implements Android measurement.
.playwright/​tests/​measureRanges.spec.ts Tests web rectangle results.

Review effort: Balanced

}
if (from >= to) continue

val fromX = layout.getPrimaryHorizontal(from)
private val rects: List<RangeRect>,
private val experimentalSynchronousEvents: Boolean,
) : Event<OnRequestRangeRectsResultEvent>(surfaceId, viewId) {
override fun getEventName(): String = EVENT_NAME
domRange.setEnd(end.node, end.offset);
for (const rect of Array.from(domRange.getClientRects())) {
if (rect.width > 0 && rect.height > 0) {
rects.push(toTextRect(rect, origin));
Comment on lines +80 to +84
const from = nativePosToTiptapPos(doc, nativeStart);
if (nativeStart === nativeEnd) {
return caretRect(view, from, origin);
}
return rangeRects(view, from, nativePosToTiptapPos(doc, nativeEnd), origin);

This branch has not been deployed

No deployments
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.

2 participants