18.7 KB593 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import deepEqual from 'fast-deep-equal';
9import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
10
11import './TextEditable.css';
12
13/** Text selection range. Unit: characters. */
14export type RangeInfo = {
15 start: number;
16 end: number;
17};
18
19/**
20 * The `index` suggests that the `RangeInfo` comes form `rangeInfos[index]`,
21 * and we expect the corresponding DOM element at `[data-range-id=index]`.
22 */
23type RangeInfoWithIndex = RangeInfo & {index: number};
24
25/** Like DOMRect, but properties are mutable. */
26type Rect = {
27 top: number;
28 left: number;
29 width: number;
30 height: number;
31};
32
33/**
34 * Find the `RangeInfo` containing the given `pos` using binary search.
35 * The `end` of the last `RangeInfo` is treated as inclusive.
36 */
37function findRangeInfo(infos: readonly RangeInfo[], pos: number): RangeInfoWithIndex | null {
38 let start = 0;
39 let end = infos.length;
40 while (start < end) {
41 // eslint-disable-next-line no-bitwise
42 const mid = (start + end) >> 1;
43 const info = infos[mid];
44 const isEnd = mid === infos.length - 1 ? 1 : 0;
45 if (info.start <= pos && pos < info.end + isEnd) {
46 return {...info, index: mid};
47 } else if (info.start > pos) {
48 end = mid;
49 } else {
50 start = mid + 1;
51 }
52 }
53 return null;
54}
55
56/** Restart the "blink" animation. */
57function restartBlinking(element: Element) {
58 for (const animation of element.getAnimations()) {
59 if ((animation as CSSAnimation).animationName === 'blink') {
60 animation.cancel();
61 animation.play();
62 return;
63 }
64 }
65}
66
67/**
68 * Get the client rect of the intersection of `rangeInfo` and `start..end`.
69 * The container must have `[data-range-id=index]` elements. This function
70 * locates the related element by looking for `data-range-id` == `rangeInfo.index`.
71 */
72function getRangeRect(
73 container: Element,
74 rangeInfo: RangeInfoWithIndex,
75 start: number,
76 end: number,
77): Rect | undefined {
78 const span = container.querySelector(`[data-range-id="${rangeInfo.index}"]`);
79 const textNode = span?.firstChild;
80 if (textNode?.nodeType !== Node.TEXT_NODE) {
81 return undefined;
82 }
83 const range = document.createRange();
84 const textLen = textNode.textContent?.length ?? 0;
85 range.setStart(textNode, Math.min(textLen, Math.max(start - rangeInfo.start, 0)));
86 range.setEnd(textNode, Math.min(textLen, end - rangeInfo.start));
87 return DOMRectToRect(range.getBoundingClientRect());
88}
89
90/**
91 * word/line: selection is extended to word/line boundary.
92 * This is used when you double/triple click then optionally drag select.
93 */
94type SelectionMode = 'char' | 'word' | 'line';
95
96function nextSelectionMode(mode: SelectionMode): SelectionMode {
97 if (mode === 'char') {
98 return 'word';
99 } else if (mode === 'word') {
100 return 'line';
101 } else {
102 return 'char';
103 }
104}
105
106/**
107 * Extends the current textarea selection to match the `mode`.
108 * `pos` is the current cursor position. It is used when `mode`
109 * is `char` but the current textarea selection is a range.
110 */
111function extendTextareaSelection(
112 textarea: HTMLTextAreaElement | null,
113 mode: SelectionMode,
114 pos: number,
115): [number, number] {
116 if (textarea == null || mode === 'char') {
117 return [pos, pos];
118 }
119 const text = textarea.value;
120 const start = textarea.selectionStart;
121 const end = textarea.selectionEnd;
122 return extendSelection(text, start, end, mode);
123}
124
125/** Extends the selection based on `SelectionMode`. */
126function extendSelection(
127 text: string,
128 startPos: number,
129 endPos: number,
130 mode: SelectionMode,
131): [number, number] {
132 let start = startPos;
133 let end = endPos;
134 const charAt = (i: number) => text.substring(i, i + 1);
135 const isNewLine = (i: number) => charAt(i) === '\n';
136 if (mode === 'word') {
137 const isWord = (i: number): boolean => charAt(i).match(/\w/) !== null;
138 const isStartWord = isWord(start);
139 while (start > 0 && !isNewLine(start - 1) && isWord(start - 1) === isStartWord) {
140 start--;
141 }
142 while (end < text.length && !isNewLine(end) && isWord(end) === isStartWord) {
143 end++;
144 }
145 } else if (mode === 'line') {
146 while (start > 0 && !isNewLine(start - 1)) {
147 start--;
148 }
149 while (end < text.length && !isNewLine(end)) {
150 end++;
151 }
152 }
153 return [start, end];
154}
155
156function getSelectedText(textarea: HTMLTextAreaElement): string {
157 const {selectionStart, selectionEnd, value} = textarea;
158 const text = value.substring(selectionStart, selectionEnd);
159 return text;
160}
161
162/** Convert DOMRect to Rect. The latter has mutable properties. */
163function DOMRectToRect(domRect: DOMRect | undefined): Rect | undefined {
164 if (domRect === undefined) {
165 return undefined;
166 }
167 const {width, height, top, left} = domRect;
168 return {width, height, top, left};
169}
170
171/** Convert selections to `Rect`s relative to `container` for rendering. */
172function selectionRangeToRects(
173 rangeInfos: readonly RangeInfo[],
174 container: Element,
175 start: number,
176 end: number,
177): readonly Rect[] {
178 if (start === end) {
179 return [];
180 }
181
182 const clientRects: Rect[] = [];
183 const startRangeInfo = findRangeInfo(rangeInfos, start);
184 const endRangeInfo = findRangeInfo(rangeInfos, end);
185 for (let i = startRangeInfo?.index ?? rangeInfos.length; i <= (endRangeInfo?.index ?? -1); i++) {
186 const rect = getRangeRect(container, {...rangeInfos[i], index: i}, start, end);
187 if (rect == null) {
188 continue;
189 }
190 // For empty rect like "\n", make it wide enough to be visible.
191 rect.width = Math.max(rect.width, 2);
192 if (clientRects.length === 0) {
193 clientRects.push(rect);
194 } else {
195 // Maybe merge with rects[-1].
196 const lastRect = clientRects[clientRects.length - 1];
197 const lastBottom = lastRect.top + lastRect.height;
198 if (lastRect.top == rect.top || lastBottom == rect.top + rect.height) {
199 lastRect.width =
200 Math.max(lastRect.left + lastRect.width, rect.left + rect.width) - lastRect.left;
201 } else {
202 // Remove small gaps caused by line-height CSS property.
203 const gap = rect.top - lastBottom;
204 if (Math.abs(gap) < rect.height / 2) {
205 lastRect.height += gap;
206 }
207 clientRects.push(rect);
208 }
209 }
210 }
211 const containerRect = container.getBoundingClientRect();
212 const rects = clientRects.map(rect => {
213 return {
214 width: rect.width,
215 height: rect.height,
216 top: rect.top - containerRect.top,
217 left: rect.left - containerRect.left,
218 };
219 });
220 return rects;
221}
222
223type SelectionHighlightProps = {rects: readonly Rect[]};
224
225function SelectionHighlight(props: SelectionHighlightProps) {
226 return (
227 <div className="text-editable-selection-highlight">
228 {props.rects.map(rect => (
229 <div key={`${rect.top}`} className="text-editable-selection-highlight-line" style={rect} />
230 ))}
231 </div>
232 );
233}
234
235type CaretProps = {height: number; offsetX: number; offsetY: number};
236
237function Caret(props: CaretProps) {
238 const caretRef = useRef<HTMLDivElement>(null);
239
240 useEffect(() => {
241 if (caretRef.current !== null) {
242 // Restart blinking when moved.
243 restartBlinking(caretRef.current);
244 }
245 }, [props.offsetX, props.offsetY, caretRef]);
246
247 const style = {
248 height: props.height,
249 transform: `translate(${props.offsetX}px, ${props.offsetY}px)`,
250 };
251
252 return <div className="text-editable-caret" ref={caretRef} style={style} />;
253}
254
255/**
256 * Plain text editing backed by a hidden textarea.
257 *
258 * ## Usage
259 *
260 * Properties:
261 * - `value`: The plain text value to edit. This should be the full text,
262 * with "context lines" expanded, so the user can Ctrl+A Ctrl+C copy it.
263 * - `rangeInfos`: (start, end) information for rendered elements. See below.
264 * - `children` with `[data-range-id]` elements. See below.
265 * - `onTextChange` handler to update `value`.
266 *
267 * `RangeInfo[]` and `[data-range-id]` elements. For example:
268 *
269 * ```tsx
270 * const value = "foo\nbar\nbaz\n";
271 * const rangeInfos: RangeInfo[] = [
272 * {start: 0, end: 4}, // "foo\n", index = 0
273 * {start: 4, end: 8}, // "bar\n", index = 1
274 * {start: 8, end: 12}, // "baz\n", index = 2
275 * ];
276 *
277 * const children = [
278 * <div key={0} data-range-id={0}>{"foo\n"}</div>
279 * <div key={1} className="collapsed">[Context line hidden]</div>
280 * <div key={2} data-range-id={2}>{"baz\n"}</div>
281 * ];
282 * ```
283 *
284 * The `rangeInfos` should cover the entire range of `value` and is sorted.
285 * The `[data-range-id]` elements can be missing for ranges, this skips
286 * rendering the related ranges, although the user can still Ctrl+A select,
287 * and copy or edit them.
288 *
289 * ## Internals
290 *
291 * Layout:
292 *
293 * ```jsx
294 * <div>
295 * <textarea />
296 * <Caret /><SelectionHighlight />
297 * <Container>{children}</Container>
298 * </div>
299 * ```
300 *
301 * Data flow:
302 * - Text
303 * - `props.value` -> `<textarea />`
304 * - Keyboard on `<textarea />` -> `props.onTextChange` -> new `props.value`.
305 * (ex. typing, copy/paste/cut/undo/redo, IME input)
306 * - This component does not convert the `<container />` back to `value`.
307 * - Selection
308 * - Keyboard on `<textarea />` -> `textarea.onSelect` -> `setCaretProps`.
309 * (ex. movements with arrow keys, ctrl+arrow keys, home/end, selections
310 * with shift+movement keys)
311 * - Mouse on `<Container />` -> `textarea.setSelectionRange` -> ...
312 * (ex. click to position, double or triple click to select word or line,
313 * drag to select a range)
314 */
315export function TextEditable(props: {
316 children?: React.ReactNode;
317 value: string;
318 rangeInfos: readonly RangeInfo[];
319 onTextChange?: (value: string) => void;
320 onSelectChange?: (start: number, end: number) => void | [number, number];
321}) {
322 const textareaRef = useRef<HTMLTextAreaElement>(null);
323 const containerRef = useRef<HTMLDivElement>(null);
324
325 // Event handler states.
326 const [isPointerDown, setIsPointerDown] = useState(false);
327 const pointerDownPos = useRef<number>(0);
328 const selectionMode = useRef<SelectionMode>('char');
329
330 // Caret and selection highlight states.
331 const [focused, setFocused] = useState(false);
332 const [caretProps, setCaretProps] = useState<CaretProps>({
333 height: 0,
334 offsetX: 0,
335 offsetY: 0,
336 });
337 const [highlightProps, setHighlightProps] = useState<SelectionHighlightProps>({rects: []});
338
339 /** Logic to recalculate caretProps and highlightProps. */
340 const recalculatePositions = () => {
341 const textarea = textareaRef.current;
342 const container = containerRef.current;
343 if (textarea == null || container == null) {
344 return;
345 }
346
347 const start = textarea.selectionStart;
348 const end = textarea.selectionEnd;
349
350 const nextCaretProps = {...caretProps, height: 0};
351 const nextHighlightProps = {...highlightProps};
352 const containerRect = container.getBoundingClientRect();
353
354 if (start === end) {
355 const rangeInfo = findRangeInfo(props.rangeInfos, start);
356 if (rangeInfo != null) {
357 const caretRect = getRangeRect(container, rangeInfo, start, start);
358 if (caretRect != null) {
359 nextCaretProps.height = caretRect.height;
360 nextCaretProps.offsetX = Math.floor(caretRect.left - containerRect.left);
361 nextCaretProps.offsetY = Math.round(caretRect.top - containerRect.top);
362 }
363 }
364 nextHighlightProps.rects = [];
365 } else {
366 nextHighlightProps.rects = selectionRangeToRects(props.rangeInfos, container, start, end);
367 nextCaretProps.height = 0;
368 }
369
370 if (!deepEqual(caretProps, nextCaretProps)) {
371 setCaretProps(nextCaretProps);
372 }
373 if (!deepEqual(highlightProps, nextHighlightProps)) {
374 setHighlightProps(nextHighlightProps);
375 }
376 };
377
378 /** Update caretProps, highlightProps on re-render and resize. */
379 useLayoutEffect(() => {
380 const container = containerRef.current;
381 recalculatePositions();
382 if (container == null) {
383 return;
384 }
385 const observer = new ResizeObserver(() => {
386 recalculatePositions();
387 });
388 observer.observe(container);
389 return () => {
390 observer.disconnect();
391 };
392 });
393
394 /**
395 * If `startEnd` is set, call `props.onSelectChange` with `startEnd`.
396 * Otherwise, call `props.onSelectChange` with the current textarea selection.
397 * `props.onSelectChange` might return a new selection to apply.
398 */
399 const setSelectRange = (startEnd?: [number, number]): [number, number] => {
400 const textarea = textareaRef.current;
401 const origStart = textarea?.selectionStart ?? 0;
402 const origEnd = textarea?.selectionEnd ?? 0;
403 const start = startEnd?.[0] ?? origStart;
404 const end = startEnd?.[1] ?? origEnd;
405 const [nextStart, nextEnd] = props.onSelectChange?.(start, end) || [start, end];
406 if (textarea != null && (origStart !== nextStart || origEnd !== nextEnd)) {
407 textarea.setSelectionRange(nextStart, nextEnd);
408 // textarea onSelect fires after PointerUp. We want live updates during PointerDown/Move.
409 recalculatePositions();
410 }
411 if (!focused) {
412 setFocused(true);
413 }
414 return [nextStart, nextEnd];
415 };
416
417 /** Convert the pointer position to the text position. */
418 const pointerToTextPos = (e: React.PointerEvent<Element>): number | undefined => {
419 if (e.buttons !== 1) {
420 return undefined;
421 }
422 let rangeElement = null;
423 let offset = 0;
424 // Firefox supports the "standard" caretPositionFromPoint.
425 // TypeScript incorrectly removed it: https://github.com/microsoft/TypeScript/issues/49931
426 // eslint-disable-next-line @typescript-eslint/no-explicit-any
427 const caretPositionFromPoint = (document as any).caretPositionFromPoint?.bind(document);
428 if (caretPositionFromPoint) {
429 const caret = caretPositionFromPoint(e.clientX, e.clientY);
430 if (caret != null) {
431 rangeElement = caret.offsetNode.parentElement;
432 offset = caret.offset;
433 }
434 } else {
435 // Chrome/WebKit only supports the "deprecated" caretRangeFromPoint.
436 const range = document.caretRangeFromPoint(e.clientX, e.clientY);
437 if (range != null) {
438 rangeElement = range.startContainer.parentElement;
439 offset = range.startOffset;
440 }
441 }
442 const rangeId = rangeElement?.getAttribute('data-range-id');
443 if (rangeId == null) {
444 return;
445 }
446 const rangeInfo = props.rangeInfos[parseInt(rangeId)];
447 return rangeInfo.start + offset;
448 };
449
450 const handleCopy = (e: React.ClipboardEvent<HTMLElement>) => {
451 e.preventDefault();
452 const textarea = textareaRef.current;
453 if (textarea != null) {
454 const text = getSelectedText(textarea);
455 e.clipboardData.setData('text/plain', text);
456 }
457 };
458
459 const handlePaste = (e: React.ClipboardEvent<HTMLElement>) => {
460 e.preventDefault();
461 const textarea = textareaRef.current;
462 if (textarea != null) {
463 const text = e.clipboardData.getData('text/plain');
464 textarea.setRangeText(text);
465 }
466 };
467
468 const handleCut = (e: React.ClipboardEvent<HTMLElement>) => {
469 handleCopy(e);
470 const textarea = textareaRef.current;
471 if (textarea != null) {
472 textarea.setRangeText('');
473 }
474 };
475
476 /** Set the "start" selection, or extend selection on double/triple clicks. */
477 const handlePointerDown = (e: React.PointerEvent<HTMLElement>) => {
478 const pos = pointerToTextPos(e);
479 if (pos == null) {
480 return;
481 }
482 setIsPointerDown(true);
483 // Shift + click does range selection.
484 if (e.shiftKey) {
485 handlePointerMove(e);
486 // Prevent textarea's default Shift + click handling.
487 e.stopPropagation();
488 e.preventDefault();
489 return;
490 }
491 // Double or triple click extends the selection.
492 const isDoubleTripleClick = pos === pointerDownPos.current;
493 if (isDoubleTripleClick) {
494 selectionMode.current = nextSelectionMode(selectionMode.current);
495 } else {
496 selectionMode.current = 'char';
497 }
498 const [start, end] = (isDoubleTripleClick &&
499 extendTextareaSelection(textareaRef.current, selectionMode.current, pos)) || [pos, pos];
500 pointerDownPos.current = pos;
501 setSelectRange([start, end]);
502 };
503
504 /** Set the "end" selection. */
505 const handlePointerMove = (e: React.PointerEvent<HTMLElement>) => {
506 const pos = pointerToTextPos(e);
507 if (pos == null) {
508 return;
509 }
510 const oldPos = pointerDownPos.current;
511 const [start, end] = pos > oldPos ? [oldPos, pos] : [pos, oldPos];
512 // Extend [start, end] by word/line selection.
513 const textarea = textareaRef.current;
514 const [newStart, newEnd] = extendSelection(
515 textarea?.value ?? '',
516 start,
517 end,
518 selectionMode.current,
519 );
520 setSelectRange([newStart, newEnd]);
521 };
522
523 /** Focus the hidden textarea so it handles keyboard events. */
524 const handlePointerUpCancel = (_e: React.PointerEvent<HTMLElement>) => {
525 // If pointerToTextPos returned null in the first place, do not set focus.
526 if (isPointerDown) {
527 textareaRef?.current?.focus();
528 }
529 setIsPointerDown(false);
530 };
531
532 /** Delegate text change to the callsite. */
533 const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
534 props.onTextChange?.(e.target.value);
535 };
536
537 /** Delegate selection change to the callsite. */
538 const handleSelect = (_e: React.SyntheticEvent<HTMLTextAreaElement>) => {
539 setSelectRange();
540 recalculatePositions();
541 };
542
543 // When typing in a textarea, the browser might perform `scrollIntoView`.
544 // Position the textarea to the bottom-right caret position so scrolling
545 // works as expected. To test, pick a long change, then press arrow down
546 // to move the cursor to the end.
547 const textareaStyle = {
548 transform: `translate(${caretProps.offsetX}px, ${caretProps.offsetY + caretProps.height}px)`,
549 };
550
551 return (
552 // The "group" is used for positioning (relative -> absolute).
553 // The PointerUp events are on the root element, not "container" to avoid issues
554 // when "container" gets unmounted occasionally, losing the important PointerUp
555 // events to set focus on textarea.
556 <div
557 className="text-editable-group"
558 onPointerUp={handlePointerUpCancel}
559 onPointerCancel={handlePointerUpCancel}>
560 <div className="text-editable-overlay">
561 {(focused || isPointerDown) && (
562 <>
563 <Caret {...caretProps} />
564 <SelectionHighlight {...highlightProps} />
565 </>
566 )}
567 </div>
568 <textarea
569 className="text-editable-hidden-textarea"
570 ref={textareaRef}
571 value={props.value}
572 style={textareaStyle}
573 onChange={handleChange}
574 onSelect={handleSelect}
575 onFocus={() => setFocused(true)}
576 onBlur={() => setFocused(false)}
577 />
578 <div
579 className="text-editable-container"
580 ref={containerRef}
581 role="textbox"
582 onDragStart={e => e.preventDefault()}
583 onCopy={handleCopy}
584 onPaste={handlePaste}
585 onCut={handleCut}
586 onPointerDown={handlePointerDown}
587 onPointerMove={handlePointerMove}>
588 {props.children}
589 </div>
590 </div>
591 );
592}
593