From b31a69cd4c9f6fe9bf89ea42c13787e52bc67b16 Mon Sep 17 00:00:00 2001 From: "Nate I." Date: Tue, 20 Oct 2026 04:35:22 +0300 Subject: [PATCH] feat: add measureRanges to EnrichedTextInput 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 --- .playwright/tests/measureRanges.spec.ts | 96 +++++++++++++++ .../textinput/EnrichedTextInputView.kt | 96 +++++++++++++++ .../textinput/EnrichedTextInputViewManager.kt | 10 ++ .../events/OnRequestRangeRectsResultEvent.kt | 52 ++++++++ apps/example-web/src/RouteSelector.tsx | 5 + .../src/testScreens/TestMeasureRanges.tsx | 59 +++++++++ apps/example/src/screens/DevScreen.tsx | 45 ++++++- .../docs/api-reference/enriched-text-input.md | 33 +++++ ios/EnrichedTextInputView.mm | 114 ++++++++++++++++++ src/index.native.tsx | 2 + src/index.tsx | 2 + src/native/EnrichedTextInput.tsx | 58 +++++++++ src/spec/EnrichedTextInputNativeComponent.ts | 20 +++ src/types.ts | 26 ++++ src/web/EnrichedTextInput.tsx | 8 ++ src/web/utils/measureRanges.ts | 86 +++++++++++++ 16 files changed, 709 insertions(+), 3 deletions(-) create mode 100644 .playwright/tests/measureRanges.spec.ts create mode 100644 android/src/main/java/com/swmansion/enriched/textinput/events/OnRequestRangeRectsResultEvent.kt create mode 100644 apps/example-web/src/testScreens/TestMeasureRanges.tsx create mode 100644 src/web/utils/measureRanges.ts diff --git a/.playwright/tests/measureRanges.spec.ts b/.playwright/tests/measureRanges.spec.ts new file mode 100644 index 000000000..740ebe5f8 --- /dev/null +++ b/.playwright/tests/measureRanges.spec.ts @@ -0,0 +1,96 @@ +import { test, expect, type Page } from '@playwright/test'; + +// 'The quick brown fox jumps over the lazy dog again and again' (0-59), +// '\n' (59), 'BBBB' (60-64) +const FIRST_PARAGRAPH_END = 59; +const SECOND_PARAGRAPH_START = 60; +const TEXT_END = 64; + +interface TextRect { + x: number; + y: number; + width: number; + height: number; +} + +async function measure( + page: Page, + ranges: { start: number; end: number }[] +): Promise { + await page.getByTestId('ranges-input').fill(JSON.stringify(ranges)); + await page.getByTestId('measure-button').click(); + await expect(page.getByTestId('rects-json')).not.toHaveValue(''); + const json = await page.getByTestId('rects-json').inputValue(); + return JSON.parse(json) as TextRect[][]; +} + +// Clicks the middle of a measured rect and returns where the caret landed +async function caretAtCenterOf(page: Page, rect: TextRect): Promise { + const box = await page.locator('.eti-editor').boundingBox(); + if (!box) throw new Error('No editor'); + await page.mouse.click( + box.x + rect.x + rect.width / 2, + box.y + rect.y + rect.height / 2 + ); + await expect(page.getByTestId('selection-start')).not.toHaveText(''); + return Number(await page.getByTestId('selection-start').textContent()); +} + +test.describe('measureRanges', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/test-measure-ranges'); + await page.waitForSelector('[data-testid="editor-container"]'); + }); + + test('returns one result per range, in order', async ({ page }) => { + const rects = await measure(page, [ + { start: 4, end: 9 }, + { start: SECOND_PARAGRAPH_START, end: TEXT_END }, + { start: 2, end: 2 }, + ]); + expect(rects).toHaveLength(3); + expect(rects.every((r) => r.length === 1)).toBe(true); + expect(rects[1]![0]!.y).toBeGreaterThan(rects[0]![0]!.y); + }); + + test('a collapsed range is a zero-width caret rect', async ({ page }) => { + const [rects] = await measure(page, [{ start: 0, end: 0 }]); + expect(rects).toHaveLength(1); + expect(rects![0]!.width).toBe(0); + expect(rects![0]!.height).toBeGreaterThan(0); + // inside the input's padding + expect(rects![0]!.x).toBeCloseTo(12, 0); + expect(rects![0]!.y).toBeGreaterThanOrEqual(12); + }); + + test('a word is a single rect over that word', async ({ page }) => { + const [rects] = await measure(page, [{ start: 4, end: 9 }]); + expect(rects).toHaveLength(1); + expect(rects![0]!.width).toBeGreaterThan(0); + + const caret = await caretAtCenterOf(page, rects![0]!); + expect(caret).toBeGreaterThanOrEqual(4); + expect(caret).toBeLessThanOrEqual(9); + }); + + test('a wrapped paragraph is a rect per line', async ({ page }) => { + const [rects] = await measure(page, [ + { start: 0, end: FIRST_PARAGRAPH_END }, + ]); + expect(rects!.length).toBeGreaterThanOrEqual(3); + for (let i = 1; i < rects!.length; i++) { + expect(rects![i]!.y).toBeGreaterThan(rects![i - 1]!.y); + } + }); + + test('a range across paragraphs covers both', async ({ page }) => { + const [rects] = await measure(page, [ + { start: FIRST_PARAGRAPH_END - 5, end: TEXT_END }, + ]); + expect(rects).toHaveLength(2); + + const caret = await caretAtCenterOf(page, rects![1]!); + expect(caret).toBeGreaterThanOrEqual(SECOND_PARAGRAPH_START); + expect(caret).toBeLessThanOrEqual(TEXT_END); + }); +}); diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index e61435f35..60bb03006 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -7,10 +7,12 @@ import android.graphics.BlendMode import android.graphics.BlendModeColorFilter import android.graphics.Color import android.graphics.Rect +import android.graphics.RectF import android.graphics.text.LineBreaker import android.os.Build import android.text.Editable import android.text.InputType +import android.text.Layout import android.text.Spannable import android.text.SpannableString import android.util.AttributeSet @@ -33,6 +35,7 @@ import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.common.ReactConstants +import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles @@ -48,6 +51,7 @@ import com.swmansion.enriched.textinput.events.OnContextMenuItemPressEvent import com.swmansion.enriched.textinput.events.OnInputBlurEvent import com.swmansion.enriched.textinput.events.OnInputFocusEvent import com.swmansion.enriched.textinput.events.OnRequestHtmlResultEvent +import com.swmansion.enriched.textinput.events.OnRequestRangeRectsResultEvent import com.swmansion.enriched.textinput.events.OnSubmitEditingEvent import com.swmansion.enriched.textinput.spans.EnrichedInputH1Span import com.swmansion.enriched.textinput.spans.EnrichedInputH2Span @@ -1048,6 +1052,98 @@ class EnrichedTextInputView : dispatcher?.dispatchEvent(OnRequestHtmlResultEvent(surfaceId, id, requestId, html, experimentalSynchronousEvents)) } + // `ranges` holds visible (ZWS-free) indexes, as setCustomSelection does: [start0, end0, start1, end1, ...] + fun requestRangeRects( + requestId: Int, + ranges: ReadableArray?, + ) { + val rects = mutableListOf() + val currentLayout = layout + + if (ranges != null && currentLayout != null) { + // Layout coordinates -> this view's coordinates + val offsetX = (totalPaddingLeft - scrollX).toFloat() + val offsetY = (totalPaddingTop - scrollY).toFloat() + + for (i in 0 until ranges.size() / 2) { + val visibleStart = ranges.getInt(i * 2) + val visibleEnd = ranges.getInt(i * 2 + 1) + val start = getActualIndex(maxOf(0, minOf(visibleStart, visibleEnd))) + val end = getActualIndex(maxOf(0, maxOf(visibleStart, visibleEnd))) + + for (rect in rectsForRange(currentLayout, start, end)) { + rects.add( + OnRequestRangeRectsResultEvent.Companion.RangeRect( + range = i, + x = PixelUtil.toDIPFromPixel(rect.left + offsetX).toDouble(), + y = PixelUtil.toDIPFromPixel(rect.top + offsetY).toDouble(), + width = PixelUtil.toDIPFromPixel(rect.width()).toDouble(), + height = PixelUtil.toDIPFromPixel(rect.height()).toDouble(), + ), + ) + } + } + } + + val reactContext = context as ReactContext + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) + dispatcher?.dispatchEvent(OnRequestRangeRectsResultEvent(surfaceId, id, requestId, rects, experimentalSynchronousEvents)) + } + + // Rects (in layout coordinates) where the characters of [start, end) are drawn: + // one per line, or a zero-width caret rect for an empty range. + private fun rectsForRange( + layout: Layout, + start: Int, + end: Int, + ): List { + val currentText = text ?: return emptyList() + + if (start == end) { + val line = layout.getLineForOffset(start) + val x = layout.getPrimaryHorizontal(start) + return listOf(RectF(x, layout.getLineTop(line).toFloat(), x, layout.getLineBottom(line).toFloat())) + } + + val rects = mutableListOf() + for (line in layout.getLineForOffset(start)..layout.getLineForOffset(end)) { + val lineEnd = layout.getLineEnd(line) + val from = maxOf(start, layout.getLineStart(line)) + var to = minOf(end, lineEnd) + // A line break ending the line isn't drawn + while (to > from && currentText[to - 1] == '\n') { + to-- + } + if (from >= to) continue + + val fromX = layout.getPrimaryHorizontal(from) + val toX = + if (to == lineEnd && line < layout.lineCount - 1) { + // At a soft wrap, `to` is also the start of the next line + if (layout.getParagraphDirection(line) == Layout.DIR_RIGHT_TO_LEFT) { + layout.getLineLeft(line) + } else { + layout.getLineRight(line) + } + } else { + layout.getPrimaryHorizontal(to) + } + + val rect = + RectF( + minOf(fromX, toX), + layout.getLineTop(line).toFloat(), + maxOf(fromX, toX), + layout.getLineBottom(line).toFloat(), + ) + if (rect.width() > 0 && rect.height() > 0) { + rects.add(rect) + } + } + return rects + } + // Sometimes setting up style triggers many changes in sequence // Eg. removing conflicting styles -> changing text -> applying spans // In such scenario we want to prevent from handling side effects (eg. onTextChanged) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 8b43f3280..8cb7f369a 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -28,6 +28,7 @@ import com.swmansion.enriched.textinput.events.OnMentionDetectedEvent import com.swmansion.enriched.textinput.events.OnMentionEvent import com.swmansion.enriched.textinput.events.OnPasteImagesEvent import com.swmansion.enriched.textinput.events.OnRequestHtmlResultEvent +import com.swmansion.enriched.textinput.events.OnRequestRangeRectsResultEvent import com.swmansion.enriched.textinput.events.OnSubmitEditingEvent import com.swmansion.enriched.textinput.spans.EnrichedSpans import com.swmansion.enriched.textinput.styles.HtmlStyle @@ -72,6 +73,7 @@ class EnrichedTextInputViewManager : map.put(OnMentionEvent.EVENT_NAME, mapOf("registrationName" to OnMentionEvent.EVENT_NAME)) map.put(OnChangeSelectionEvent.EVENT_NAME, mapOf("registrationName" to OnChangeSelectionEvent.EVENT_NAME)) map.put(OnRequestHtmlResultEvent.EVENT_NAME, mapOf("registrationName" to OnRequestHtmlResultEvent.EVENT_NAME)) + map.put(OnRequestRangeRectsResultEvent.EVENT_NAME, mapOf("registrationName" to OnRequestRangeRectsResultEvent.EVENT_NAME)) map.put(OnInputKeyPressEvent.EVENT_NAME, mapOf("registrationName" to OnInputKeyPressEvent.EVENT_NAME)) map.put(OnPasteImagesEvent.EVENT_NAME, mapOf("registrationName" to OnPasteImagesEvent.EVENT_NAME)) map.put(OnContextMenuItemPressEvent.EVENT_NAME, mapOf("registrationName" to OnContextMenuItemPressEvent.EVENT_NAME)) @@ -488,6 +490,14 @@ class EnrichedTextInputViewManager : view?.requestHTML(requestId) } + override fun requestRangeRects( + view: EnrichedTextInputView?, + requestId: Int, + ranges: ReadableArray?, + ) { + view?.requestRangeRects(requestId, ranges) + } + override fun setTextAlignment( view: EnrichedTextInputView?, alignment: String, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/events/OnRequestRangeRectsResultEvent.kt b/android/src/main/java/com/swmansion/enriched/textinput/events/OnRequestRangeRectsResultEvent.kt new file mode 100644 index 000000000..87518889f --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/events/OnRequestRangeRectsResultEvent.kt @@ -0,0 +1,52 @@ +package com.swmansion.enriched.textinput.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +class OnRequestRangeRectsResultEvent( + surfaceId: Int, + viewId: Int, + private val requestId: Int, + private val rects: List, + private val experimentalSynchronousEvents: Boolean, +) : Event(surfaceId, viewId) { + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap { + val rectsArray: WritableArray = Arguments.createArray() + + for (rect in rects) { + val rectMap = Arguments.createMap() + rectMap.putInt("range", rect.range) + rectMap.putDouble("x", rect.x) + rectMap.putDouble("y", rect.y) + rectMap.putDouble("width", rect.width) + rectMap.putDouble("height", rect.height) + + rectsArray.pushMap(rectMap) + } + + val eventData: WritableMap = Arguments.createMap() + eventData.putInt("requestId", requestId) + eventData.putArray("rects", rectsArray) + + return eventData + } + + override fun experimental_isSynchronous(): Boolean = experimentalSynchronousEvents + + companion object { + const val EVENT_NAME: String = "onRequestRangeRectsResult" + + // `range` is the index of the requested range the rect belongs to + data class RangeRect( + val range: Int, + val x: Double, + val y: Double, + val width: Double, + val height: Double, + ) + } +} diff --git a/apps/example-web/src/RouteSelector.tsx b/apps/example-web/src/RouteSelector.tsx index d280fb214..8a33515ba 100644 --- a/apps/example-web/src/RouteSelector.tsx +++ b/apps/example-web/src/RouteSelector.tsx @@ -2,6 +2,7 @@ import App from './App'; import { TestMentions } from './testScreens/TestMentions'; import { TestLinks } from './testScreens/TestLinks'; import { TestSetSelection } from './testScreens/TestSetSelection'; +import { TestMeasureRanges } from './testScreens/TestMeasureRanges'; import { VisualRegression } from './testScreens/VisualRegression'; import { TestSubmitProps } from './testScreens/TestSubmitProps'; import { TestEnrichedText } from './testScreens/TestEnrichedText'; @@ -27,6 +28,10 @@ export default function RouteSelector() { return ; } + if (path === '/test-measure-ranges') { + return ; + } + if (path === '/test-links') { return ; } diff --git a/apps/example-web/src/testScreens/TestMeasureRanges.tsx b/apps/example-web/src/testScreens/TestMeasureRanges.tsx new file mode 100644 index 000000000..3ceee30ff --- /dev/null +++ b/apps/example-web/src/testScreens/TestMeasureRanges.tsx @@ -0,0 +1,59 @@ +import { useRef, useState } from 'react'; +import { + EnrichedTextInput, + type EnrichedTextInputInstance, + type OnChangeSelectionEvent, + type TextRect, +} from 'react-native-enriched-html'; + +// The first paragraph wraps onto several lines in a 200px wide input +const SAMPLE_HTML = + '

The quick brown fox jumps over the lazy dog again and again

BBBB

'; + +const INPUT_STYLE = { width: 200, padding: 12, fontSize: 16 }; + +export function TestMeasureRanges() { + const ref = useRef(null); + const [rangesInput, setRangesInput] = useState('[]'); + const [rects, setRects] = useState(null); + const [lastSelection, setLastSelection] = + useState(null); + + const measure = async () => { + const ranges = JSON.parse(rangesInput) as { start: number; end: number }[]; + setRects((await ref.current?.measureRanges(ranges)) ?? null); + }; + + return ( +
+
+ { + setLastSelection(e.nativeEvent); + }} + /> +
+ + setRangesInput(e.target.value)} + /> + + + + {lastSelection?.start ?? ''} +
+ ); +} diff --git a/apps/example/src/screens/DevScreen.tsx b/apps/example/src/screens/DevScreen.tsx index d5b4ba3f1..70c6702b9 100644 --- a/apps/example/src/screens/DevScreen.tsx +++ b/apps/example/src/screens/DevScreen.tsx @@ -1,5 +1,11 @@ -import { View, StyleSheet, Text, ScrollView } from 'react-native'; -import { EnrichedTextInput } from 'react-native-enriched-html'; +import { + View, + StyleSheet, + Text, + ScrollView, + type LayoutRectangle, +} from 'react-native'; +import { EnrichedTextInput, type TextRect } from 'react-native-enriched-html'; import { Button } from '../components/Button'; import { Toolbar } from '../components/Toolbar'; import { LinkModal } from '../components/LinkModal'; @@ -13,7 +19,7 @@ import { htmlStyle, ANDROID_EXPERIMENTAL_SYNCHRONOUS_EVENTS, } from '../constants/editorConfig'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { TextRenderer } from '../components/TextRenderer'; interface DevScreenProps { @@ -23,6 +29,18 @@ interface DevScreenProps { export function DevScreen({ onSwitch }: DevScreenProps) { const editor = useEditorState(); const [textNodes, setTextNodes] = useState>([]); + const [inputLayout, setInputLayout] = useState(); + const [selectionRects, setSelectionRects] = useState([]); + + // Draws an overlay over the current selection, the way another user's + // cursor or selection could be drawn + useEffect(() => { + if (!editor.selection) return; + editor.ref.current + ?.measureRanges([editor.selection]) + .then(([rects]) => setSelectionRects(rects ?? [])) + .catch(() => {}); + }, [editor.ref, editor.selection, inputLayout]); const handlePushTextNode = async () => { const currentText = await editor.ref.current?.getHTML(); @@ -73,8 +91,25 @@ export function DevScreen({ onSwitch }: DevScreenProps) { ANDROID_EXPERIMENTAL_SYNCHRONOUS_EVENTS } onPasteImages={(e) => editor.handlePasteImagesEvent(e.nativeEvent)} + onLayout={(e) => setInputLayout(e.nativeEvent.layout)} testID="editor-input" /> + {inputLayout && + selectionRects.map((rect, index) => ( + + ))} ) => + Promise; + +interface TextRect { + x: number; + y: number; + width: number; + height: number; +} +``` + +Returns a Promise that resolves with the rectangles the given ranges of text +are currently drawn in. Useful for drawing overlays over the text being edited, +for example other users' cursors and selections in a collaborative editor, or +highlights of commented passages. + +- `ranges` - ranges to measure, using the same indexes as + [`setSelection`](#setselection) + +The result holds one array per requested range, in the same order: + +- a rectangle for each line the range is drawn on, or an empty array when + nothing of the range is drawn +- a single rectangle of zero width at the caret position, when `start` equals + `end` + +Rectangles are relative to the input's top-left corner, in points (dp on +Android), and include the input's padding and current scroll offset. Measure +again after the text, the input's layout or its scroll position changes. + ### `.setImage()` ```ts diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index c44d4d9dd..10372f261 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1297,6 +1297,10 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { } else if ([commandName isEqualToString:@"requestHTML"]) { NSInteger requestId = [((NSNumber *)args[0]) integerValue]; [self requestHTML:requestId]; + } else if ([commandName isEqualToString:@"requestRangeRects"]) { + NSInteger requestId = [((NSNumber *)args[0]) integerValue]; + NSArray *ranges = (NSArray *)args[1]; + [self requestRangeRects:requestId ranges:ranges]; } else if ([commandName isEqualToString:@"setTextAlignment"]) { NSString *alignmentString = (NSString *)args[0]; @@ -1495,6 +1499,116 @@ - (void)requestHTML:(NSInteger)requestId { } } +// `ranges` holds visible (ZWSP-free) indexes, as setSelection does: +// [start0, end0, start1, end1, ...] +- (void)requestRangeRects:(NSInteger)requestId ranges:(const NSArray *)ranges { + auto emitter = [self getEventEmitter]; + if (emitter == nullptr) { + return; + } + + NSString *text = textView.textStorage.string; + [textView.layoutManager ensureLayoutForTextContainer:textView.textContainer]; + + std::vector + rectsVector; + for (NSUInteger i = 0; i + 1 < ranges.count; i += 2) { + NSInteger visibleStart = [((NSNumber *)ranges[i]) integerValue]; + NSInteger visibleEnd = [((NSNumber *)ranges[i + 1]) integerValue]; + NSUInteger start = + [self getActualIndex:MAX(0, MIN(visibleStart, visibleEnd)) text:text]; + NSUInteger end = [self getActualIndex:MAX(0, MAX(visibleStart, visibleEnd)) + text:text]; + + for (NSValue *value in + [self rectsForCharacterRange:NSMakeRange(start, end - start)]) { + CGRect rect = value.CGRectValue; + rectsVector.push_back({.range = static_cast(i / 2), + .x = rect.origin.x, + .y = rect.origin.y, + .width = rect.size.width, + .height = rect.size.height}); + } + } + + emitter->onRequestRangeRectsResult( + {.requestId = static_cast(requestId), .rects = rectsVector}); +} + +// Rects (in this view's coordinates) where the characters of `range` are +// drawn: one per line fragment, or a zero-width caret rect for an empty range. +- (NSArray *)rectsForCharacterRange:(NSRange)range { + NSMutableArray *result = [[NSMutableArray alloc] init]; + + if (range.length == 0) { + UITextPosition *position = + [textView positionFromPosition:textView.beginningOfDocument + offset:range.location]; + if (position == nil) { + return result; + } + CGRect caretRect = [textView caretRectForPosition:position]; + if (CGRectIsNull(caretRect) || CGRectIsInfinite(caretRect)) { + return result; + } + caretRect.size.width = 0; + [result addObject:[NSValue valueWithCGRect:[textView convertRect:caretRect + toView:self]]]; + return result; + } + + NSString *text = textView.textStorage.string; + NSLayoutManager *layoutManager = textView.layoutManager; + NSTextContainer *textContainer = textView.textContainer; + UIEdgeInsets inset = textView.textContainerInset; + NSRange glyphRange = [layoutManager glyphRangeForCharacterRange:range + actualCharacterRange:nullptr]; + + NSUInteger glyphIndex = glyphRange.location; + while (glyphIndex < NSMaxRange(glyphRange)) { + NSRange lineGlyphRange; + CGRect usedRect = + [layoutManager lineFragmentUsedRectForGlyphAtIndex:glyphIndex + effectiveRange:&lineGlyphRange]; + if (NSMaxRange(lineGlyphRange) <= glyphIndex) { + break; + } + NSRange charRange = [layoutManager + characterRangeForGlyphRange:NSIntersectionRange(glyphRange, + lineGlyphRange) + actualGlyphRange:nullptr]; + glyphIndex = NSMaxRange(lineGlyphRange); + + // a line break ending the line isn't drawn + while (charRange.length > 0 && + [[NSCharacterSet newlineCharacterSet] + characterIsMember:[text characterAtIndex:NSMaxRange(charRange) - + 1]]) { + charRange.length--; + } + if (charRange.length == 0) { + continue; + } + + CGRect bounds = [layoutManager + boundingRectForGlyphRange:[layoutManager + glyphRangeForCharacterRange:charRange + actualCharacterRange:nullptr] + inTextContainer:textContainer]; + // horizontal extent of the glyphs, vertical extent of the line + CGRect rect = + CGRectMake(bounds.origin.x + inset.left, usedRect.origin.y + inset.top, + bounds.size.width, usedRect.size.height); + if (rect.size.width <= 0 || rect.size.height <= 0) { + continue; + } + [result addObject:[NSValue valueWithCGRect:[textView convertRect:rect + toView:self]]]; + } + + return result; +} + - (void)emitOnKeyPressEvent:(NSString *)key { auto emitter = [self getEventEmitter]; if (emitter != nullptr) { diff --git a/src/index.native.tsx b/src/index.native.tsx index 8f7473c7a..c32d9998b 100644 --- a/src/index.native.tsx +++ b/src/index.native.tsx @@ -12,6 +12,8 @@ export type { OnKeyPressEvent, OnPasteImagesEvent, OnSubmitEditing, + TextRange, + TextRect, HtmlStyle, MentionStyleProperties, FocusEvent, diff --git a/src/index.tsx b/src/index.tsx index e6b4e654a..7e43700fe 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -13,6 +13,8 @@ export type { OnKeyPressEvent, OnPasteImagesEvent, OnSubmitEditing, + TextRange, + TextRect, HtmlStyle, MentionStyleProperties, FocusEvent, diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index cfe12104c..cc25f55a4 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -13,6 +13,7 @@ import EnrichedTextInputNativeComponent, { type OnMentionEvent, type OnMentionDetectedInternal, type OnRequestHtmlResultEvent, + type OnRequestRangeRectsResultEvent, } from '../spec/EnrichedTextInputNativeComponent'; import type { HostComponent, @@ -31,6 +32,7 @@ import type { EnrichedTextInputProps, OnLinkDetected, OnMentionDetected, + TextRect, } from '../types'; const warnMentionIndicators = (indicator: string) => { @@ -46,6 +48,12 @@ type HtmlRequest = { reject: (error: Error) => void; }; +type RangeRectsRequest = { + rangeCount: number; + resolve: (rects: TextRect[][]) => void; + reject: (error: Error) => void; +}; + export const EnrichedTextInput = ({ ref, autoFocus = false, @@ -91,6 +99,11 @@ export const EnrichedTextInput = ({ const nextHtmlRequestId = useRef(1); const pendingHtmlRequests = useRef(new Map()); + const nextRangeRectsRequestId = useRef(1); + const pendingRangeRectsRequests = useRef( + new Map() + ); + // Store onPress callbacks in a ref so native only receives serializable data const contextMenuCallbacksRef = useRef< Map @@ -137,11 +150,16 @@ export const EnrichedTextInput = ({ useEffect(() => { const pendingRequests = pendingHtmlRequests.current; + const pendingRectsRequests = pendingRangeRectsRequests.current; return () => { pendingRequests.forEach(({ reject }) => { reject(new Error('Component unmounted')); }); pendingRequests.clear(); + pendingRectsRequests.forEach(({ reject }) => { + reject(new Error('Component unmounted')); + }); + pendingRectsRequests.clear(); }; }, []); @@ -192,6 +210,26 @@ export const EnrichedTextInput = ({ Commands.requestHTML(nullthrows(nativeRef.current), requestId); }); }, + measureRanges: (ranges) => { + const view = nullthrows(nativeRef.current); + if (ranges.length === 0) { + return Promise.resolve([]); + } + return new Promise((resolve, reject) => { + const requestId = nextRangeRectsRequestId.current++; + pendingRangeRectsRequests.current.set(requestId, { + rangeCount: ranges.length, + resolve, + reject, + }); + // Codegen does not support arrays of objects as Commands parameters, so ranges are flattened + Commands.requestRangeRects( + view, + requestId, + ranges.flatMap(({ start, end }) => [start, end]) + ); + }); + }, toggleBold: () => { Commands.toggleBold(nullthrows(nativeRef.current)); }, @@ -329,6 +367,25 @@ export const EnrichedTextInput = ({ pendingHtmlRequests.current.delete(requestId); }; + const handleRequestRangeRectsResult = ( + e: NativeSyntheticEvent + ) => { + const { requestId, rects } = e.nativeEvent; + const pending = pendingRangeRectsRequests.current.get(requestId); + if (!pending) return; + + const result: TextRect[][] = Array.from( + { length: pending.rangeCount }, + () => [] + ); + for (const { range, x, y, width, height } of rects) { + result[range]?.push({ x, y, width, height }); + } + pending.resolve(result); + + pendingRangeRectsRequests.current.delete(requestId); + }; + return ( ; onChangeSelection?: CodegenTypes.DirectEventHandler; onRequestHtmlResult?: CodegenTypes.DirectEventHandler; + onRequestRangeRectsResult?: CodegenTypes.DirectEventHandler; onInputKeyPress?: CodegenTypes.DirectEventHandler; onPasteImages?: CodegenTypes.DirectEventHandler; onContextMenuItemPress?: CodegenTypes.DirectEventHandler; @@ -478,6 +491,12 @@ interface NativeCommands { viewRef: React.ElementRef, requestId: CodegenTypes.Int32 ) => void; + // `ranges` is flattened as [start0, end0, start1, end1, ...] + requestRangeRects: ( + viewRef: React.ElementRef, + requestId: CodegenTypes.Int32, + ranges: CodegenTypes.Int32[] + ) => void; setTextAlignment: ( viewRef: React.ElementRef, alignment: string @@ -515,6 +534,7 @@ export const Commands: NativeCommands = codegenNativeCommands({ 'startMention', 'addMention', 'requestHTML', + 'requestRangeRects', 'setTextAlignment', ], }); diff --git a/src/types.ts b/src/types.ts index d478b123c..e0ec3bde0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -427,6 +427,20 @@ export interface OnSubmitEditing { text: string; } +/** A range of the input's text, in the same indexes as `setSelection`. */ +export interface TextRange { + start: number; + end: number; +} + +/** A rectangle relative to the input's top-left corner, in points (dp on Android). */ +export interface TextRect { + x: number; + y: number; + width: number; + height: number; +} + // Component types export type FocusEvent = NativeSyntheticEvent; @@ -467,6 +481,18 @@ export interface EnrichedTextInputInstance { */ getHTML: () => Promise; + /** + * Measures where the given ranges of text are currently drawn, e.g. to draw + * overlays over the input (another user's cursor or selection, a comment + * highlight). + * + * Resolves with one array per requested range, in the same order: a rect per + * line the range is drawn on, or a single zero-width rect at the caret + * position when `start` equals `end`. Rects are relative to the input's + * top-left corner and account for its padding and scroll offset. + */ + measureRanges: (ranges: ReadonlyArray) => Promise; + /** Toggles bold on the current selection (or toggles it for future typing if nothing is selected). */ toggleBold: () => void; diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 1002b647c..e0e931ab7 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -91,6 +91,7 @@ import { } from './sanitization/htmlSanitizer'; import { assertBrowserEnvironment } from './utils/assertBrowserEnvironment'; import { runSafelyInEditor } from './utils/runSafelyInEditor'; +import { measureRanges } from './utils/measureRanges'; import { useLinkPress } from './htmlExtensions/useLinkPress'; function runFocused( @@ -383,6 +384,13 @@ export const EnrichedTextInput = ({ () => sanitizationConfigRef.current ) ), + measureRanges: (ranges) => + new Promise((resolve) => + resolve( + runSafelyInEditor(editor, (e) => measureRanges(e.view, ranges)) ?? + ranges.map(() => []) + ) + ), toggleBold: () => runFocused(editor, (c) => c.toggleBold()), toggleItalic: () => runFocused(editor, (c) => c.toggleItalic()), toggleUnderline: () => runFocused(editor, (c) => c.toggleUnderline()), diff --git a/src/web/utils/measureRanges.ts b/src/web/utils/measureRanges.ts new file mode 100644 index 000000000..49466db75 --- /dev/null +++ b/src/web/utils/measureRanges.ts @@ -0,0 +1,86 @@ +import type { EditorView } from '@tiptap/pm/view'; +import type { TextRange, TextRect } from '../../types'; +import { nativePosToTiptapPos } from '../nativeMappers/positionMapping'; +import { ENRICHED_TEXT_INPUT_CLASSNAME } from '../constants/classNames'; + +function toTextRect( + rect: { left: number; top: number; width: number; height: number }, + origin: DOMRect +): TextRect { + return { + x: rect.left - origin.left, + y: rect.top - origin.top, + width: rect.width, + height: rect.height, + }; +} + +function caretRect(view: EditorView, pos: number, origin: DOMRect): TextRect[] { + const { left, top, bottom } = view.coordsAtPos(pos); + if (bottom <= top) return []; + return [toTextRect({ left, top, width: 0, height: bottom - top }, origin)]; +} + +function rangeRects( + view: EditorView, + from: number, + to: number, + origin: DOMRect +): TextRect[] { + const rects: TextRect[] = []; + const domRange = view.dom.ownerDocument.createRange(); + + // Measure text nodes one at a time, so that the boxes of elements fully + // inside the range (paragraphs, marks) are not reported on top of the text + view.state.doc.nodesBetween(from, to, (node, pos) => { + if (node.isText) { + const start = view.domAtPos(Math.max(from, pos), 1); + const end = view.domAtPos(Math.min(to, pos + node.nodeSize), -1); + domRange.setStart(start.node, start.offset); + 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)); + } + } + return false; + } + if (node.isInline && node.isAtom) { + // Inline images + const rect = ( + view.nodeDOM(pos) as Element | null + )?.getBoundingClientRect?.(); + if (rect && rect.width > 0 && rect.height > 0) { + rects.push(toTextRect(rect, origin)); + } + return false; + } + return true; + }); + + return rects; +} + +/** + * Measures where the given native-indexed ranges are drawn, relative to the + * input's top-left corner. + */ +export function measureRanges( + view: EditorView, + ranges: ReadonlyArray +): TextRect[][] { + const doc = view.state.doc; + const origin = ( + view.dom.closest(`.${ENRICHED_TEXT_INPUT_CLASSNAME}`) ?? view.dom + ).getBoundingClientRect(); + + return ranges.map(({ start, end }) => { + const nativeStart = Math.max(0, Math.min(start, end)); + const nativeEnd = Math.max(0, Math.max(start, end)); + const from = nativePosToTiptapPos(doc, nativeStart); + if (nativeStart === nativeEnd) { + return caretRect(view, from, origin); + } + return rangeRects(view, from, nativePosToTiptapPos(doc, nativeEnd), origin); + }); +}