1
0
Fork 0
tsgames/src/common/components/ContentEditable.tsx

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}
/>
);
};