Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .playwright/tests/measureRanges.spec.ts
Original file line number Diff line number Diff line change
@@ -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<TextRect[][]> {
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<number> {
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<OnRequestRangeRectsResultEvent.Companion.RangeRect>()
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<RectF> {
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<RectF>()
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RangeRect>,
private val experimentalSynchronousEvents: Boolean,
) : Event<OnRequestRangeRectsResultEvent>(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,
)
}
}
5 changes: 5 additions & 0 deletions apps/example-web/src/RouteSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,6 +28,10 @@ export default function RouteSelector() {
return <TestSetSelection />;
}

if (path === '/test-measure-ranges') {
return <TestMeasureRanges />;
}

if (path === '/test-links') {
return <TestLinks />;
}
Expand Down
59 changes: 59 additions & 0 deletions apps/example-web/src/testScreens/TestMeasureRanges.tsx
Original file line number Diff line number Diff line change
@@ -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 =
'<p>The quick brown fox jumps over the lazy dog again and again</p><p>BBBB</p>';

const INPUT_STYLE = { width: 200, padding: 12, fontSize: 16 };

export function TestMeasureRanges() {
const ref = useRef<EnrichedTextInputInstance>(null);
const [rangesInput, setRangesInput] = useState('[]');
const [rects, setRects] = useState<TextRect[][] | null>(null);
const [lastSelection, setLastSelection] =
useState<OnChangeSelectionEvent | null>(null);

const measure = async () => {
const ranges = JSON.parse(rangesInput) as { start: number; end: number }[];
setRects((await ref.current?.measureRanges(ranges)) ?? null);
};

return (
<div>
<div data-testid="editor-container">
<EnrichedTextInput
ref={ref}
defaultValue={SAMPLE_HTML}
editable
scrollEnabled
style={INPUT_STYLE}
onChangeSelection={(e) => {
setLastSelection(e.nativeEvent);
}}
/>
</div>

<input
data-testid="ranges-input"
value={rangesInput}
onChange={(e) => setRangesInput(e.target.value)}
/>
<button data-testid="measure-button" onClick={measure}>
Measure
</button>

<input
data-testid="rects-json"
readOnly
value={rects ? JSON.stringify(rects) : ''}
/>
<span data-testid="selection-start">{lastSelection?.start ?? ''}</span>
</div>
);
}
Loading