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); + }); +}