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