| 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 | |
| 8 | import deepEqual from 'fast-deep-equal'; |
| 9 | import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'; |
| 10 | |
| 11 | import './TextEditable.css'; |
| 12 | |
| 13 | /** Text selection range. Unit: characters. */ |
| 14 | export 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 | */ |
| 23 | type RangeInfoWithIndex = RangeInfo & {index: number}; |
| 24 | |
| 25 | /** Like DOMRect, but properties are mutable. */ |
| 26 | type 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 | */ |
| 37 | function 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. */ |
| 57 | function 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 | */ |
| 72 | function 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 | */ |
| 94 | type SelectionMode = 'char' | 'word' | 'line'; |
| 95 | |
| 96 | function 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 | */ |
| 111 | function 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`. */ |
| 126 | function 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 | |
| 156 | function 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. */ |
| 163 | function 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. */ |
| 172 | function 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 | |
| 223 | type SelectionHighlightProps = {rects: readonly Rect[]}; |
| 224 | |
| 225 | function 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 | |
| 235 | type CaretProps = {height: number; offsetX: number; offsetY: number}; |
| 236 | |
| 237 | function 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 | */ |
| 315 | export 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 | |