|
1 | | -import React from "react" |
| 1 | +import React, { useState, useEffect, useRef, useCallback } from "react" |
2 | 2 |
|
3 | | -interface ContenteditableProps { |
| 3 | +interface ContentEditableProps { |
4 | 4 | placeholder?: string |
| 5 | + onChange: (content: string) => void |
5 | 6 | } |
6 | 7 |
|
7 | | -const Contenteditable: React.FC<ContenteditableProps> = (props) => { |
| 8 | +const ContentEditable: React.FC<ContentEditableProps> = ({ |
| 9 | + placeholder, |
| 10 | + // emitContent, |
| 11 | +}) => { |
| 12 | + const [content, setContent] = useState("") |
| 13 | + const divRef = useRef<HTMLDivElement | null>(null) |
| 14 | + |
| 15 | + useEffect(() => { |
| 16 | + if (divRef.current) { |
| 17 | + divRef.current.style.height = "auto" |
| 18 | + // divRef.current.style.height = divRef.current.scrollHeight + "px" |
| 19 | + } |
| 20 | + }, [content]) |
| 21 | + |
| 22 | + /** |
| 23 | + * Check if the caret is on the last line of an element |
| 24 | + * Returns `false` when the caret is part of a selection |
| 25 | + */ |
| 26 | + const isCaretOnLastLine = useCallback((element: HTMLDivElement): boolean => { |
| 27 | + if (element.ownerDocument.activeElement !== element) return false |
| 28 | + |
| 29 | + // Get the client rect of the current selection |
| 30 | + const window = element.ownerDocument.defaultView |
| 31 | + |
| 32 | + if (!window) return false |
| 33 | + |
| 34 | + const selection = window.getSelection() |
| 35 | + |
| 36 | + if (!selection || selection.rangeCount === 0) return false |
| 37 | + |
| 38 | + const originalCaretRange = selection.getRangeAt(0) |
| 39 | + |
| 40 | + // Bail if there is a selection |
| 41 | + if (originalCaretRange.toString().length > 0) return false |
| 42 | + |
| 43 | + const originalCaretRect = originalCaretRange.getBoundingClientRect() |
| 44 | + |
| 45 | + // Create a range at the end of the last text node |
| 46 | + const endOfElementRange = document.createRange() |
| 47 | + endOfElementRange.selectNodeContents(element) |
| 48 | + |
| 49 | + // The endContainer might not be an actual text node, |
| 50 | + // try to find the last text node inside |
| 51 | + let endContainer = endOfElementRange.endContainer |
| 52 | + let endOffset = 0 |
| 53 | + |
| 54 | + while (endContainer.hasChildNodes() && !(endContainer instanceof Text)) { |
| 55 | + if (!endContainer.lastChild) continue |
| 56 | + |
| 57 | + endContainer = endContainer.lastChild |
| 58 | + endOffset = endContainer instanceof Text ? endContainer.length : 0 |
| 59 | + } |
| 60 | + |
| 61 | + endOfElementRange.setEnd(endContainer, endOffset) |
| 62 | + endOfElementRange.setStart(endContainer, endOffset) |
| 63 | + const endOfElementRect = endOfElementRange.getBoundingClientRect() |
| 64 | + |
| 65 | + return originalCaretRect.bottom === endOfElementRect.bottom |
| 66 | + }, []) |
| 67 | + |
| 68 | + const handleCaretScroll = useCallback( |
| 69 | + (e: KeyboardEvent) => { |
| 70 | + if (!divRef.current) return |
| 71 | + const focus = divRef.current |
| 72 | + switch (e.keyCode) { |
| 73 | + case 38: |
| 74 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 75 | + if ((focus as any).selectionStart === 0) focus.scrollTop = 0 |
| 76 | + break |
| 77 | + case 13: |
| 78 | + case 40: |
| 79 | + if (isCaretOnLastLine(focus)) focus.scrollTop = focus.scrollHeight |
| 80 | + break |
| 81 | + default: |
| 82 | + break |
| 83 | + } |
| 84 | + }, |
| 85 | + [isCaretOnLastLine] |
| 86 | + ) |
| 87 | + |
| 88 | + function handlePasteEvent(e: React.ClipboardEvent<HTMLDivElement>) { |
| 89 | + e.preventDefault() |
| 90 | + |
| 91 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 92 | + const clipboardData = e.clipboardData || (window as any).clipboardData |
| 93 | + const plainText = clipboardData.getData("text/plain") |
| 94 | + |
| 95 | + // Get the current selection |
| 96 | + const sel: Selection | null = window.getSelection() |
| 97 | + if (sel && sel.rangeCount) { |
| 98 | + // Get the first range of the selection |
| 99 | + const range = sel.getRangeAt(0) |
| 100 | + |
| 101 | + // Delete the contents of the range (this is the selected text) |
| 102 | + range.deleteContents() |
| 103 | + |
| 104 | + // Create a new text node containing the pasted text |
| 105 | + const textNode = document.createTextNode(plainText) |
| 106 | + |
| 107 | + // Insert the text node into the range, which will replace the selected text |
| 108 | + range.insertNode(textNode) |
| 109 | + |
| 110 | + // Move the caret to the end of the new text |
| 111 | + range.setStartAfter(textNode) |
| 112 | + sel.removeAllRanges() |
| 113 | + sel.addRange(range) |
| 114 | + |
| 115 | + setContent(divRef.current?.innerText ?? "") |
| 116 | + } else { |
| 117 | + // If there's no selection, just insert the text at the current caret position |
| 118 | + insertTextAtCaret(plainText) |
| 119 | + } |
| 120 | + } |
| 121 | + |
| 122 | + function insertTextAtCaret(text: string) { |
| 123 | + if (!divRef.current) return |
| 124 | + const currentCaretPos = getCaretPosition(divRef.current) |
| 125 | + |
| 126 | + divRef.current.innerText = |
| 127 | + divRef.current.innerText.slice(0, currentCaretPos) + |
| 128 | + text + |
| 129 | + divRef.current.innerText.slice(currentCaretPos) |
| 130 | + |
| 131 | + setContent(divRef.current.innerText) |
| 132 | + divRef.current.scrollTop = divRef.current.scrollHeight |
| 133 | + setCaretPosition(divRef.current, currentCaretPos + text.length) |
| 134 | + } |
| 135 | + |
| 136 | + // Note: setSelectionRange and createTextRange are not supported by contenteditable elements |
| 137 | + |
| 138 | + function setCaretPosition(elem: HTMLElement, pos: number) { |
| 139 | + // Create a new range |
| 140 | + const range = document.createRange() |
| 141 | + |
| 142 | + // Get the child node of the div |
| 143 | + const childNode = elem.childNodes[0] |
| 144 | + |
| 145 | + if (childNode != null) { |
| 146 | + // Set the range to the correct position within the text |
| 147 | + range.setStart(childNode, pos) |
| 148 | + range.setEnd(childNode, pos) |
| 149 | + |
| 150 | + // Get the selection object |
| 151 | + const sel: Selection | null = window.getSelection() |
| 152 | + if (!sel) return |
| 153 | + // Remove any existing selections |
| 154 | + sel.removeAllRanges() |
| 155 | + |
| 156 | + // Add the new range (this will set the cursor position) |
| 157 | + sel.addRange(range) |
| 158 | + } else { |
| 159 | + // If the div is empty, focus it |
| 160 | + elem.focus() |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + function getCaretPosition(editableDiv: HTMLElement) { |
| 165 | + let caretPos = 0, |
| 166 | + range |
| 167 | + if (window.getSelection) { |
| 168 | + const sel: Selection | null = window.getSelection() |
| 169 | + if (sel && sel.rangeCount) { |
| 170 | + range = sel.getRangeAt(0) |
| 171 | + if (range.commonAncestorContainer.parentNode === editableDiv) { |
| 172 | + caretPos = range.endOffset |
| 173 | + } |
| 174 | + } |
| 175 | + } else if (document.getSelection() && document.getSelection()?.getRangeAt) { |
| 176 | + range = document.getSelection()?.getRangeAt(0) |
| 177 | + if (range && range.commonAncestorContainer.parentNode === editableDiv) { |
| 178 | + const tempEl = document.createElement("span") |
| 179 | + editableDiv.insertBefore(tempEl, editableDiv.firstChild) |
| 180 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 181 | + const tempRange: any = range.cloneRange() |
| 182 | + tempRange.moveToElementText(tempEl) |
| 183 | + tempRange.setEndPoint("EndToEnd", range) |
| 184 | + caretPos = tempRange.text.length |
| 185 | + } |
| 186 | + } |
| 187 | + return caretPos |
| 188 | + } |
| 189 | + |
| 190 | + function handleKeyDown(e: React.KeyboardEvent) { |
| 191 | + if ((e.key === "Delete" || e.key === "Backspace") && isAllTextSelected()) { |
| 192 | + console.log("delete all", isAllTextSelected()) |
| 193 | + e.preventDefault() |
| 194 | + if (divRef.current) { |
| 195 | + divRef.current.innerText = "" |
| 196 | + setContent("") |
| 197 | + } |
| 198 | + } |
| 199 | + } |
| 200 | + |
| 201 | + const isAllTextSelected = (): boolean => { |
| 202 | + const sel: Selection | null = window.getSelection() |
| 203 | + |
| 204 | + // Matches newline characters that are either followed by another newline |
| 205 | + // character (\n) or the end of the string ($). |
| 206 | + const newlineCount = (divRef.current?.innerText.match(/\n(\n|$)/g) || []) |
| 207 | + .length |
| 208 | + return sel |
| 209 | + ? sel.toString().length + newlineCount === |
| 210 | + divRef.current?.innerText.length |
| 211 | + : false |
| 212 | + } |
| 213 | + |
| 214 | + useEffect(() => { |
| 215 | + document.addEventListener("keyup", handleCaretScroll) |
| 216 | + return () => document.removeEventListener("keyup", handleCaretScroll) |
| 217 | + }, [handleCaretScroll]) |
| 218 | + |
8 | 219 | return ( |
9 | 220 | <div |
10 | | - contentEditable |
11 | | - dir="auto" |
12 | | - role="textbox" |
13 | | - aria-label={props.placeholder || ""} |
14 | | - /> |
| 221 | + style={{ |
| 222 | + display: "flex", |
| 223 | + justifyContent: "flex-start", |
| 224 | + alignItems: "center", |
| 225 | + width: "calc(100% - 32px)", |
| 226 | + padding: "0 16px", |
| 227 | + }} |
| 228 | + > |
| 229 | + <div |
| 230 | + ref={divRef} |
| 231 | + contentEditable |
| 232 | + defaultValue={content} |
| 233 | + dir="auto" |
| 234 | + role="textbox" |
| 235 | + aria-label={placeholder ?? ""} |
| 236 | + style={{ |
| 237 | + width: "100%", |
| 238 | + border: "1px solid #ccc", |
| 239 | + borderRadius: "0.35rem", |
| 240 | + padding: "calc((1.5rem * 1.3125)/2) 0 calc((1.5rem * 1.3125)/2) 1rem", |
| 241 | + minHeight: "19px", |
| 242 | + maxHeight: "160px", |
| 243 | + overflow: "auto", |
| 244 | + height: "auto", |
| 245 | + lineHeight: 1.3, |
| 246 | + textAlign: "initial", |
| 247 | + wordBreak: "break-word", |
| 248 | + unicodeBidi: "plaintext", |
| 249 | + }} |
| 250 | + onInput={(e: React.FormEvent<HTMLDivElement>) => |
| 251 | + setContent(e.currentTarget.innerText) |
| 252 | + } |
| 253 | + onPaste={(e) => handlePasteEvent(e)} |
| 254 | + onKeyDown={handleKeyDown} |
| 255 | + /> |
| 256 | + {!content && ( |
| 257 | + <span |
| 258 | + dir="auto" |
| 259 | + style={{ |
| 260 | + position: "absolute", |
| 261 | + color: "#a2acb4", |
| 262 | + pointerEvents: "none", |
| 263 | + textAlign: "initial", |
| 264 | + marginLeft: "1rem", |
| 265 | + }} |
| 266 | + > |
| 267 | + {placeholder ?? ""} |
| 268 | + </span> |
| 269 | + )} |
| 270 | + </div> |
15 | 271 | ) |
16 | 272 | } |
17 | 273 |
|
18 | | -export default Contenteditable |
| 274 | +export default ContentEditable |
0 commit comments