diff --git a/src/common/components/ContentEditable.tsx b/src/common/components/ContentEditable.tsx index b303d39..685e5d3 100644 --- a/src/common/components/ContentEditable.tsx +++ b/src/common/components/ContentEditable.tsx @@ -1,10 +1,11 @@ -import { useEffect, useRef } from "preact/hooks"; -import type { JSX } from "preact"; -import clsx from "clsx"; -import styles from "../assets/content-editable.module.css"; -import { highlight } from "../highlight"; +import { useLayoutEffect, useRef } from 'preact/hooks'; +import type { JSX } from 'preact'; +import clsx from 'clsx'; +import styles from '../assets/content-editable.module.css'; +import { highlight } from '../highlight'; -type Props = Omit, 'value' | 'onInput'> & { +export type ContentEditableElement = HTMLDivElement & { value: string }; +export type Props = Omit, 'value' | 'onInput'> & { value: string; placeholder?: string; autoLines?: boolean; @@ -13,117 +14,404 @@ type Props = Omit, 'value' | 'onInput'> & { onInput?: JSX.EventHandler>; }; -const parseLines = (html: string, keepSpan: boolean = false): string => { - html = html - .replace(/\n?
<\/div>/g, '\n\n') - .replace(/\n?]*>/, '\n') - .replace(/<\/?div[^>]*>/g, '') - .replace(/]*>/g, ''); +type Snapshot = { text: string; anchor: number; focus: number }; +const normalize = (text: string) => text.replace(/\r\n?/g, '\n'); +const nativeBlock = (node: Node) => node.nodeType === 1 && /^(DIV|P|LI|PRE|H[1-6])$/.test((node as Element).tagName); +const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); - if (!keepSpan) { - html = html.replace(/<[^>]+>/g, ''); - } - - return html; -}; - -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); - const contents = range.cloneContents(); - const div = document.createElement('div'); - div.appendChild(contents); - const text = parseLines(div.innerHTML); - return text.length; +function deletionRange(text: string, position: number, backward: boolean) { + const index = backward ? position - 1 : position; + if (index < 0 || index >= text.length) return; + const segment = graphemes.segment(text).containing(index)!; + return { anchor: segment.index, focus: segment.index + segment.segment.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 { +/** Read the DOM, not its HTML serialization. The index also maps both ends of + * a selection, including native BRs and paragraphs created by mobile browsers. */ +function readDOM(root: HTMLElement): Snapshot { + let text = ''; + const starts = new Map(); + const boundaries = new Map(); + const visit = (node: Node) => { + starts.set(node, text.length); if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent || ''; - const len = text.length; - 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; - } + text += node.nodeValue ?? ''; + return; } - return false; - } - - if (!traverse(el)) { - range.selectNodeContents(el); - range.collapse(false); - } - - sel.removeAllRanges(); - sel.addRange(range); + if (!(node instanceof Element)) return; + if (node.tagName === 'BR') { + if (!node.hasAttribute('data-ce-end') && !(nativeBlock(node.parentNode!) && node.parentNode!.childNodes.length === 1)) text += '\n'; + return; + } + const points: number[] = []; + const children = Array.from(node.childNodes); + children.forEach((child, index) => { + if (nativeBlock(child) && index > 0 && (nativeBlock(children[index - 1]) || !text.endsWith('\n'))) text += '\n'; + points.push(text.length); + visit(child); + }); + points.push(text.length); + boundaries.set(node, points); + }; + visit(root); + const offset = (node: Node | null, index: number) => { + if (!node || !root.contains(node)) return text.length; + if (node.nodeType === Node.TEXT_NODE) return (starts.get(node) ?? 0) + Math.min(index, node.nodeValue?.length ?? 0); + const points = boundaries.get(node); + return points?.[Math.min(index, points.length - 1)] ?? starts.get(node) ?? 0; + }; + const selection = root.ownerDocument.getSelection(); + return { + text, + anchor: offset(selection?.anchorNode ?? null, selection?.anchorOffset ?? 0), + focus: offset(selection?.focusNode ?? null, selection?.focusOffset ?? 0), + }; } -function resizeToContent(el: HTMLElement) { - el.style.height = 'auto'; - el.style.height = el.scrollHeight + 'px'; +function restoreSelection(root: HTMLElement, snapshot: Snapshot) { + const point = (offset: number): [Node, number] => { + const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node: Node | null; + let last: Node | null = null; + let remaining = Math.max(0, Math.min(offset, snapshot.text.length)); + while ((node = walker.nextNode())) { + const length = node.nodeValue?.length ?? 0; + // Prefer the next text node at a shared boundary. The end of a + // heading's block span can visually precede its trailing newline, + // whereas the following node starts on the intended source line. + if (remaining < length) return [node, remaining]; + remaining -= length; + last = node; + } + const hasEnd = root.lastChild instanceof Element && root.lastChild.hasAttribute('data-ce-end'); + if (last && !hasEnd) return [last, last.nodeValue?.length ?? 0]; + return [root, Math.max(0, root.childNodes.length - (hasEnd ? 1 : 0))]; + }; + const [anchor, anchorOffset] = point(snapshot.anchor); + const [focus, focusOffset] = point(snapshot.focus); + root.ownerDocument.getSelection()?.setBaseAndExtent(anchor, anchorOffset, focus, focusOffset); +} + +/** Reuse live nodes whenever their shape matches. Ordinary word edits leave + * their original text nodes intact, including the browser's correction anchors. */ +function reconcile(parent: Node, desired: Node): boolean { + let changed = false; + let current = parent.firstChild; + for (const next of Array.from(desired.childNodes)) { + const same = current && current.nodeType === next.nodeType && ( + current.nodeType === Node.TEXT_NODE || + (current instanceof Element && next instanceof Element && current.tagName === next.tagName && current.getAttribute('class') === next.getAttribute('class')) + ); + if (!same) { + parent.insertBefore(next.cloneNode(true), current); + changed = true; + continue; + } + if (current!.nodeType === Node.TEXT_NODE) { + if (current!.nodeValue !== next.nodeValue) { current!.nodeValue = next.nodeValue; changed = true; } + } else { + const live = current as Element; + const wanted = next as Element; + for (const attribute of Array.from(live.attributes)) { + if (!wanted.hasAttribute(attribute.name)) { live.removeAttribute(attribute.name); changed = true; } + } + for (const attribute of Array.from(wanted.attributes)) { + if (live.getAttribute(attribute.name) !== attribute.value) { live.setAttribute(attribute.name, attribute.value); changed = true; } + } + changed = reconcile(live, wanted) || changed; + } + current = current!.nextSibling; + } + while (current) { + const next = current.nextSibling; + parent.removeChild(current); + changed = true; + current = next; + } + return changed; } export const ContentEditable = ({ - value, - placeholder, - autoLines, - highlight: enableHighlight, - disabled, - onInput, - class: externalClass, + value, placeholder, autoLines = false, highlight: enableHighlight = false, + disabled = false, onInput, class: externalClass, className, + onKeyDown, onBeforeInput, onPaste, onCopy, onCut, + onCompositionStart, onCompositionEnd, onCompositionUpdate, onBlur, + onCompositionStartCapture, onCompositionEndCapture, onCompositionUpdateCapture, + children: _children, dangerouslySetInnerHTML: _html, contentEditable: _editable, ...props }: Props) => { - const ref = useRef(null); + const root = useRef(null); + const model = useRef({ text: normalize(value), anchor: 0, focus: 0 }); + const before = useRef(); + const past = useRef([]); + const future = useRef([]); + const grouping = useRef<{ kind: string; time: number; focus: number }>(); + const composing = useRef(false); + const pending = useRef(null); + const lastProp = useRef(normalize(value)); + const timer = useRef>(); + const settleTimer = useRef>(); + const notifying = useRef(false); + const originalHeight = useRef(null); + const options = useRef({ autoLines, enableHighlight, placeholder, disabled }); + options.current = { autoLines, enableHighlight, placeholder, disabled }; - useEffect(() => { - const el = ref.current; + const sync = () => { + const el = root.current; if (!el) return; + el.value = model.current.text; + if (!el.value && options.current.placeholder) el.dataset.placeholder = options.current.placeholder; + else delete el.dataset.placeholder; + if (options.current.autoLines) { + if (originalHeight.current === null) originalHeight.current = el.style.height; + el.style.height = 'auto'; + const style = getComputedStyle(el); + const adjustment = style.boxSizing === 'border-box' + ? parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth) + : -parseFloat(style.paddingTop) - parseFloat(style.paddingBottom); + el.style.height = `${el.scrollHeight + adjustment}px`; + } else if (originalHeight.current !== null) { + el.style.height = originalHeight.current; + originalHeight.current = null; + } + }; - const newValue = enableHighlight ? highlight(value) : value; - const oldValue = parseLines(el.innerHTML, true); + const decorate = () => { + clearTimeout(timer.current); + const el = root.current; + if (!el || composing.current) return; + const focused = el.ownerDocument.activeElement === el; + const selection = focused ? readDOM(el) : model.current; + const { scrollTop, scrollLeft } = el; + const desired = el.ownerDocument.createElement('div'); + if (options.current.enableHighlight) { + // Keep the shared highlighter untouched. Escape only at this call + // site; '>' and quotes remain available to its existing tokenizer. + desired.innerHTML = highlight(model.current.text.replace(/&/g, '&').replace(/ { + clearTimeout(timer.current); + if (!composing.current) timer.current = setTimeout(decorate, 120); + }; - const offset = document.activeElement === el ? getCaretOffset(el) : null; - el.innerHTML = newValue; - (el as any).value = value; - if (offset !== null) setCaretOffset(el, offset); - if (autoLines) resizeToContent(el); - }, [value, enableHighlight]); + const remember = (previous: Snapshot, next: Snapshot, kind: string) => { + if (previous.text === next.text) return; + const now = Date.now(); + const group = grouping.current; + const continuous = group && group.kind === kind && (kind === 'composition' || (now - group.time < 750 && + previous.anchor === previous.focus && previous.focus === group.focus && + (kind === 'insertText' || kind === 'deleteContentBackward' || kind === 'deleteContentForward'))); + if (!continuous) { + past.current.push(previous); + if (past.current.length > 200) past.current.shift(); + } + future.current = []; + grouping.current = { kind, time: now, focus: next.focus }; + }; - const handleInput: JSX.EventHandler> = (e) => { - if (!ref.current) return; - if (autoLines) resizeToContent(ref.current); - const text = parseLines(ref.current.innerHTML); - // add value prop to work with useInputState/useInputCallback - (ref.current as any).value = text; - onInput?.(e); + const notify = (inputType: string, data: string | null = null) => { + notifying.current = true; + try { + root.current!.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType, data })); + } finally { notifying.current = false; } + }; + + const replaceSelection = (text: string, inputType: string, range?: Pick) => { + const el = root.current!; + const previous = readDOM(el); + const selection = range ?? previous; + const start = Math.min(selection.anchor, selection.focus); + const end = Math.max(selection.anchor, selection.focus); + const inserted = normalize(text); + const position = start + inserted.length; + const next = { text: previous.text.slice(0, start) + inserted + previous.text.slice(end), anchor: position, focus: position }; + remember(previous, next, inputType); + model.current = next; + decorate(); + restoreSelection(el, next); + before.current = undefined; + notify(inputType, inserted); + }; + + const undo = (redo: boolean) => { + const source = redo ? future.current : past.current; + const destination = redo ? past.current : future.current; + const snapshot = source.pop(); + if (!snapshot) return; + destination.push(readDOM(root.current!)); + model.current = snapshot; + grouping.current = undefined; + decorate(); + restoreSelection(root.current!, snapshot); + notify(redo ? 'historyRedo' : 'historyUndo'); + }; + + const applyExternal = (text: string) => { + const selection = readDOM(root.current!); + model.current = { text, anchor: Math.min(selection.anchor, text.length), focus: Math.min(selection.focus, text.length) }; + past.current = []; + future.current = []; + grouping.current = undefined; + decorate(); + }; + + useLayoutEffect(() => { + decorate(); + let width = root.current!.clientWidth; + const observer = new ResizeObserver(() => { + const next = root.current?.clientWidth; + if (next !== width) { width = next ?? 0; sync(); } + }); + observer.observe(root.current!); + const fonts = root.current!.ownerDocument.fonts; + fonts?.addEventListener('loadingdone', sync); + return () => { + observer.disconnect(); + fonts?.removeEventListener('loadingdone', sync); + clearTimeout(timer.current); + clearTimeout(settleTimer.current); + }; + }, []); + + useLayoutEffect(() => { + const next = normalize(value); + if (lastProp.current === next) return; + lastProp.current = next; + if (next === model.current.text) { pending.current = null; return; } + if (composing.current) pending.current = next; + else applyExternal(next); + }, [value]); + + useLayoutEffect(() => { decorate(); sync(); }, [enableHighlight, autoLines, placeholder]); + + const handleInput: NonNullable = event => { + if (disabled) return; + if (!notifying.current) { + const next = readDOM(root.current!); + const previous = before.current ?? model.current; + remember(previous, next, composing.current || event.isComposing ? 'composition' : event.inputType || 'insertText'); + model.current = next; + before.current = undefined; + sync(); + schedule(); + } + onInput?.(event); }; return (
{ + onBeforeInput?.(event); + if (event.defaultPrevented || disabled || notifying.current) return; + before.current = readDOM(root.current!); + if (composing.current || event.isComposing || !event.cancelable) return; + // Browsers merge styled block spans when replacing a selection, + // sometimes removing a newline outside the selected text. Apply + // that replacement to the source offsets instead of the layout. + const hasSelection = before.current.anchor !== before.current.focus; + if (hasSelection && event.inputType === 'insertText' && event.data !== null) { + event.preventDefault(); + replaceSelection(event.data, event.inputType); + } else if (event.inputType === 'deleteContentBackward' || event.inputType === 'deleteContentForward') { + // Native block merging may remove an adjacent source newline + // as well as the intended glyph. Delete one source grapheme + // explicitly, keeping emoji and combining sequences whole. + event.preventDefault(); + const range = hasSelection ? undefined : deletionRange(before.current.text, before.current.focus, event.inputType === 'deleteContentBackward'); + if (hasSelection || range) replaceSelection('', event.inputType, range); + } else if (event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak') { + event.preventDefault(); + replaceSelection('\n', 'insertLineBreak'); + } else if (event.inputType === 'historyUndo' || event.inputType === 'historyRedo') { + event.preventDefault(); + undo(event.inputType === 'historyRedo'); + } + }} + onKeyDown={event => { + onKeyDown?.(event); + if (event.defaultPrevented || disabled || composing.current || event.isComposing || event.keyCode === 229) return; + if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.key.toLowerCase() === 'z' || event.key.toLowerCase() === 'y')) { + event.preventDefault(); + undo(event.key.toLowerCase() === 'y' || event.shiftKey); + } else if (event.key === 'Enter') { + event.preventDefault(); + replaceSelection('\n', 'insertLineBreak'); + } + }} + onPaste={event => { + onPaste?.(event); + if (event.defaultPrevented || disabled || composing.current || !event.clipboardData) return; + event.preventDefault(); + replaceSelection(event.clipboardData.getData('text/plain'), 'insertFromPaste'); + }} + onCopy={event => { + onCopy?.(event); + if (event.defaultPrevented || !event.clipboardData) return; + const selection = readDOM(root.current!); + event.clipboardData.setData('text/plain', selection.text.slice(Math.min(selection.anchor, selection.focus), Math.max(selection.anchor, selection.focus))); + event.preventDefault(); + }} + onCut={event => { + onCut?.(event); + if (event.defaultPrevented || disabled || composing.current || !event.clipboardData) return; + const selection = readDOM(root.current!); + event.clipboardData.setData('text/plain', selection.text.slice(Math.min(selection.anchor, selection.focus), Math.max(selection.anchor, selection.focus))); + event.preventDefault(); + replaceSelection('', 'deleteByCut'); + }} + {...{ + // Preact core does not lowercase composition event names on browsers + // without an oncompositionstart property. Use their native names. + oncompositionstartCapture: onCompositionStartCapture, + oncompositionendCapture: onCompositionEndCapture, + oncompositionupdateCapture: onCompositionUpdateCapture, + oncompositionupdate: onCompositionUpdate, + oncompositionstart: (event: JSX.TargetedCompositionEvent) => { + composing.current = true; + grouping.current = undefined; + before.current = readDOM(root.current!); + clearTimeout(timer.current); + clearTimeout(settleTimer.current); + onCompositionStart?.(event); + }, + oncompositionend: (event: JSX.TargetedCompositionEvent) => { + onCompositionEnd?.(event); + clearTimeout(settleTimer.current); + // Keep the DOM untouched through the final native input, which + // may arrive either before or after compositionend. + settleTimer.current = setTimeout(() => { + composing.current = false; + grouping.current = undefined; + if (pending.current !== null) { + const next = pending.current; + pending.current = null; + applyExternal(next); + } else schedule(); + }, 30); + }, + }} + onBlur={event => { if (!composing.current) decorate(); onBlur?.(event); }} /> ); };