addons/isl/src/stackEdit/ui/TextEditable.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import deepEqual from 'fast-deep-equal';
b69ab319import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
b69ab3110
b69ab3111import './TextEditable.css';
b69ab3112
b69ab3113/** Text selection range. Unit: characters. */
b69ab3114export type RangeInfo = {
b69ab3115 start: number;
b69ab3116 end: number;
b69ab3117};
b69ab3118
b69ab3119/**
b69ab3120 * The `index` suggests that the `RangeInfo` comes form `rangeInfos[index]`,
b69ab3121 * and we expect the corresponding DOM element at `[data-range-id=index]`.
b69ab3122 */
b69ab3123type RangeInfoWithIndex = RangeInfo & {index: number};
b69ab3124
b69ab3125/** Like DOMRect, but properties are mutable. */
b69ab3126type Rect = {
b69ab3127 top: number;
b69ab3128 left: number;
b69ab3129 width: number;
b69ab3130 height: number;
b69ab3131};
b69ab3132
b69ab3133/**
b69ab3134 * Find the `RangeInfo` containing the given `pos` using binary search.
b69ab3135 * The `end` of the last `RangeInfo` is treated as inclusive.
b69ab3136 */
b69ab3137function findRangeInfo(infos: readonly RangeInfo[], pos: number): RangeInfoWithIndex | null {
b69ab3138 let start = 0;
b69ab3139 let end = infos.length;
b69ab3140 while (start < end) {
b69ab3141 // eslint-disable-next-line no-bitwise
b69ab3142 const mid = (start + end) >> 1;
b69ab3143 const info = infos[mid];
b69ab3144 const isEnd = mid === infos.length - 1 ? 1 : 0;
b69ab3145 if (info.start <= pos && pos < info.end + isEnd) {
b69ab3146 return {...info, index: mid};
b69ab3147 } else if (info.start > pos) {
b69ab3148 end = mid;
b69ab3149 } else {
b69ab3150 start = mid + 1;
b69ab3151 }
b69ab3152 }
b69ab3153 return null;
b69ab3154}
b69ab3155
b69ab3156/** Restart the "blink" animation. */
b69ab3157function restartBlinking(element: Element) {
b69ab3158 for (const animation of element.getAnimations()) {
b69ab3159 if ((animation as CSSAnimation).animationName === 'blink') {
b69ab3160 animation.cancel();
b69ab3161 animation.play();
b69ab3162 return;
b69ab3163 }
b69ab3164 }
b69ab3165}
b69ab3166
b69ab3167/**
b69ab3168 * Get the client rect of the intersection of `rangeInfo` and `start..end`.
b69ab3169 * The container must have `[data-range-id=index]` elements. This function
b69ab3170 * locates the related element by looking for `data-range-id` == `rangeInfo.index`.
b69ab3171 */
b69ab3172function getRangeRect(
b69ab3173 container: Element,
b69ab3174 rangeInfo: RangeInfoWithIndex,
b69ab3175 start: number,
b69ab3176 end: number,
b69ab3177): Rect | undefined {
b69ab3178 const span = container.querySelector(`[data-range-id="${rangeInfo.index}"]`);
b69ab3179 const textNode = span?.firstChild;
b69ab3180 if (textNode?.nodeType !== Node.TEXT_NODE) {
b69ab3181 return undefined;
b69ab3182 }
b69ab3183 const range = document.createRange();
b69ab3184 const textLen = textNode.textContent?.length ?? 0;
b69ab3185 range.setStart(textNode, Math.min(textLen, Math.max(start - rangeInfo.start, 0)));
b69ab3186 range.setEnd(textNode, Math.min(textLen, end - rangeInfo.start));
b69ab3187 return DOMRectToRect(range.getBoundingClientRect());
b69ab3188}
b69ab3189
b69ab3190/**
b69ab3191 * word/line: selection is extended to word/line boundary.
b69ab3192 * This is used when you double/triple click then optionally drag select.
b69ab3193 */
b69ab3194type SelectionMode = 'char' | 'word' | 'line';
b69ab3195
b69ab3196function nextSelectionMode(mode: SelectionMode): SelectionMode {
b69ab3197 if (mode === 'char') {
b69ab3198 return 'word';
b69ab3199 } else if (mode === 'word') {
b69ab31100 return 'line';
b69ab31101 } else {
b69ab31102 return 'char';
b69ab31103 }
b69ab31104}
b69ab31105
b69ab31106/**
b69ab31107 * Extends the current textarea selection to match the `mode`.
b69ab31108 * `pos` is the current cursor position. It is used when `mode`
b69ab31109 * is `char` but the current textarea selection is a range.
b69ab31110 */
b69ab31111function extendTextareaSelection(
b69ab31112 textarea: HTMLTextAreaElement | null,
b69ab31113 mode: SelectionMode,
b69ab31114 pos: number,
b69ab31115): [number, number] {
b69ab31116 if (textarea == null || mode === 'char') {
b69ab31117 return [pos, pos];
b69ab31118 }
b69ab31119 const text = textarea.value;
b69ab31120 const start = textarea.selectionStart;
b69ab31121 const end = textarea.selectionEnd;
b69ab31122 return extendSelection(text, start, end, mode);
b69ab31123}
b69ab31124
b69ab31125/** Extends the selection based on `SelectionMode`. */
b69ab31126function extendSelection(
b69ab31127 text: string,
b69ab31128 startPos: number,
b69ab31129 endPos: number,
b69ab31130 mode: SelectionMode,
b69ab31131): [number, number] {
b69ab31132 let start = startPos;
b69ab31133 let end = endPos;
b69ab31134 const charAt = (i: number) => text.substring(i, i + 1);
b69ab31135 const isNewLine = (i: number) => charAt(i) === '\n';
b69ab31136 if (mode === 'word') {
b69ab31137 const isWord = (i: number): boolean => charAt(i).match(/\w/) !== null;
b69ab31138 const isStartWord = isWord(start);
b69ab31139 while (start > 0 && !isNewLine(start - 1) && isWord(start - 1) === isStartWord) {
b69ab31140 start--;
b69ab31141 }
b69ab31142 while (end < text.length && !isNewLine(end) && isWord(end) === isStartWord) {
b69ab31143 end++;
b69ab31144 }
b69ab31145 } else if (mode === 'line') {
b69ab31146 while (start > 0 && !isNewLine(start - 1)) {
b69ab31147 start--;
b69ab31148 }
b69ab31149 while (end < text.length && !isNewLine(end)) {
b69ab31150 end++;
b69ab31151 }
b69ab31152 }
b69ab31153 return [start, end];
b69ab31154}
b69ab31155
b69ab31156function getSelectedText(textarea: HTMLTextAreaElement): string {
b69ab31157 const {selectionStart, selectionEnd, value} = textarea;
b69ab31158 const text = value.substring(selectionStart, selectionEnd);
b69ab31159 return text;
b69ab31160}
b69ab31161
b69ab31162/** Convert DOMRect to Rect. The latter has mutable properties. */
b69ab31163function DOMRectToRect(domRect: DOMRect | undefined): Rect | undefined {
b69ab31164 if (domRect === undefined) {
b69ab31165 return undefined;
b69ab31166 }
b69ab31167 const {width, height, top, left} = domRect;
b69ab31168 return {width, height, top, left};
b69ab31169}
b69ab31170
b69ab31171/** Convert selections to `Rect`s relative to `container` for rendering. */
b69ab31172function selectionRangeToRects(
b69ab31173 rangeInfos: readonly RangeInfo[],
b69ab31174 container: Element,
b69ab31175 start: number,
b69ab31176 end: number,
b69ab31177): readonly Rect[] {
b69ab31178 if (start === end) {
b69ab31179 return [];
b69ab31180 }
b69ab31181
b69ab31182 const clientRects: Rect[] = [];
b69ab31183 const startRangeInfo = findRangeInfo(rangeInfos, start);
b69ab31184 const endRangeInfo = findRangeInfo(rangeInfos, end);
b69ab31185 for (let i = startRangeInfo?.index ?? rangeInfos.length; i <= (endRangeInfo?.index ?? -1); i++) {
b69ab31186 const rect = getRangeRect(container, {...rangeInfos[i], index: i}, start, end);
b69ab31187 if (rect == null) {
b69ab31188 continue;
b69ab31189 }
b69ab31190 // For empty rect like "\n", make it wide enough to be visible.
b69ab31191 rect.width = Math.max(rect.width, 2);
b69ab31192 if (clientRects.length === 0) {
b69ab31193 clientRects.push(rect);
b69ab31194 } else {
b69ab31195 // Maybe merge with rects[-1].
b69ab31196 const lastRect = clientRects[clientRects.length - 1];
b69ab31197 const lastBottom = lastRect.top + lastRect.height;
b69ab31198 if (lastRect.top == rect.top || lastBottom == rect.top + rect.height) {
b69ab31199 lastRect.width =
b69ab31200 Math.max(lastRect.left + lastRect.width, rect.left + rect.width) - lastRect.left;
b69ab31201 } else {
b69ab31202 // Remove small gaps caused by line-height CSS property.
b69ab31203 const gap = rect.top - lastBottom;
b69ab31204 if (Math.abs(gap) < rect.height / 2) {
b69ab31205 lastRect.height += gap;
b69ab31206 }
b69ab31207 clientRects.push(rect);
b69ab31208 }
b69ab31209 }
b69ab31210 }
b69ab31211 const containerRect = container.getBoundingClientRect();
b69ab31212 const rects = clientRects.map(rect => {
b69ab31213 return {
b69ab31214 width: rect.width,
b69ab31215 height: rect.height,
b69ab31216 top: rect.top - containerRect.top,
b69ab31217 left: rect.left - containerRect.left,
b69ab31218 };
b69ab31219 });
b69ab31220 return rects;
b69ab31221}
b69ab31222
b69ab31223type SelectionHighlightProps = {rects: readonly Rect[]};
b69ab31224
b69ab31225function SelectionHighlight(props: SelectionHighlightProps) {
b69ab31226 return (
b69ab31227 <div className="text-editable-selection-highlight">
b69ab31228 {props.rects.map(rect => (
b69ab31229 <div key={`${rect.top}`} className="text-editable-selection-highlight-line" style={rect} />
b69ab31230 ))}
b69ab31231 </div>
b69ab31232 );
b69ab31233}
b69ab31234
b69ab31235type CaretProps = {height: number; offsetX: number; offsetY: number};
b69ab31236
b69ab31237function Caret(props: CaretProps) {
b69ab31238 const caretRef = useRef<HTMLDivElement>(null);
b69ab31239
b69ab31240 useEffect(() => {
b69ab31241 if (caretRef.current !== null) {
b69ab31242 // Restart blinking when moved.
b69ab31243 restartBlinking(caretRef.current);
b69ab31244 }
b69ab31245 }, [props.offsetX, props.offsetY, caretRef]);
b69ab31246
b69ab31247 const style = {
b69ab31248 height: props.height,
b69ab31249 transform: `translate(${props.offsetX}px, ${props.offsetY}px)`,
b69ab31250 };
b69ab31251
b69ab31252 return <div className="text-editable-caret" ref={caretRef} style={style} />;
b69ab31253}
b69ab31254
b69ab31255/**
b69ab31256 * Plain text editing backed by a hidden textarea.
b69ab31257 *
b69ab31258 * ## Usage
b69ab31259 *
b69ab31260 * Properties:
b69ab31261 * - `value`: The plain text value to edit. This should be the full text,
b69ab31262 * with "context lines" expanded, so the user can Ctrl+A Ctrl+C copy it.
b69ab31263 * - `rangeInfos`: (start, end) information for rendered elements. See below.
b69ab31264 * - `children` with `[data-range-id]` elements. See below.
b69ab31265 * - `onTextChange` handler to update `value`.
b69ab31266 *
b69ab31267 * `RangeInfo[]` and `[data-range-id]` elements. For example:
b69ab31268 *
b69ab31269 * ```tsx
b69ab31270 * const value = "foo\nbar\nbaz\n";
b69ab31271 * const rangeInfos: RangeInfo[] = [
b69ab31272 * {start: 0, end: 4}, // "foo\n", index = 0
b69ab31273 * {start: 4, end: 8}, // "bar\n", index = 1
b69ab31274 * {start: 8, end: 12}, // "baz\n", index = 2
b69ab31275 * ];
b69ab31276 *
b69ab31277 * const children = [
b69ab31278 * <div key={0} data-range-id={0}>{"foo\n"}</div>
b69ab31279 * <div key={1} className="collapsed">[Context line hidden]</div>
b69ab31280 * <div key={2} data-range-id={2}>{"baz\n"}</div>
b69ab31281 * ];
b69ab31282 * ```
b69ab31283 *
b69ab31284 * The `rangeInfos` should cover the entire range of `value` and is sorted.
b69ab31285 * The `[data-range-id]` elements can be missing for ranges, this skips
b69ab31286 * rendering the related ranges, although the user can still Ctrl+A select,
b69ab31287 * and copy or edit them.
b69ab31288 *
b69ab31289 * ## Internals
b69ab31290 *
b69ab31291 * Layout:
b69ab31292 *
b69ab31293 * ```jsx
b69ab31294 * <div>
b69ab31295 * <textarea />
b69ab31296 * <Caret /><SelectionHighlight />
b69ab31297 * <Container>{children}</Container>
b69ab31298 * </div>
b69ab31299 * ```
b69ab31300 *
b69ab31301 * Data flow:
b69ab31302 * - Text
b69ab31303 * - `props.value` -> `<textarea />`
b69ab31304 * - Keyboard on `<textarea />` -> `props.onTextChange` -> new `props.value`.
b69ab31305 * (ex. typing, copy/paste/cut/undo/redo, IME input)
b69ab31306 * - This component does not convert the `<container />` back to `value`.
b69ab31307 * - Selection
b69ab31308 * - Keyboard on `<textarea />` -> `textarea.onSelect` -> `setCaretProps`.
b69ab31309 * (ex. movements with arrow keys, ctrl+arrow keys, home/end, selections
b69ab31310 * with shift+movement keys)
b69ab31311 * - Mouse on `<Container />` -> `textarea.setSelectionRange` -> ...
b69ab31312 * (ex. click to position, double or triple click to select word or line,
b69ab31313 * drag to select a range)
b69ab31314 */
b69ab31315export function TextEditable(props: {
b69ab31316 children?: React.ReactNode;
b69ab31317 value: string;
b69ab31318 rangeInfos: readonly RangeInfo[];
b69ab31319 onTextChange?: (value: string) => void;
b69ab31320 onSelectChange?: (start: number, end: number) => void | [number, number];
b69ab31321}) {
b69ab31322 const textareaRef = useRef<HTMLTextAreaElement>(null);
b69ab31323 const containerRef = useRef<HTMLDivElement>(null);
b69ab31324
b69ab31325 // Event handler states.
b69ab31326 const [isPointerDown, setIsPointerDown] = useState(false);
b69ab31327 const pointerDownPos = useRef<number>(0);
b69ab31328 const selectionMode = useRef<SelectionMode>('char');
b69ab31329
b69ab31330 // Caret and selection highlight states.
b69ab31331 const [focused, setFocused] = useState(false);
b69ab31332 const [caretProps, setCaretProps] = useState<CaretProps>({
b69ab31333 height: 0,
b69ab31334 offsetX: 0,
b69ab31335 offsetY: 0,
b69ab31336 });
b69ab31337 const [highlightProps, setHighlightProps] = useState<SelectionHighlightProps>({rects: []});
b69ab31338
b69ab31339 /** Logic to recalculate caretProps and highlightProps. */
b69ab31340 const recalculatePositions = () => {
b69ab31341 const textarea = textareaRef.current;
b69ab31342 const container = containerRef.current;
b69ab31343 if (textarea == null || container == null) {
b69ab31344 return;
b69ab31345 }
b69ab31346
b69ab31347 const start = textarea.selectionStart;
b69ab31348 const end = textarea.selectionEnd;
b69ab31349
b69ab31350 const nextCaretProps = {...caretProps, height: 0};
b69ab31351 const nextHighlightProps = {...highlightProps};
b69ab31352 const containerRect = container.getBoundingClientRect();
b69ab31353
b69ab31354 if (start === end) {
b69ab31355 const rangeInfo = findRangeInfo(props.rangeInfos, start);
b69ab31356 if (rangeInfo != null) {
b69ab31357 const caretRect = getRangeRect(container, rangeInfo, start, start);
b69ab31358 if (caretRect != null) {
b69ab31359 nextCaretProps.height = caretRect.height;
b69ab31360 nextCaretProps.offsetX = Math.floor(caretRect.left - containerRect.left);
b69ab31361 nextCaretProps.offsetY = Math.round(caretRect.top - containerRect.top);
b69ab31362 }
b69ab31363 }
b69ab31364 nextHighlightProps.rects = [];
b69ab31365 } else {
b69ab31366 nextHighlightProps.rects = selectionRangeToRects(props.rangeInfos, container, start, end);
b69ab31367 nextCaretProps.height = 0;
b69ab31368 }
b69ab31369
b69ab31370 if (!deepEqual(caretProps, nextCaretProps)) {
b69ab31371 setCaretProps(nextCaretProps);
b69ab31372 }
b69ab31373 if (!deepEqual(highlightProps, nextHighlightProps)) {
b69ab31374 setHighlightProps(nextHighlightProps);
b69ab31375 }
b69ab31376 };
b69ab31377
b69ab31378 /** Update caretProps, highlightProps on re-render and resize. */
b69ab31379 useLayoutEffect(() => {
b69ab31380 const container = containerRef.current;
b69ab31381 recalculatePositions();
b69ab31382 if (container == null) {
b69ab31383 return;
b69ab31384 }
b69ab31385 const observer = new ResizeObserver(() => {
b69ab31386 recalculatePositions();
b69ab31387 });
b69ab31388 observer.observe(container);
b69ab31389 return () => {
b69ab31390 observer.disconnect();
b69ab31391 };
b69ab31392 });
b69ab31393
b69ab31394 /**
b69ab31395 * If `startEnd` is set, call `props.onSelectChange` with `startEnd`.
b69ab31396 * Otherwise, call `props.onSelectChange` with the current textarea selection.
b69ab31397 * `props.onSelectChange` might return a new selection to apply.
b69ab31398 */
b69ab31399 const setSelectRange = (startEnd?: [number, number]): [number, number] => {
b69ab31400 const textarea = textareaRef.current;
b69ab31401 const origStart = textarea?.selectionStart ?? 0;
b69ab31402 const origEnd = textarea?.selectionEnd ?? 0;
b69ab31403 const start = startEnd?.[0] ?? origStart;
b69ab31404 const end = startEnd?.[1] ?? origEnd;
b69ab31405 const [nextStart, nextEnd] = props.onSelectChange?.(start, end) || [start, end];
b69ab31406 if (textarea != null && (origStart !== nextStart || origEnd !== nextEnd)) {
b69ab31407 textarea.setSelectionRange(nextStart, nextEnd);
b69ab31408 // textarea onSelect fires after PointerUp. We want live updates during PointerDown/Move.
b69ab31409 recalculatePositions();
b69ab31410 }
b69ab31411 if (!focused) {
b69ab31412 setFocused(true);
b69ab31413 }
b69ab31414 return [nextStart, nextEnd];
b69ab31415 };
b69ab31416
b69ab31417 /** Convert the pointer position to the text position. */
b69ab31418 const pointerToTextPos = (e: React.PointerEvent<Element>): number | undefined => {
b69ab31419 if (e.buttons !== 1) {
b69ab31420 return undefined;
b69ab31421 }
b69ab31422 let rangeElement = null;
b69ab31423 let offset = 0;
b69ab31424 // Firefox supports the "standard" caretPositionFromPoint.
b69ab31425 // TypeScript incorrectly removed it: https://github.com/microsoft/TypeScript/issues/49931
b69ab31426 // eslint-disable-next-line @typescript-eslint/no-explicit-any
b69ab31427 const caretPositionFromPoint = (document as any).caretPositionFromPoint?.bind(document);
b69ab31428 if (caretPositionFromPoint) {
b69ab31429 const caret = caretPositionFromPoint(e.clientX, e.clientY);
b69ab31430 if (caret != null) {
b69ab31431 rangeElement = caret.offsetNode.parentElement;
b69ab31432 offset = caret.offset;
b69ab31433 }
b69ab31434 } else {
b69ab31435 // Chrome/WebKit only supports the "deprecated" caretRangeFromPoint.
b69ab31436 const range = document.caretRangeFromPoint(e.clientX, e.clientY);
b69ab31437 if (range != null) {
b69ab31438 rangeElement = range.startContainer.parentElement;
b69ab31439 offset = range.startOffset;
b69ab31440 }
b69ab31441 }
b69ab31442 const rangeId = rangeElement?.getAttribute('data-range-id');
b69ab31443 if (rangeId == null) {
b69ab31444 return;
b69ab31445 }
b69ab31446 const rangeInfo = props.rangeInfos[parseInt(rangeId)];
b69ab31447 return rangeInfo.start + offset;
b69ab31448 };
b69ab31449
b69ab31450 const handleCopy = (e: React.ClipboardEvent<HTMLElement>) => {
b69ab31451 e.preventDefault();
b69ab31452 const textarea = textareaRef.current;
b69ab31453 if (textarea != null) {
b69ab31454 const text = getSelectedText(textarea);
b69ab31455 e.clipboardData.setData('text/plain', text);
b69ab31456 }
b69ab31457 };
b69ab31458
b69ab31459 const handlePaste = (e: React.ClipboardEvent<HTMLElement>) => {
b69ab31460 e.preventDefault();
b69ab31461 const textarea = textareaRef.current;
b69ab31462 if (textarea != null) {
b69ab31463 const text = e.clipboardData.getData('text/plain');
b69ab31464 textarea.setRangeText(text);
b69ab31465 }
b69ab31466 };
b69ab31467
b69ab31468 const handleCut = (e: React.ClipboardEvent<HTMLElement>) => {
b69ab31469 handleCopy(e);
b69ab31470 const textarea = textareaRef.current;
b69ab31471 if (textarea != null) {
b69ab31472 textarea.setRangeText('');
b69ab31473 }
b69ab31474 };
b69ab31475
b69ab31476 /** Set the "start" selection, or extend selection on double/triple clicks. */
b69ab31477 const handlePointerDown = (e: React.PointerEvent<HTMLElement>) => {
b69ab31478 const pos = pointerToTextPos(e);
b69ab31479 if (pos == null) {
b69ab31480 return;
b69ab31481 }
b69ab31482 setIsPointerDown(true);
b69ab31483 // Shift + click does range selection.
b69ab31484 if (e.shiftKey) {
b69ab31485 handlePointerMove(e);
b69ab31486 // Prevent textarea's default Shift + click handling.
b69ab31487 e.stopPropagation();
b69ab31488 e.preventDefault();
b69ab31489 return;
b69ab31490 }
b69ab31491 // Double or triple click extends the selection.
b69ab31492 const isDoubleTripleClick = pos === pointerDownPos.current;
b69ab31493 if (isDoubleTripleClick) {
b69ab31494 selectionMode.current = nextSelectionMode(selectionMode.current);
b69ab31495 } else {
b69ab31496 selectionMode.current = 'char';
b69ab31497 }
b69ab31498 const [start, end] = (isDoubleTripleClick &&
b69ab31499 extendTextareaSelection(textareaRef.current, selectionMode.current, pos)) || [pos, pos];
b69ab31500 pointerDownPos.current = pos;
b69ab31501 setSelectRange([start, end]);
b69ab31502 };
b69ab31503
b69ab31504 /** Set the "end" selection. */
b69ab31505 const handlePointerMove = (e: React.PointerEvent<HTMLElement>) => {
b69ab31506 const pos = pointerToTextPos(e);
b69ab31507 if (pos == null) {
b69ab31508 return;
b69ab31509 }
b69ab31510 const oldPos = pointerDownPos.current;
b69ab31511 const [start, end] = pos > oldPos ? [oldPos, pos] : [pos, oldPos];
b69ab31512 // Extend [start, end] by word/line selection.
b69ab31513 const textarea = textareaRef.current;
b69ab31514 const [newStart, newEnd] = extendSelection(
b69ab31515 textarea?.value ?? '',
b69ab31516 start,
b69ab31517 end,
b69ab31518 selectionMode.current,
b69ab31519 );
b69ab31520 setSelectRange([newStart, newEnd]);
b69ab31521 };
b69ab31522
b69ab31523 /** Focus the hidden textarea so it handles keyboard events. */
b69ab31524 const handlePointerUpCancel = (_e: React.PointerEvent<HTMLElement>) => {
b69ab31525 // If pointerToTextPos returned null in the first place, do not set focus.
b69ab31526 if (isPointerDown) {
b69ab31527 textareaRef?.current?.focus();
b69ab31528 }
b69ab31529 setIsPointerDown(false);
b69ab31530 };
b69ab31531
b69ab31532 /** Delegate text change to the callsite. */
b69ab31533 const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
b69ab31534 props.onTextChange?.(e.target.value);
b69ab31535 };
b69ab31536
b69ab31537 /** Delegate selection change to the callsite. */
b69ab31538 const handleSelect = (_e: React.SyntheticEvent<HTMLTextAreaElement>) => {
b69ab31539 setSelectRange();
b69ab31540 recalculatePositions();
b69ab31541 };
b69ab31542
b69ab31543 // When typing in a textarea, the browser might perform `scrollIntoView`.
b69ab31544 // Position the textarea to the bottom-right caret position so scrolling
b69ab31545 // works as expected. To test, pick a long change, then press arrow down
b69ab31546 // to move the cursor to the end.
b69ab31547 const textareaStyle = {
b69ab31548 transform: `translate(${caretProps.offsetX}px, ${caretProps.offsetY + caretProps.height}px)`,
b69ab31549 };
b69ab31550
b69ab31551 return (
b69ab31552 // The "group" is used for positioning (relative -> absolute).
b69ab31553 // The PointerUp events are on the root element, not "container" to avoid issues
b69ab31554 // when "container" gets unmounted occasionally, losing the important PointerUp
b69ab31555 // events to set focus on textarea.
b69ab31556 <div
b69ab31557 className="text-editable-group"
b69ab31558 onPointerUp={handlePointerUpCancel}
b69ab31559 onPointerCancel={handlePointerUpCancel}>
b69ab31560 <div className="text-editable-overlay">
b69ab31561 {(focused || isPointerDown) && (
b69ab31562 <>
b69ab31563 <Caret {...caretProps} />
b69ab31564 <SelectionHighlight {...highlightProps} />
b69ab31565 </>
b69ab31566 )}
b69ab31567 </div>
b69ab31568 <textarea
b69ab31569 className="text-editable-hidden-textarea"
b69ab31570 ref={textareaRef}
b69ab31571 value={props.value}
b69ab31572 style={textareaStyle}
b69ab31573 onChange={handleChange}
b69ab31574 onSelect={handleSelect}
b69ab31575 onFocus={() => setFocused(true)}
b69ab31576 onBlur={() => setFocused(false)}
b69ab31577 />
b69ab31578 <div
b69ab31579 className="text-editable-container"
b69ab31580 ref={containerRef}
b69ab31581 role="textbox"
b69ab31582 onDragStart={e => e.preventDefault()}
b69ab31583 onCopy={handleCopy}
b69ab31584 onPaste={handlePaste}
b69ab31585 onCut={handleCut}
b69ab31586 onPointerDown={handlePointerDown}
b69ab31587 onPointerMove={handlePointerMove}>
b69ab31588 {props.children}
b69ab31589 </div>
b69ab31590 </div>
b69ab31591 );
b69ab31592}