Good contenteditable
This commit is contained in:
parent
a6a189de1d
commit
05eb1a8fc4
|
|
@ -1,10 +1,11 @@
|
||||||
import { useEffect, useRef } from "preact/hooks";
|
import { useLayoutEffect, useRef } from 'preact/hooks';
|
||||||
import type { JSX } from "preact";
|
import type { JSX } from 'preact';
|
||||||
import clsx from "clsx";
|
import clsx from 'clsx';
|
||||||
import styles from "../assets/content-editable.module.css";
|
import styles from '../assets/content-editable.module.css';
|
||||||
import { highlight } from "../highlight";
|
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;
|
value: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
autoLines?: boolean;
|
autoLines?: boolean;
|
||||||
|
|
@ -13,117 +14,404 @@ type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
|
||||||
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
|
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseLines = (html: string, keepSpan: boolean = false): string => {
|
type Snapshot = { text: string; anchor: number; focus: number };
|
||||||
html = html
|
const normalize = (text: string) => text.replace(/\r\n?/g, '\n');
|
||||||
.replace(/\n?<div><br\/?><\/div>/g, '\n\n')
|
const nativeBlock = (node: Node) => node.nodeType === 1 && /^(DIV|P|LI|PRE|H[1-6])$/.test((node as Element).tagName);
|
||||||
.replace(/\n?<div[^>]*>/, '\n')
|
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||||
.replace(/<\/?div[^>]*>/g, '')
|
|
||||||
.replace(/<br[^>]*>/g, '');
|
|
||||||
|
|
||||||
if (!keepSpan) {
|
function deletionRange(text: string, position: number, backward: boolean) {
|
||||||
html = html.replace(/<[^>]+>/g, '');
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return html;
|
/** 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 {
|
||||||
function getCaretOffset(el: HTMLElement): number {
|
let text = '';
|
||||||
const sel = window.getSelection();
|
const starts = new Map<Node, number>();
|
||||||
if (!sel || sel.rangeCount === 0) return 0;
|
const boundaries = new Map<Node, number[]>();
|
||||||
|
const visit = (node: Node) => {
|
||||||
const range = sel.getRangeAt(0).cloneRange();
|
starts.set(node, text.length);
|
||||||
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 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) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
const text = node.textContent || '';
|
text += node.nodeValue ?? '';
|
||||||
const len = text.length;
|
return;
|
||||||
if (remaining <= len) {
|
|
||||||
range.setStart(node, remaining);
|
|
||||||
range.collapse(true);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
remaining -= len;
|
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 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 {
|
} else {
|
||||||
for (const child of Array.from(node.childNodes)) {
|
const live = current as Element;
|
||||||
if (traverse(child)) return true;
|
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; }
|
||||||
}
|
}
|
||||||
return false;
|
changed = reconcile(live, wanted) || changed;
|
||||||
}
|
}
|
||||||
|
current = current!.nextSibling;
|
||||||
if (!traverse(el)) {
|
|
||||||
range.selectNodeContents(el);
|
|
||||||
range.collapse(false);
|
|
||||||
}
|
}
|
||||||
|
while (current) {
|
||||||
sel.removeAllRanges();
|
const next = current.nextSibling;
|
||||||
sel.addRange(range);
|
parent.removeChild(current);
|
||||||
|
changed = true;
|
||||||
|
current = next;
|
||||||
}
|
}
|
||||||
|
return changed;
|
||||||
function resizeToContent(el: HTMLElement) {
|
|
||||||
el.style.height = 'auto';
|
|
||||||
el.style.height = el.scrollHeight + 'px';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContentEditable = ({
|
export const ContentEditable = ({
|
||||||
value,
|
value, placeholder, autoLines = false, highlight: enableHighlight = false,
|
||||||
placeholder,
|
disabled = false, onInput, class: externalClass, className,
|
||||||
autoLines,
|
onKeyDown, onBeforeInput, onPaste, onCopy, onCut,
|
||||||
highlight: enableHighlight,
|
onCompositionStart, onCompositionEnd, onCompositionUpdate, onBlur,
|
||||||
disabled,
|
onCompositionStartCapture, onCompositionEndCapture, onCompositionUpdateCapture,
|
||||||
onInput,
|
children: _children, dangerouslySetInnerHTML: _html, contentEditable: _editable,
|
||||||
class: externalClass,
|
|
||||||
...props
|
...props
|
||||||
}: 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 sync = () => {
|
||||||
const el = ref.current;
|
const el = root.current;
|
||||||
if (!el) return;
|
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 decorate = () => {
|
||||||
const oldValue = parseLines(el.innerHTML, true);
|
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;
|
const remember = (previous: Snapshot, next: Snapshot, kind: string) => {
|
||||||
el.innerHTML = newValue;
|
if (previous.text === next.text) return;
|
||||||
(el as any).value = value;
|
const now = Date.now();
|
||||||
if (offset !== null) setCaretOffset(el, offset);
|
const group = grouping.current;
|
||||||
if (autoLines) resizeToContent(el);
|
const continuous = group && group.kind === kind && (kind === 'composition' || (now - group.time < 750 &&
|
||||||
}, [value, enableHighlight]);
|
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) => {
|
const notify = (inputType: string, data: string | null = null) => {
|
||||||
if (!ref.current) return;
|
notifying.current = true;
|
||||||
if (autoLines) resizeToContent(ref.current);
|
try {
|
||||||
const text = parseLines(ref.current.innerHTML);
|
root.current!.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType, data }));
|
||||||
// add value prop to work with useInputState/useInputCallback
|
} finally { notifying.current = false; }
|
||||||
(ref.current as any).value = text;
|
};
|
||||||
onInput?.(e);
|
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
|
||||||
{...props}
|
{...props}
|
||||||
contentEditable={!disabled}
|
ref={root}
|
||||||
data-placeholder={value?.replaceAll('\n', '').length ? undefined : placeholder}
|
class={clsx(styles.root, autoLines && styles.autoLines, externalClass, className, disabled && styles.disabled)}
|
||||||
class={clsx(styles.root, autoLines && styles.autoLines, externalClass, disabled && styles.disabled)}
|
contentEditable={disabled ? false : 'plaintext-only'}
|
||||||
onInput={disabled ? undefined : handleInput}
|
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