diff --git a/README.md b/README.md index 305ea33..9b6169e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,76 @@ -# react-basic-contenteditable -React 18 contenteditable component. Super-customizable! +# React Basic Contenteditable + +![React Basic Content Editable](readme-header-img.png) + +A React component that allows you to create an editable area in your application. It's perfect for situations where you need to provide a user-friendly, in-place editing functionality. + +## Installation + +Install via npm + +```sh +npm install --save react-basic-contenteditable +``` + +or yarn + +```sh +yarn add react-basic-contenteditable +``` + +## Usage + + + +### Example + +```javascript +import ContentEditable from "react-basic-contenteditable" + +const App = () => { + const [content, setContent] = useState("") + + return ( +
+
{content}
+ setContent(content)} + /> +
+ ) +} + +export default App +``` + +### Props + +> All props except `onChange` are optional. + +| 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 | + +## Contribution + +If you have a suggestion that would make this component better feel free to fork the project and open a pull request or create an issue for any idea or bug you find.\ +Remeber to follow the [Contributing Guidelines](https://github.com/ChrisUser/.github/blob/main/CONTRIBUTING.md). + +## Licence + +React Basic Contenteditable is [MIT licensed](https://github.com/ChrisUser/react-basic-contenteditable/blob/master/LICENSE). diff --git a/index.html b/index.html index e4b78ea..9782ab5 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,10 @@ - + - Vite + React + TS + react-basic-contenteditable
diff --git a/lib/ContentEditable.tsx b/lib/ContentEditable.tsx new file mode 100644 index 0000000..73c6c3b --- /dev/null +++ b/lib/ContentEditable.tsx @@ -0,0 +1,330 @@ +import React, { useState, useEffect, useRef, useCallback } from "react" + +interface ContentEditableProps { + containerClassName?: string + contentEditableClassName?: string + placeholderClassName?: string + placeholder?: string + disabled?: boolean + updatedContent?: string + onChange: (content: string) => void + onKeyUp?: (e: React.KeyboardEvent) => void + onKeyDown?: (e: React.KeyboardEvent) => void + onFocus?: (e: React.FocusEvent) => void + onBlur?: (e: React.FocusEvent) => void + onContentExternalUpdate?: (content: string) => void +} + +const ContentEditable: React.FC = ({ + containerClassName, + contentEditableClassName, + placeholderClassName, + placeholder, + disabled, + updatedContent, + onChange, + onKeyUp, + onKeyDown, + onFocus, + onBlur, + onContentExternalUpdate, +}) => { + const [content, setContent] = useState("") + const divRef = useRef(null) + + useEffect(() => { + if (updatedContent !== null && updatedContent !== undefined) { + setContent(updatedContent) + if (divRef.current) divRef.current.innerText = updatedContent + if (onContentExternalUpdate) onContentExternalUpdate(updatedContent) + } + }, [updatedContent, onContentExternalUpdate]) + + useEffect(() => { + if (divRef.current) { + divRef.current.style.height = "auto" + onChange(content) + } + }, [content, onChange]) + + /** + * Checks if the caret is on the last line of a contenteditable element + * @param element - The HTMLDivElement to check + * @returns A boolean indicating whether the caret is on the last line or `false` when the caret is part of a selection + */ + const isCaretOnLastLine = useCallback((element: HTMLDivElement): boolean => { + if (element.ownerDocument.activeElement !== element) return false + + // Get the client rect of the current selection + const window = element.ownerDocument.defaultView + + if (!window) return false + + const selection = window.getSelection() + + if (!selection || selection.rangeCount === 0) return false + + const originalCaretRange = selection.getRangeAt(0) + + // Bail if there is a selection + if (originalCaretRange.toString().length > 0) return false + + const originalCaretRect = originalCaretRange.getBoundingClientRect() + + // Create a range at the end of the last text node + const endOfElementRange = document.createRange() + endOfElementRange.selectNodeContents(element) + + // The endContainer might not be an actual text node, + // try to find the last text node inside + let endContainer = endOfElementRange.endContainer + let endOffset = 0 + + while (endContainer.hasChildNodes() && !(endContainer instanceof Text)) { + if (!endContainer.lastChild) continue + + endContainer = endContainer.lastChild + endOffset = endContainer instanceof Text ? endContainer.length : 0 + } + + endOfElementRange.setEnd(endContainer, endOffset) + endOfElementRange.setStart(endContainer, endOffset) + const endOfElementRect = endOfElementRange.getBoundingClientRect() + + return originalCaretRect.bottom === endOfElementRect.bottom + }, []) + + /** + * Handles the caret scroll behavior based on keyboard events + * @param e - The keyboard event + */ + const handleCaretScroll = useCallback( + (e: KeyboardEvent) => { + if (!divRef.current) return + const focus = divRef.current + switch (e.keyCode) { + case 38: + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((focus as any).selectionStart === 0) focus.scrollTop = 0 + break + case 13: + case 40: + if (isCaretOnLastLine(focus)) focus.scrollTop = focus.scrollHeight + break + default: + break + } + }, + [isCaretOnLastLine] + ) + + function handlePasteEvent(e: React.ClipboardEvent) { + e.preventDefault() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const clipboardData = e.clipboardData || (window as any).clipboardData + const plainText = clipboardData.getData("text/plain") + + // Get the current selection + const sel: Selection | null = window.getSelection() + 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 ?? "") + } else { + // If there's no selection, just insert the text at the current caret position + insertTextAtCaret(plainText) + } + } + + /** + * Inserts the specified text at the current caret position in the contentEditable element + * @param text - The text to be inserted + */ + function insertTextAtCaret(text: string) { + if (!divRef.current) return + const currentCaretPos = getCaretPosition(divRef.current) + + divRef.current.innerText = + divRef.current.innerText.slice(0, currentCaretPos) + + text + + divRef.current.innerText.slice(currentCaretPos) + + setContent(divRef.current.innerText) + divRef.current.scrollTop = divRef.current.scrollHeight + setCaretPosition(divRef.current, currentCaretPos + text.length) + } + + // Note: setSelectionRange and createTextRange are not supported by contenteditable elements + + /** + * Sets the caret position within the contentEditable element + * If the element is empty, it will be focused + * + * @param elem - The contentEditable element + * @param pos - The position to set the caret to + */ + function setCaretPosition(elem: HTMLElement, pos: number) { + // Create a new range + const range = document.createRange() + + // Get the child node of the div + const childNode = elem.childNodes[0] + + if (childNode != null) { + // Set the range to the correct position within the text + range.setStart(childNode, pos) + range.setEnd(childNode, pos) + + // Get the selection object + const sel: Selection | null = window.getSelection() + if (!sel) return + // Remove any existing selections + sel.removeAllRanges() + + // Add the new range (this will set the cursor position) + sel.addRange(range) + } else { + // If the div is empty, focus it + elem.focus() + } + } + + /** + * Retrieves the caret position within the contentEditable element + * @param editableDiv - The contentEditable element + * @returns The caret position as a number + */ + function getCaretPosition(editableDiv: HTMLElement) { + let caretPos = 0, + range + if (window.getSelection) { + const sel: Selection | null = window.getSelection() + if (sel && sel.rangeCount) { + range = sel.getRangeAt(0) + if (range.commonAncestorContainer.parentNode === editableDiv) { + caretPos = range.endOffset + } + } + } else if (document.getSelection() && document.getSelection()?.getRangeAt) { + range = document.getSelection()?.getRangeAt(0) + if (range && range.commonAncestorContainer.parentNode === editableDiv) { + const tempEl = document.createElement("span") + editableDiv.insertBefore(tempEl, editableDiv.firstChild) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tempRange: any = range.cloneRange() + tempRange.moveToElementText(tempEl) + tempRange.setEndPoint("EndToEnd", range) + caretPos = tempRange.text.length + } + } + return caretPos + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (onKeyDown) onKeyDown(e) + if ((e.key === "Delete" || e.key === "Backspace") && isAllTextSelected()) { + e.preventDefault() + if (divRef.current) { + divRef.current.innerText = "" + setContent("") + } + } + } + + const isAllTextSelected = (): boolean => { + const sel: Selection | null = window.getSelection() + + // Matches newline characters that are either followed by another newline + // character (\n) or the end of the string ($). + const newlineCount = (divRef.current?.innerText.match(/\n(\n|$)/g) || []) + .length + return sel + ? sel.toString().length + newlineCount === + divRef.current?.innerText.length + : false + } + + useEffect(() => { + document.addEventListener("keyup", handleCaretScroll) + return () => document.removeEventListener("keyup", handleCaretScroll) + }, [handleCaretScroll]) + + return ( +
+
) => { + if (disabled) return + setContent(e.currentTarget.innerText) + }} + onPaste={(e) => { + if (disabled) return + handlePasteEvent(e) + }} + onFocus={(e) => { + if (onFocus) onFocus(e) + }} + onBlur={(e) => { + if (onBlur) onBlur(e) + }} + onKeyUp={(e) => { + if (disabled) return + if (onKeyUp) onKeyUp(e) + }} + onKeyDown={(e) => { + if (disabled) return + handleKeyDown(e) + }} + /> + {!content && ( + + {placeholder ?? ""} + + )} +
+ ) +} + +export default ContentEditable diff --git a/lib/main.ts b/lib/main.ts new file mode 100644 index 0000000..8f7df98 --- /dev/null +++ b/lib/main.ts @@ -0,0 +1,2 @@ +import ContentEditable from "./ContentEditable" +export default ContentEditable diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/readme-header-img.png b/readme-header-img.png new file mode 100644 index 0000000..81b6198 Binary files /dev/null and b/readme-header-img.png differ diff --git a/src/App.css b/src/App.css index b9d355d..728f56d 100644 --- a/src/App.css +++ b/src/App.css @@ -1,42 +1,89 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; +.full-width { + width: 100%; +} +.falsy { + color: red; +} +.truthy { + color: green; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; +.main-container { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; } -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); +.message-history-container { + display: flex; + flex-direction: column; + gap: 1rem; + margin: 2rem 0; + flex: 1; + overflow: auto; + max-height: 75vh; } -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); +.message-item { + display: flex; + align-self: flex-start; + padding: 0.85rem 1rem; + border-radius: 0.35rem; + background-color: #eff1f6; + letter-spacing: 0.05ch; + line-height: 1.2; + white-space: pre-wrap; } - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } +.message-history-container, +.input-container { + padding: 0 3rem; } -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } +.input-container { + position: relative; +} +.input-element { + border: 1px solid #ccc; + border-radius: 0.35rem; + line-height: 1.3; + min-height: 1.188rem; + max-height: 10rem; +} +.input-placeholder { + color: #a2acb4; + margin-left: 1rem; } -.card { - padding: 2em; +.metrics-section { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 2rem; + margin: 2rem -3rem 0; + font-size: 0.875rem; + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", + monospace; + border-top: 1px solid #0b57d0; + background-color: #e0e8f6; + padding: 2rem 3rem; +} +.metrics-section__left-box { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.35rem; } -.read-the-docs { - color: #888; +button { + cursor: pointer; + appearance: none; + border: none; + background-color: #2f6ed3; + color: white; + padding: 0.875rem 1rem; + border-radius: 0.35rem; + letter-spacing: 0.08ch; +} +button:hover { + background-color: #1f4aa8; } diff --git a/src/App.tsx b/src/App.tsx index afe48ac..ca141cd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,34 +1,90 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' +import ContentEditable from "../lib/ContentEditable" +import "./App.css" +import { useEffect, useState } from "react" -function App() { - const [count, setCount] = useState(0) +const App = () => { + const [emptyContent, setEmptyContent] = useState( + undefined + ) + const [content, setContent] = useState("") + const [isFocused, setIsFocused] = useState(false) + const [isBlurred, setIsBlurred] = useState(false) + const [keyDown, setKeyDown] = useState("") + const [keyUp, setKeyUp] = useState("") + const [messageHistory, setMessageHistory] = useState([ + "Type something and press on 'Send' to send another message...", + ]) + + const truncateString = (str: string, limit: number) => { + return str.length > limit ? str.slice(0, limit) + "..." : str + } + + useEffect(() => { + setEmptyContent(undefined) + }, [emptyContent]) + + const saveMessageInHistory = (message: string) => { + setMessageHistory([...messageHistory, message]) + setContent("") + setEmptyContent("") + } return ( - <> -
- - Vite logo - - - React logo - +
+
+ {messageHistory.map((message, index) => ( +
+ {message} +
+ ))}
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

+ setContent(content)} + onFocus={() => { + setIsFocused(true) + setIsBlurred(false) + }} + onBlur={() => { + setIsFocused(false) + setIsBlurred(true) + }} + onKeyDown={(e) => setKeyDown(e.key)} + onKeyUp={(e) => setKeyUp(e.key)} + /> +
+
+
+ Content: {truncateString(content, 200)} +
+
+ Is focused:{" "} + + {isFocused ? "true" : "false"} + +
+
+ Is blurred:{" "} + + {isBlurred ? "true" : "false"} + +
+
+ Key down: {keyDown} +
+
+ Key up: {keyUp} +
+
+
+ +
-

- Click on the Vite and React logos to learn more -

- +
) } diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/index.css b/src/index.css index 6119ad9..4a24969 100644 --- a/src/index.css +++ b/src/index.css @@ -1,27 +1,14 @@ :root { font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; +* { + box-sizing: border-box; } - body { margin: 0; display: flex; @@ -30,39 +17,9 @@ body { min-height: 100vh; } -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } +#root { + width: 100%; + display: flex; + justify-content: center; + align-items: center; }