@@ -4,9 +4,11 @@ interface ContentEditableProps {
44 containerClassName ?: string
55 contentEditableClassName ?: string
66 placeholderClassName ?: string
7+ charsCounterClassName ?: string
78 placeholder ?: string
89 disabled ?: boolean
910 updatedContent ?: string
11+ maxLength ?: number
1012 onChange ?: ( content : string ) => void
1113 onKeyUp ?: ( e : React . KeyboardEvent ) => void
1214 onKeyDown ?: ( e : React . KeyboardEvent ) => void
@@ -15,13 +17,24 @@ interface ContentEditableProps {
1517 onContentExternalUpdate ?: ( content : string ) => void
1618}
1719
20+ // Helper function to check if content length is within maxLength
21+ const isContentWithinMaxLength = (
22+ content : string ,
23+ maxLength ?: number
24+ ) : boolean => {
25+ if ( ! maxLength ) return true
26+ return content . length <= maxLength
27+ }
28+
1829const ContentEditable : React . FC < ContentEditableProps > = ( {
1930 containerClassName,
2031 contentEditableClassName,
2132 placeholderClassName,
33+ charsCounterClassName,
2234 placeholder,
2335 disabled,
2436 updatedContent,
37+ maxLength,
2538 onChange,
2639 onKeyUp,
2740 onKeyDown,
@@ -43,9 +56,11 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
4356 useEffect ( ( ) => {
4457 if ( divRef . current ) {
4558 divRef . current . style . height = "auto"
46- if ( onChange ) onChange ( content )
59+ if ( onChange && isContentWithinMaxLength ( content , maxLength ) ) {
60+ onChange ( content )
61+ }
4762 }
48- } , [ content , onChange ] )
63+ } , [ content , onChange , maxLength ] )
4964
5065 /**
5166 * Checks if the caret is on the last line of a contenteditable element
@@ -125,30 +140,40 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
125140 const clipboardData = e . clipboardData || ( window as any ) . clipboardData
126141 const plainText = clipboardData . getData ( "text/plain" )
127142
128- // Get the current selection
143+ // Get the current selection and current content
129144 const sel : Selection | null = window . getSelection ( )
145+ const currentContent = divRef . current ?. innerText || ""
146+
130147 if ( sel && sel . rangeCount ) {
131- // Get the first range of the selection
132148 const range = sel . getRangeAt ( 0 )
133-
134- // Delete the contents of the range (this is the selected text)
135- range . deleteContents ( )
136-
137- // Create a new text node containing the pasted text
138- const textNode = document . createTextNode ( plainText )
139-
140- // Insert the text node into the range, which will replace the selected text
141- range . insertNode ( textNode )
142-
143- // Move the caret to the end of the new text
144- range . setStartAfter ( textNode )
145- sel . removeAllRanges ( )
146- sel . addRange ( range )
147-
148- setContent ( divRef . current ?. innerText ?? "" )
149+ const selectedText = range . toString ( )
150+
151+ // Calculate how much text we can insert
152+ const availableSpace = maxLength
153+ ? maxLength - ( currentContent . length - selectedText . length )
154+ : plainText . length
155+ const truncatedText = plainText . slice ( 0 , availableSpace )
156+
157+ if ( truncatedText . length > 0 ) {
158+ range . deleteContents ( )
159+ const textNode = document . createTextNode ( truncatedText )
160+ range . insertNode ( textNode )
161+ range . setStartAfter ( textNode )
162+ sel . removeAllRanges ( )
163+ sel . addRange ( range )
164+
165+ setContent ( divRef . current ?. innerText ?? "" )
166+ }
149167 } else {
150- // If there's no selection, just insert the text at the current caret position
151- insertTextAtCaret ( plainText )
168+ // If there isn't a selection, check if we can insert at current position
169+ const availableSpace = maxLength
170+ ? maxLength - currentContent . length
171+ : plainText . length
172+ const truncatedText = plainText . slice ( 0 , availableSpace )
173+
174+ if ( truncatedText . length > 0 ) {
175+ insertTextAtCaret ( truncatedText )
176+ }
152177 }
153178 }
154179
@@ -205,6 +230,21 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
205230 }
206231 }
207232
233+ /**
234+ * Sets the caret (text cursor) position at the end of the specified contenteditable element
235+ * @param editableDiv - The HTMLElement representing the contenteditable div where the caret should be placed
236+ */
237+ function setCaretAtTheEnd ( editableDiv : HTMLElement ) {
238+ const range = document . createRange ( )
239+ const sel = window . getSelection ( )
240+ if ( editableDiv . lastChild && sel ) {
241+ range . setStartAfter ( editableDiv . lastChild )
242+ range . collapse ( true )
243+ sel . removeAllRanges ( )
244+ sel . addRange ( range )
245+ }
246+ }
247+
208248 /**
209249 * Retrieves the caret position within the contentEditable element
210250 * @param editableDiv - The contentEditable element
@@ -288,8 +328,18 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
288328 unicodeBidi : "plaintext" ,
289329 } }
290330 onInput = { ( e : React . FormEvent < HTMLDivElement > ) => {
291- if ( disabled ) return
292- setContent ( e . currentTarget . innerText )
331+ const currentContent = e . currentTarget . innerText
332+ if (
333+ disabled ||
334+ ! isContentWithinMaxLength ( currentContent , maxLength )
335+ ) {
336+ if ( divRef . current ) {
337+ divRef . current . innerText = content
338+ setCaretAtTheEnd ( divRef . current )
339+ }
340+ return
341+ }
342+ setContent ( currentContent )
293343 } }
294344 onPaste = { ( e ) => {
295345 if ( disabled ) return
@@ -323,6 +373,17 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
323373 { placeholder ?? "" }
324374 </ span >
325375 ) }
376+ { ! ! maxLength && (
377+ < span
378+ dir = "auto"
379+ className = { charsCounterClassName }
380+ style = { {
381+ marginLeft : "1rem" ,
382+ } }
383+ >
384+ { `${ content . length ?? 0 } /${ maxLength } ` }
385+ </ span >
386+ ) }
326387 </ div >
327388 )
328389}
0 commit comments