117 lines
3.7 KiB
TypeScript
117 lines
3.7 KiB
TypeScript
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<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
|
|
value: string;
|
|
placeholder?: string;
|
|
autoLines?: boolean;
|
|
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
|
|
};
|
|
|
|
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<HTMLDivElement>(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<HTMLDivElement> = (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<JSX.TargetedInputEvent<HTMLDivElement>> = (e) => {
|
|
if (autoLines && ref.current) resizeToContent(ref.current);
|
|
if (ref.current) {
|
|
(ref.current as any).value = ref.current.textContent;
|
|
}
|
|
onInput?.(e);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
contentEditable
|
|
onKeyDown={handleKeyDown}
|
|
onInput={handleInput}
|
|
data-placeholder={value.replaceAll('\n', '').length ? undefined : placeholder}
|
|
class={clsx(styles.root, autoLines && styles.autoLines, externalClass)}
|
|
{...props}
|
|
/>
|
|
);
|
|
};
|