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/package.json b/package.json index 504ffde..dbb0cc9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-basic-contenteditable", "description": "React contenteditable component. Super-customizable!", - "version": "1.0.3", + "version": "1.0.4", "type": "module", "main": "dist/main.js", "types": "dist/main.d.ts", @@ -53,7 +53,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "typescript": "^5.7.2", - "vite": "^6.0.6", + "vite": "^6.0.9", "vite-plugin-dts": "^4.4.0" }, "bugs": { 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) diff --git a/yarn.lock b/yarn.lock index 24daaf7..3782426 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1772,8 +1772,16 @@ string-argv@~0.3.1: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -1791,7 +1799,14 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -1905,10 +1920,10 @@ vite-plugin-dts@^4.4.0: local-pkg "^0.5.1" magic-string "^0.30.17" -vite@^6.0.6: - version "6.0.6" - resolved "https://registry.yarnpkg.com/vite/-/vite-6.0.6.tgz#a851674fcff55b0c1962f72082354b8802e48505" - integrity sha512-NSjmUuckPmDU18bHz7QZ+bTYhRR0iA72cs2QAxCqDpafJ0S6qetco0LB3WW2OxlMHS0JmAv+yZ/R3uPmMyGTjQ== +vite@^6.0.9: + version "6.0.9" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.0.9.tgz#0a830b767ef7aa762360b56bdef955c1395dc1ee" + integrity sha512-MSgUxHcaXLtnBPktkbUSoQUANApKYuxZ6DrbVENlIorbhL2dZydTLaZ01tjUoE3szeFzlFk9ANOKk0xurh4MKA== dependencies: esbuild "^0.24.2" postcss "^8.4.49"