import { useEffect, useRef } from "preact/hooks"; import type { JSX } from "preact"; import clsx from "clsx"; import styles from "../assets/content-editable.module.css"; type Props = Omit, 'value' | 'onInput'> & { value: string; placeholder?: string; autoLines?: boolean; onInput?: JSX.EventHandler>; }; function getCaretOffset(el: HTMLElement): number { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return 0; const range = sel.getRangeAt(0).cloneRange(); range.selectNodeContents(el); range.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset); return range.toString().length; } function setCaretOffset(el: HTMLElement, offset: number) { const sel = window.getSelection(); if (!sel) return; const range = document.createRange(); let remaining = offset; function traverse(node: Node): boolean { if (node.nodeType === Node.TEXT_NODE) { const len = node.textContent?.length ?? 0; if (remaining <= len) { range.setStart(node, remaining); range.collapse(true); return true; } remaining -= len; } else { for (const child of Array.from(node.childNodes)) { if (traverse(child)) return true; } } return false; } if (!traverse(el)) { range.selectNodeContents(el); range.collapse(false); } sel.removeAllRanges(); sel.addRange(range); } function resizeToContent(el: HTMLElement) { el.style.height = 'auto'; el.style.height = el.scrollHeight + 'px'; } export const ContentEditable = ({ value, placeholder, autoLines, onInput, class: externalClass, ...props }: Props) => { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el || el.innerHTML === value) return; const offset = document.activeElement === el ? getCaretOffset(el) : null; el.innerHTML = value; if (offset !== null) setCaretOffset(el, offset); if (autoLines) resizeToContent(el); }, [value]); const handleKeyDown: JSX.KeyboardEventHandler = (e) => { if (e.key !== 'Enter' || !ref.current) return; e.preventDefault(); const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); range.deleteContents(); const endsWithNewline = ref.current.textContent?.endsWith('\n'); const caretAtEnd = getCaretOffset(ref.current) === (ref.current.textContent?.length ?? 0); const newline = document.createTextNode('\n'.repeat((endsWithNewline || !caretAtEnd) ? 1 : 2)); range.insertNode(newline); range.setStartAfter(newline); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); (ref.current as any).value = ref.current.textContent; ref.current?.dispatchEvent(new InputEvent('input', { bubbles: true })); }; const handleInput: JSX.EventHandler> = (e) => { if (autoLines && ref.current) resizeToContent(ref.current); if (ref.current) { (ref.current as any).value = ref.current.textContent; } onInput?.(e); }; return (
); };