diff --git a/README.md b/README.md index d0acd87..1c799c7 100644 --- a/README.md +++ b/README.md @@ -51,20 +51,22 @@ export default App > All props are optional, that's how you can **fully customize** it! -| Name | Optional | Type | Description | -| ------------------------ | -------- | ------------------- | --------------------------------------------------------------------- | -| containerClassName | ✔️ | `string` | Custom classes for the wrapper div | -| contentEditableClassName | ✔️ | `string` | Custom classes for the input element | -| placeholderClassName | ✔️ | `string` | Custom classes for the placeholder text | -| placeholder | ✔️ | `string` | Input placeholder text | -| disabled | ✔️ | `boolean` | Flag that disables the input element | -| updatedContent | ✔️ | `string` | Text injected from parent element into the input as the current value | -| onContentExternalUpdate | ✔️ | `(content) => void` | Method that emits the injected content by the `updatedContent` prop | -| onChange | ✔️ | `(content) => void` | Method that emits the current content as a string | -| onKeyUp | ✔️ | `(e) => void` | Method that emits the keyUp keyboard event | -| onKeyDown | ✔️ | `(e) => void` | Method that emits the keyDown keyboard event | -| onFocus | ✔️ | `(e) => void` | Method that emits the focus event | -| onBlur | ✔️ | `(e) => void` | Method that emits the blur event | +| Name | Optional | Type | Description | +| ------------------------ | -------- | ------------------- | --------------------------------------------------------------------------- | +| containerClassName | ✔️ | `string` | Custom classes for the wrapper div | +| contentEditableClassName | ✔️ | `string` | Custom classes for the input element | +| placeholderClassName | ✔️ | `string` | Custom classes for the placeholder text | +| charsCounterClassName | ✔️ | `string` | Custom classes for the character counter text (if `maxLength` is specified) | +| placeholder | ✔️ | `string` | Input placeholder text | +| disabled | ✔️ | `boolean` | Flag that disables the input element | +| maxLength | ✔️ | `number` | Indicates the maximum number of characters a user can enter | +| updatedContent | ✔️ | `string` | Text injected from parent element into the input as the current value | +| onContentExternalUpdate | ✔️ | `(content) => void` | Method that emits the injected content by the `updatedContent` prop | +| onChange | ✔️ | `(content) => void` | Method that emits the current content as a string | +| onKeyUp | ✔️ | `(e) => void` | Method that emits the keyUp keyboard event | +| onKeyDown | ✔️ | `(e) => void` | Method that emits the keyDown keyboard event | +| onFocus | ✔️ | `(e) => void` | Method that emits the focus event | +| onBlur | ✔️ | `(e) => void` | Method that emits the blur event | ## Contribution diff --git a/lib/ContentEditable.tsx b/lib/ContentEditable.tsx index eb107f8..5ff80a0 100644 --- a/lib/ContentEditable.tsx +++ b/lib/ContentEditable.tsx @@ -4,9 +4,11 @@ interface ContentEditableProps { containerClassName?: string contentEditableClassName?: string placeholderClassName?: string + charsCounterClassName?: string placeholder?: string disabled?: boolean updatedContent?: string + maxLength?: number onChange?: (content: string) => void onKeyUp?: (e: React.KeyboardEvent) => void onKeyDown?: (e: React.KeyboardEvent) => void @@ -15,13 +17,24 @@ interface ContentEditableProps { onContentExternalUpdate?: (content: string) => void } +// Helper function to check if content length is within maxLength +const isContentWithinMaxLength = ( + content: string, + maxLength?: number +): boolean => { + if (!maxLength) return true + return content.length <= maxLength +} + const ContentEditable: React.FC = ({ containerClassName, contentEditableClassName, placeholderClassName, + charsCounterClassName, placeholder, disabled, updatedContent, + maxLength, onChange, onKeyUp, onKeyDown, @@ -43,9 +56,11 @@ const ContentEditable: React.FC = ({ useEffect(() => { if (divRef.current) { divRef.current.style.height = "auto" - if (onChange) onChange(content) + if (onChange && isContentWithinMaxLength(content, maxLength)) { + onChange(content) + } } - }, [content, onChange]) + }, [content, onChange, maxLength]) /** * Checks if the caret is on the last line of a contenteditable element @@ -125,30 +140,40 @@ const ContentEditable: React.FC = ({ const clipboardData = e.clipboardData || (window as any).clipboardData const plainText = clipboardData.getData("text/plain") - // Get the current selection + // Get the current selection and current content const sel: Selection | null = window.getSelection() + const currentContent = divRef.current?.innerText || "" + if (sel && sel.rangeCount) { - // Get the first range of the selection const range = sel.getRangeAt(0) - - // Delete the contents of the range (this is the selected text) - range.deleteContents() - - // Create a new text node containing the pasted text - const textNode = document.createTextNode(plainText) - - // Insert the text node into the range, which will replace the selected text - range.insertNode(textNode) - - // Move the caret to the end of the new text - range.setStartAfter(textNode) - sel.removeAllRanges() - sel.addRange(range) - - setContent(divRef.current?.innerText ?? "") + const selectedText = range.toString() + + // Calculate how much text we can insert + const availableSpace = maxLength + ? maxLength - (currentContent.length - selectedText.length) + : plainText.length + const truncatedText = plainText.slice(0, availableSpace) + + if (truncatedText.length > 0) { + range.deleteContents() + const textNode = document.createTextNode(truncatedText) + range.insertNode(textNode) + range.setStartAfter(textNode) + sel.removeAllRanges() + sel.addRange(range) + + setContent(divRef.current?.innerText ?? "") + } } else { - // If there's no selection, just insert the text at the current caret position - insertTextAtCaret(plainText) + // If there isn't a selection, check if we can insert at current position + const availableSpace = maxLength + ? maxLength - currentContent.length + : plainText.length + const truncatedText = plainText.slice(0, availableSpace) + + if (truncatedText.length > 0) { + insertTextAtCaret(truncatedText) + } } } @@ -205,6 +230,21 @@ const ContentEditable: React.FC = ({ } } + /** + * Sets the caret (text cursor) position at the end of the specified contenteditable element + * @param editableDiv - The HTMLElement representing the contenteditable div where the caret should be placed + */ + function setCaretAtTheEnd(editableDiv: HTMLElement) { + const range = document.createRange() + const sel = window.getSelection() + if (editableDiv.lastChild && sel) { + range.setStartAfter(editableDiv.lastChild) + range.collapse(true) + sel.removeAllRanges() + sel.addRange(range) + } + } + /** * Retrieves the caret position within the contentEditable element * @param editableDiv - The contentEditable element @@ -288,8 +328,18 @@ const ContentEditable: React.FC = ({ unicodeBidi: "plaintext", }} onInput={(e: React.FormEvent) => { - if (disabled) return - setContent(e.currentTarget.innerText) + const currentContent = e.currentTarget.innerText + if ( + disabled || + !isContentWithinMaxLength(currentContent, maxLength) + ) { + if (divRef.current) { + divRef.current.innerText = content + setCaretAtTheEnd(divRef.current) + } + return + } + setContent(currentContent) }} onPaste={(e) => { if (disabled) return @@ -323,6 +373,17 @@ const ContentEditable: React.FC = ({ {placeholder ?? ""} )} + {!!maxLength && ( + + {`${content.length ?? 0}/${maxLength}`} + + )} ) } diff --git a/src/App.tsx b/src/App.tsx index 0d9b363..8ad5e4e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -45,6 +45,7 @@ const App = () => { placeholderClassName="input-placeholder" updatedContent={emptyContent} onChange={(content) => setContent(content)} + maxLength={100} onFocus={() => { setIsFocused(true) setIsBlurred(false)