Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 16 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
109 changes: 85 additions & 24 deletions lib/ContentEditable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ContentEditableProps> = ({
containerClassName,
contentEditableClassName,
placeholderClassName,
charsCounterClassName,
placeholder,
disabled,
updatedContent,
maxLength,
onChange,
onKeyUp,
onKeyDown,
Expand All @@ -43,9 +56,11 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
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
Expand Down Expand Up @@ -125,30 +140,40 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
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)
}
}
}

Expand Down Expand Up @@ -205,6 +230,21 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
}
}

/**
* 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
Expand Down Expand Up @@ -288,8 +328,18 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
unicodeBidi: "plaintext",
}}
onInput={(e: React.FormEvent<HTMLDivElement>) => {
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
Expand Down Expand Up @@ -323,6 +373,17 @@ const ContentEditable: React.FC<ContentEditableProps> = ({
{placeholder ?? ""}
</span>
)}
{!!maxLength && (
<span
dir="auto"
className={charsCounterClassName}
style={{
marginLeft: "1rem",
}}
>
{`${content.length ?? 0}/${maxLength}`}
</span>
)}
</div>
)
}
Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const App = () => {
placeholderClassName="input-placeholder"
updatedContent={emptyContent}
onChange={(content) => setContent(content)}
maxLength={100}
onFocus={() => {
setIsFocused(true)
setIsBlurred(false)
Expand Down