Skip to content

Commit d803f0f

Browse files
committed
feat: add first component code draft
1 parent 85668a1 commit d803f0f

1 file changed

Lines changed: 265 additions & 9 deletions

File tree

Lines changed: 265 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,274 @@
1-
import React from "react"
1+
import React, { useState, useEffect, useRef, useCallback } from "react"
22

3-
interface ContenteditableProps {
3+
interface ContentEditableProps {
44
placeholder?: string
5+
onChange: (content: string) => void
56
}
67

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+
8219
return (
9220
<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>
15271
)
16272
}
17273

18-
export default Contenteditable
274+
export default ContentEditable

0 commit comments

Comments
 (0)