Good contenteditable
This commit is contained in:
parent
a6a189de1d
commit
05eb1a8fc4
|
|
@ -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<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
|
||||
export type ContentEditableElement = HTMLDivElement & { value: string };
|
||||
export type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
autoLines?: boolean;
|
||||
|
|
@ -13,117 +14,404 @@ type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
|
|||
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
|
||||
};
|
||||
|
||||
const parseLines = (html: string, keepSpan: boolean = false): string => {
|
||||
html = html
|
||||
.replace(/\n?<div><br\/?><\/div>/g, '\n\n')
|
||||
.replace(/\n?<div[^>]*>/, '\n')
|
||||
.replace(/<\/?div[^>]*>/g, '')
|
||||
.replace(/<br[^>]*>/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<Node, number>();
|
||||
const boundaries = new Map<Node, number[]>();
|
||||
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;
|
||||
text += node.nodeValue ?? '';
|
||||
return;
|
||||
}
|
||||
remaining -= len;
|
||||
} else {
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (traverse(child)) return true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!traverse(el)) {
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false);
|
||||
}
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
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<HTMLDivElement>(null);
|
||||
const root = useRef<ContentEditableElement>(null);
|
||||
const model = useRef<Snapshot>({ text: normalize(value), anchor: 0, focus: 0 });
|
||||
const before = useRef<Snapshot>();
|
||||
const past = useRef<Snapshot[]>([]);
|
||||
const future = useRef<Snapshot[]>([]);
|
||||
const grouping = useRef<{ kind: string; time: number; focus: number }>();
|
||||
const composing = useRef(false);
|
||||
const pending = useRef<string | null>(null);
|
||||
const lastProp = useRef(normalize(value));
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const settleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const notifying = useRef(false);
|
||||
const originalHeight = useRef<string | null>(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(/</g, '<'));
|
||||
} else desired.textContent = model.current.text;
|
||||
if (!model.current.text || model.current.text.endsWith('\n')) {
|
||||
const end = el.ownerDocument.createElement('br');
|
||||
end.setAttribute('data-ce-end', '');
|
||||
desired.append(end);
|
||||
}
|
||||
const changed = reconcile(el, desired);
|
||||
if (focused && changed) restoreSelection(el, { ...selection, text: model.current.text });
|
||||
sync();
|
||||
el.scrollTop = scrollTop;
|
||||
el.scrollLeft = scrollLeft;
|
||||
};
|
||||
|
||||
if (oldValue === newValue) return;
|
||||
const schedule = () => {
|
||||
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<JSX.TargetedInputEvent<HTMLDivElement>> = (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<Snapshot, 'anchor' | 'focus'>) => {
|
||||
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<Props['onInput']> = 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 (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
contentEditable={!disabled}
|
||||
data-placeholder={value?.replaceAll('\n', '').length ? undefined : placeholder}
|
||||
class={clsx(styles.root, autoLines && styles.autoLines, externalClass, disabled && styles.disabled)}
|
||||
onInput={disabled ? undefined : handleInput}
|
||||
ref={root}
|
||||
class={clsx(styles.root, autoLines && styles.autoLines, externalClass, className, disabled && styles.disabled)}
|
||||
contentEditable={disabled ? false : 'plaintext-only'}
|
||||
role={props.role ?? 'textbox'}
|
||||
aria-multiline="true"
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : props.tabIndex ?? props.tabindex ?? 0}
|
||||
onInput={handleInput}
|
||||
onBeforeInput={event => {
|
||||
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<HTMLDivElement>) => {
|
||||
composing.current = true;
|
||||
grouping.current = undefined;
|
||||
before.current = readDOM(root.current!);
|
||||
clearTimeout(timer.current);
|
||||
clearTimeout(settleTimer.current);
|
||||
onCompositionStart?.(event);
|
||||
},
|
||||
oncompositionend: (event: JSX.TargetedCompositionEvent<HTMLDivElement>) => {
|
||||
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); }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue