mirror of
https://github.com/facebook/lexical.git
synced 2025-05-17 06:59:17 +08:00
Rework input engine to make use of DOM mutation observers (#438)
* Rework input engine to make use of DOM mutation observers * Add updateTextNodeFromDOMContent function * Avoid duplicates * Fix composition issue * Remove debugger * Refine logic
This commit is contained in:

committed by
acywatson

parent
d8487701d3
commit
b5e36c660b
@ -72,17 +72,14 @@ const editorThemeClasses = {
|
||||
};
|
||||
|
||||
function ContentEditable({
|
||||
props,
|
||||
isReadOnly,
|
||||
editorElementRef,
|
||||
}: {
|
||||
props: {...},
|
||||
isReadOnly?: boolean,
|
||||
editorElementRef: (null | HTMLElement) => void,
|
||||
}): React$Node {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className="editor"
|
||||
contentEditable={isReadOnly !== true}
|
||||
role="textbox"
|
||||
@ -105,7 +102,7 @@ export const useRichTextEditor = ({
|
||||
editorThemeClasses,
|
||||
);
|
||||
const mentionsTypeahead = useMentions(editor);
|
||||
const props = useOutlineRichText(editor, isReadOnly);
|
||||
useOutlineRichText(editor, isReadOnly);
|
||||
const toolbar = useToolbar(editor);
|
||||
const decorators = useOutlineDecorators(editor);
|
||||
useEmojis(editor);
|
||||
@ -117,7 +114,6 @@ export const useRichTextEditor = ({
|
||||
return (
|
||||
<>
|
||||
<ContentEditable
|
||||
props={props}
|
||||
isReadOnly={isReadOnly}
|
||||
editorElementRef={editorElementRef}
|
||||
/>
|
||||
@ -131,7 +127,6 @@ export const useRichTextEditor = ({
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
props,
|
||||
isReadOnly,
|
||||
editorElementRef,
|
||||
showPlaceholder,
|
||||
@ -161,7 +156,7 @@ export const usePlainTextEditor = ({
|
||||
editorThemeClasses,
|
||||
);
|
||||
const mentionsTypeahead = useMentions(editor);
|
||||
const props = usePlainText(editor, isReadOnly);
|
||||
usePlainText(editor, isReadOnly);
|
||||
const decorators = useOutlineDecorators(editor);
|
||||
useEmojis(editor);
|
||||
useHashtags(editor);
|
||||
@ -171,7 +166,6 @@ export const usePlainTextEditor = ({
|
||||
() => (
|
||||
<>
|
||||
<ContentEditable
|
||||
props={props}
|
||||
isReadOnly={isReadOnly}
|
||||
editorElementRef={editorElementRef}
|
||||
/>
|
||||
@ -183,7 +177,6 @@ export const usePlainTextEditor = ({
|
||||
</>
|
||||
),
|
||||
[
|
||||
props,
|
||||
isReadOnly,
|
||||
editorElementRef,
|
||||
showPlaceholder,
|
||||
|
@ -17,7 +17,7 @@ import type {
|
||||
View,
|
||||
} from 'outline';
|
||||
|
||||
import {CAN_USE_BEFORE_INPUT, IS_FIREFOX, IS_SAFARI} from 'shared/environment';
|
||||
import {IS_SAFARI} from 'shared/environment';
|
||||
import {
|
||||
isDeleteBackward,
|
||||
isDeleteForward,
|
||||
@ -38,7 +38,6 @@ import {
|
||||
isMoveForward,
|
||||
isMoveWordForward,
|
||||
} from 'outline/KeyHelpers';
|
||||
import getDOMTextNodeFromElement from 'shared/getDOMTextNodeFromElement';
|
||||
import isImmutableOrInertOrSegmented from 'shared/isImmutableOrInertOrSegmented';
|
||||
import {
|
||||
deleteBackward,
|
||||
@ -63,9 +62,6 @@ import {
|
||||
} from 'outline/SelectionHelpers';
|
||||
import {createTextNode, isTextNode} from 'outline';
|
||||
|
||||
// Safari triggers composition before keydown, meaning
|
||||
// we need to account for this when handling key events.
|
||||
let lastKeyWasMaybeAndroidSoftKey = false;
|
||||
const ZERO_WIDTH_SPACE_CHAR = '\u200B';
|
||||
const ZERO_WIDTH_JOINER_CHAR = '\u2060';
|
||||
|
||||
@ -79,9 +75,32 @@ export type EventHandler = (
|
||||
|
||||
export type EventHandlerState = {
|
||||
isReadOnly: boolean,
|
||||
compositionSelection: null | Selection,
|
||||
};
|
||||
|
||||
function getNodeFromDOMNode(view: View, dom: Node): OutlineNode | null {
|
||||
let node = dom;
|
||||
while (node != null) {
|
||||
// $FlowFixMe: internal field
|
||||
const key: NodeKey | undefined = node.__outlineInternalRef;
|
||||
if (key !== undefined) {
|
||||
return view.getNodeByKey(key);
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDOMFromNode(editor: OutlineEditor, node: null | OutlineNode) {
|
||||
if (node === null) {
|
||||
return null;
|
||||
}
|
||||
return editor.getElementByKey(node.getKey());
|
||||
}
|
||||
|
||||
function getLastSelection(editor: OutlineEditor): null | Selection {
|
||||
return editor.getViewModel().read((lastView) => lastView.getSelection());
|
||||
}
|
||||
|
||||
function generateNodes(
|
||||
nodeRange: {range: Array<NodeKey>, nodeMap: ParsedNodeMap},
|
||||
view: View,
|
||||
@ -144,11 +163,6 @@ function shouldOverrideBrowserDefault(
|
||||
: isHoldingShift && selectionAtBoundary;
|
||||
}
|
||||
|
||||
function updateAndroidSoftKeyFlagIfAny(event: KeyboardEvent): void {
|
||||
lastKeyWasMaybeAndroidSoftKey =
|
||||
event.key === 'Unidentified' && event.isComposing && event.keyCode === 229;
|
||||
}
|
||||
|
||||
function isTopLevelBlockRTL(selection: Selection) {
|
||||
const anchorNode = selection.getAnchorNode();
|
||||
const topLevelBlock = anchorNode.getTopParentBlockOrThrow();
|
||||
@ -161,7 +175,6 @@ export function onKeyDownForPlainText(
|
||||
editor: OutlineEditor,
|
||||
state: EventHandlerState,
|
||||
): void {
|
||||
updateAndroidSoftKeyFlagIfAny(event);
|
||||
if (editor.isComposing()) {
|
||||
return;
|
||||
}
|
||||
@ -235,7 +248,6 @@ export function onKeyDownForRichText(
|
||||
editor: OutlineEditor,
|
||||
state: EventHandlerState,
|
||||
): void {
|
||||
updateAndroidSoftKeyFlagIfAny(event);
|
||||
if (editor.isComposing()) {
|
||||
return;
|
||||
}
|
||||
@ -436,36 +448,6 @@ export function onCompositionStart(
|
||||
editor.update((view) => {
|
||||
const selection = view.getSelection();
|
||||
if (selection !== null) {
|
||||
if (!CAN_USE_BEFORE_INPUT) {
|
||||
// We only have native beforeinput composition events for
|
||||
// Safari, so we have to apply the composition selection for
|
||||
// other browsers.
|
||||
state.compositionSelection = selection;
|
||||
}
|
||||
if (!selection.isCaret()) {
|
||||
const focusKey = selection.focusKey;
|
||||
const anchorNode = selection.getAnchorNode();
|
||||
const focusNode = selection.getAnchorNode();
|
||||
// If we have a range that starts on an immutable/segmented node
|
||||
// then move it to the next node so that we insert text at the
|
||||
// right place.
|
||||
if (selection.anchorKey !== focusKey) {
|
||||
if (
|
||||
(anchorNode.isImmutable() || anchorNode.isSegmented()) &&
|
||||
anchorNode.getNextSibling() === selection.getFocusNode()
|
||||
) {
|
||||
selection.setRange(focusKey, 0, focusKey, selection.focusOffset);
|
||||
}
|
||||
}
|
||||
if (canRemoveText(anchorNode, focusNode)) {
|
||||
removeText(selection);
|
||||
}
|
||||
}
|
||||
// This works around a FF issue with text selection
|
||||
// ranges during composition.
|
||||
if (IS_FIREFOX) {
|
||||
selection.isDirty = true;
|
||||
}
|
||||
editor.setCompositionKey(selection.anchorKey);
|
||||
}
|
||||
});
|
||||
@ -477,6 +459,8 @@ export function onCompositionEnd(
|
||||
state: EventHandlerState,
|
||||
): void {
|
||||
editor.setCompositionKey(null);
|
||||
|
||||
editor.update((view) => {});
|
||||
}
|
||||
|
||||
export function onSelectionChange(
|
||||
@ -566,112 +550,74 @@ export function handleBlockTextInputOnNode(
|
||||
anchorNode.markDirtyDecorator();
|
||||
view.markNodeAsDirty(anchorNode);
|
||||
editor._compositionKey = null;
|
||||
const selection = view.getSelection();
|
||||
if (selection !== null) {
|
||||
const key = anchorNode.getKey();
|
||||
const lastSelection = getLastSelection(editor);
|
||||
const lastAnchorOffset =
|
||||
lastSelection !== null ? lastSelection.anchorOffset : null;
|
||||
if (lastAnchorOffset !== null) {
|
||||
selection.setRange(key, lastAnchorOffset, key, lastAnchorOffset);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function onNativeInput(
|
||||
function updateTextNodeFromDOMContent(
|
||||
dom: Node,
|
||||
view: View,
|
||||
editor: OutlineEditor,
|
||||
): void {
|
||||
const node = getNodeFromDOMNode(view, dom);
|
||||
if (node !== null && !node.isDirty()) {
|
||||
const rawTextContent = dom.nodeValue;
|
||||
const textContent = rawTextContent.replace(/[\u200B\u2060]/g, '');
|
||||
|
||||
if (isTextNode(node) && textContent !== node.getTextContent()) {
|
||||
if (handleBlockTextInputOnNode(node, view, editor)) {
|
||||
return;
|
||||
}
|
||||
node.setTextContent(textContent);
|
||||
const selection = view.getSelection();
|
||||
if (
|
||||
selection !== null &&
|
||||
selection.isCaret() &&
|
||||
selection.anchorKey === node.getKey()
|
||||
) {
|
||||
const domSelection = window.getSelection();
|
||||
let offset = domSelection.focusOffset;
|
||||
const firstCharacter = rawTextContent[0];
|
||||
if (
|
||||
firstCharacter === ZERO_WIDTH_SPACE_CHAR ||
|
||||
firstCharacter === ZERO_WIDTH_JOINER_CHAR
|
||||
) {
|
||||
offset--;
|
||||
}
|
||||
node.select(offset, offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function onInput(
|
||||
event: InputEvent,
|
||||
editor: OutlineEditor,
|
||||
state: EventHandlerState,
|
||||
): void {
|
||||
const inputType = event.inputType;
|
||||
const isInsertText = inputType === 'insertText';
|
||||
const isInsertCompositionText = inputType === 'insertCompositionText';
|
||||
const isDeleteCompositionText = inputType === 'deleteCompositionText';
|
||||
|
||||
if (!isInsertText && !isInsertCompositionText && !isDeleteCompositionText) {
|
||||
return;
|
||||
if (
|
||||
inputType === 'insertText' ||
|
||||
inputType === 'insertCompositionText' ||
|
||||
inputType === 'deleteCompositionText'
|
||||
) {
|
||||
editor.update((view) => {
|
||||
const domSelection = window.getSelection();
|
||||
const anchorDOM = domSelection.anchorNode;
|
||||
updateTextNodeFromDOMContent(anchorDOM, view, editor);
|
||||
});
|
||||
}
|
||||
|
||||
editor.update((view) => {
|
||||
const selection = view.getSelection();
|
||||
|
||||
if (selection === null) {
|
||||
return;
|
||||
}
|
||||
const anchorKey = selection.anchorKey;
|
||||
|
||||
// To ensure we handle Android software keyboard
|
||||
// text entry properly (which is usually composed text),
|
||||
// we need to support a few extra heuristics. Notably,
|
||||
// we need to disable composition for "insertText" or
|
||||
// "insertCompositionText" when the last key press was
|
||||
// likely to be an android soft key press. Android will
|
||||
// then enable composition again automatically.
|
||||
if (
|
||||
anchorKey === editor._compositionKey &&
|
||||
(isInsertText ||
|
||||
(isInsertCompositionText && lastKeyWasMaybeAndroidSoftKey))
|
||||
) {
|
||||
editor._compositionKey = null;
|
||||
}
|
||||
const data = event.data;
|
||||
if (data != null) {
|
||||
const focusKey = selection.focusKey;
|
||||
const anchorElement = editor.getElementByKey(anchorKey);
|
||||
const anchorNode = selection.getAnchorNode();
|
||||
const textNode = getDOMTextNodeFromElement(anchorElement);
|
||||
|
||||
// Let's try and detect a bad update here. This usually comes from text transformation
|
||||
// tools that attempt to insertText across a range of nodes – which obviously we can't
|
||||
// detect unless we rely on the DOM being the source of truth. We can try and recover
|
||||
// by dispatching an Undo event, and then capturing the previous selection and trying to
|
||||
// apply the text on that.
|
||||
if (
|
||||
anchorElement !== null &&
|
||||
checkForBadInsertion(anchorElement, anchorNode, editor)
|
||||
) {
|
||||
window.requestAnimationFrame(() => {
|
||||
document.execCommand('Undo', false, null);
|
||||
editor.update((undoneView) => {
|
||||
const undoneSelection = undoneView.getSelection();
|
||||
if (undoneSelection !== null) {
|
||||
insertText(undoneSelection, data);
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleBlockTextInputOnNode(anchorNode, view, editor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we are inserting text into the same anchor as is our focus
|
||||
// node, then we can apply a faster optimization that also handles
|
||||
// text replacement tools that use execCommand (which doesn't trigger
|
||||
// beforeinput in some browsers).
|
||||
if (
|
||||
anchorElement !== null &&
|
||||
textNode !== null &&
|
||||
isInsertText &&
|
||||
anchorKey === focusKey
|
||||
) {
|
||||
// Let's read what is in the DOM already, and use that as the value
|
||||
// for our anchor node. We get the text content from the anchor element's
|
||||
// text node.
|
||||
const rawTextContent = textNode.nodeValue;
|
||||
const textContent = rawTextContent.replace(/[\u200B\u2060]/g, '');
|
||||
let anchorOffset = window.getSelection().anchorOffset;
|
||||
// If the first character is a BOM, then we need to offset this because
|
||||
// this character isn't really apart of our offset.
|
||||
if (
|
||||
rawTextContent[0] === ZERO_WIDTH_SPACE_CHAR ||
|
||||
rawTextContent[0] === ZERO_WIDTH_JOINER_CHAR
|
||||
) {
|
||||
anchorOffset--;
|
||||
}
|
||||
|
||||
// We set the range before content, as hashtags might skew the offset
|
||||
selection.setRange(anchorKey, anchorOffset, anchorKey, anchorOffset);
|
||||
anchorNode.setTextContent(textContent);
|
||||
} else {
|
||||
insertText(selection, data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyTargetRange(selection: Selection, event: InputEvent): void {
|
||||
@ -714,7 +660,7 @@ function isBadDoubleSpacePeriodReplacment(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function onNativeBeforeInputForPlainText(
|
||||
export function onBeforeInputForPlainText(
|
||||
event: InputEvent,
|
||||
editor: OutlineEditor,
|
||||
state: EventHandlerState,
|
||||
@ -744,43 +690,22 @@ export function onNativeBeforeInputForPlainText(
|
||||
insertText(selection, ' ');
|
||||
return;
|
||||
}
|
||||
const isInputText = inputType === 'insertText';
|
||||
const anchorNode = selection.getAnchorNode();
|
||||
const focusNode = selection.getAnchorNode();
|
||||
|
||||
// We let the browser do its own thing for these composition
|
||||
// events. We handle their updates in our mutation observer.
|
||||
if (
|
||||
isInputText ||
|
||||
inputType === 'insertCompositionText' ||
|
||||
inputType === 'deleteCompositionText'
|
||||
) {
|
||||
if (
|
||||
isInputText &&
|
||||
selection.isCaret() &&
|
||||
selection.anchorOffset === anchorNode.getTextContentSize() &&
|
||||
(isImmutableOrInertOrSegmented(anchorNode) ||
|
||||
!anchorNode.canInsertTextAtEnd())
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (data != null) {
|
||||
const nextSibling = anchorNode.getNextSibling();
|
||||
if (nextSibling === null) {
|
||||
const textNode = createTextNode(data);
|
||||
textNode.select();
|
||||
anchorNode.insertAfter(textNode);
|
||||
} else if (isTextNode(nextSibling)) {
|
||||
nextSibling.select(0, 0);
|
||||
insertText(selection, data);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Gets around a Safari text replacement bug.
|
||||
if (IS_SAFARI && data === null && event.dataTransfer) {
|
||||
const text = event.dataTransfer.getData('text/plain');
|
||||
event.preventDefault();
|
||||
insertRichText(selection, text);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const anchorNode = selection.getAnchorNode();
|
||||
const focusNode = selection.getAnchorNode();
|
||||
|
||||
// Standard text insertion goes through a different path.
|
||||
// For most text insertion, we let the browser do its own thing.
|
||||
// We update the view model in our mutation observer. However,
|
||||
// we do have a few exceptions.
|
||||
if (inputType === 'insertText') {
|
||||
if (data === '\n') {
|
||||
event.preventDefault();
|
||||
insertLineBreak(selection);
|
||||
@ -788,17 +713,44 @@ export function onNativeBeforeInputForPlainText(
|
||||
event.preventDefault();
|
||||
insertLineBreak(selection);
|
||||
insertLineBreak(selection);
|
||||
} else if (!selection.isCaret()) {
|
||||
} else if (data == null && IS_SAFARI && event.dataTransfer) {
|
||||
// Gets around a Safari text replacement bug.
|
||||
const text = event.dataTransfer.getData('text/plain');
|
||||
event.preventDefault();
|
||||
insertRichText(selection, text);
|
||||
} else if (data != null) {
|
||||
const anchorKey = selection.anchorKey;
|
||||
const focusKey = selection.focusKey;
|
||||
|
||||
if (
|
||||
(IS_FIREFOX || !isInputText) &&
|
||||
canRemoveText(anchorNode, focusNode)
|
||||
) {
|
||||
removeText(selection);
|
||||
}
|
||||
if (isInputText && anchorKey !== focusKey && data) {
|
||||
if (anchorKey === focusKey) {
|
||||
const isAtEnd =
|
||||
selection.anchorOffset === anchorNode.getTextContentSize();
|
||||
const canInsertAtEnd = anchorNode.canInsertTextAtEnd();
|
||||
|
||||
// We should always block text insertion to imm/seg/inert nodes.
|
||||
// We should also do the same when at the end of nodes that do not
|
||||
// allow text insertion at the end (like links). When we do have
|
||||
// text to go at the end, we can insert into a sibling node instead.
|
||||
if (
|
||||
isImmutableOrInertOrSegmented(anchorNode) ||
|
||||
(isAtEnd && !canInsertAtEnd && selection.isCaret())
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (isAtEnd && data != null) {
|
||||
const nextSibling = anchorNode.getNextSibling();
|
||||
if (nextSibling === null) {
|
||||
const textNode = createTextNode(data);
|
||||
textNode.select();
|
||||
anchorNode.insertAfter(textNode);
|
||||
} else if (isTextNode(nextSibling)) {
|
||||
nextSibling.select(0, 0);
|
||||
insertText(selection, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For range text insertion, always over override
|
||||
// default and control outselves
|
||||
event.preventDefault();
|
||||
editor.setCompositionKey(null);
|
||||
insertText(selection, data);
|
||||
@ -879,7 +831,7 @@ export function onNativeBeforeInputForPlainText(
|
||||
});
|
||||
}
|
||||
|
||||
export function onNativeBeforeInputForRichText(
|
||||
export function onBeforeInputForRichText(
|
||||
event: InputEvent,
|
||||
editor: OutlineEditor,
|
||||
state: EventHandlerState,
|
||||
@ -909,60 +861,66 @@ export function onNativeBeforeInputForRichText(
|
||||
insertText(selection, ' ');
|
||||
return;
|
||||
}
|
||||
const isInputText = inputType === 'insertText';
|
||||
const anchorNode = selection.getAnchorNode();
|
||||
const focusNode = selection.getAnchorNode();
|
||||
|
||||
// We let the browser do its own thing for these composition
|
||||
// events. We handle their updates in our mutation observer.
|
||||
if (
|
||||
isInputText ||
|
||||
inputType === 'insertCompositionText' ||
|
||||
inputType === 'deleteCompositionText'
|
||||
) {
|
||||
if (
|
||||
isInputText &&
|
||||
selection.isCaret() &&
|
||||
selection.anchorOffset === anchorNode.getTextContentSize() &&
|
||||
(isImmutableOrInertOrSegmented(anchorNode) ||
|
||||
!anchorNode.canInsertTextAtEnd())
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (data != null) {
|
||||
const nextSibling = anchorNode.getNextSibling();
|
||||
if (nextSibling === null) {
|
||||
const textNode = createTextNode(data);
|
||||
textNode.select();
|
||||
anchorNode.insertAfter(textNode);
|
||||
} else if (isTextNode(nextSibling)) {
|
||||
nextSibling.select(0, 0);
|
||||
insertText(selection, data);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Gets around a Safari text replacement bug.
|
||||
if (IS_SAFARI && data === null && event.dataTransfer) {
|
||||
const text = event.dataTransfer.getData('text/plain');
|
||||
event.preventDefault();
|
||||
insertRichText(selection, text);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const anchorNode = selection.getAnchorNode();
|
||||
const focusNode = selection.getAnchorNode();
|
||||
|
||||
// Standard text insertion goes through a different path.
|
||||
// For most text insertion, we let the browser do its own thing.
|
||||
// We update the view model in our mutation observer. However,
|
||||
// we do have a few exceptions.
|
||||
if (inputType === 'insertText') {
|
||||
if (data === '\n') {
|
||||
event.preventDefault();
|
||||
insertLineBreak(selection);
|
||||
} else if (data === '\n\n') {
|
||||
event.preventDefault();
|
||||
insertParagraph(selection);
|
||||
} else if (!selection.isCaret()) {
|
||||
} else if (data == null && IS_SAFARI && event.dataTransfer) {
|
||||
// Gets around a Safari text replacement bug.
|
||||
const text = event.dataTransfer.getData('text/plain');
|
||||
event.preventDefault();
|
||||
insertRichText(selection, text);
|
||||
} else if (data != null) {
|
||||
const anchorKey = selection.anchorKey;
|
||||
const focusKey = selection.focusKey;
|
||||
|
||||
if (
|
||||
(IS_FIREFOX || !isInputText) &&
|
||||
canRemoveText(anchorNode, focusNode)
|
||||
) {
|
||||
removeText(selection);
|
||||
}
|
||||
if (isInputText && anchorKey !== focusKey && data) {
|
||||
if (anchorKey === focusKey) {
|
||||
const isAtEnd =
|
||||
selection.anchorOffset === anchorNode.getTextContentSize();
|
||||
const canInsertAtEnd = anchorNode.canInsertTextAtEnd();
|
||||
|
||||
// We should always block text insertion to imm/seg/inert nodes.
|
||||
// We should also do the same when at the end of nodes that do not
|
||||
// allow text insertion at the end (like links). When we do have
|
||||
// text to go at the end, we can insert into a sibling node instead.
|
||||
if (
|
||||
isImmutableOrInertOrSegmented(anchorNode) ||
|
||||
(isAtEnd && !canInsertAtEnd && selection.isCaret())
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (isAtEnd && data != null) {
|
||||
const nextSibling = anchorNode.getNextSibling();
|
||||
if (nextSibling === null) {
|
||||
const textNode = createTextNode(data);
|
||||
textNode.select();
|
||||
anchorNode.insertAfter(textNode);
|
||||
} else if (isTextNode(nextSibling)) {
|
||||
nextSibling.select(0, 0);
|
||||
insertText(selection, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For range text insertion, always over override
|
||||
// default and control outselves
|
||||
event.preventDefault();
|
||||
editor.setCompositionKey(null);
|
||||
insertText(selection, data);
|
||||
@ -1047,30 +1005,72 @@ export function onNativeBeforeInputForRichText(
|
||||
});
|
||||
}
|
||||
|
||||
export function onPolyfilledBeforeInput(
|
||||
event: SyntheticInputEvent<EventTarget>,
|
||||
export function onMutation(
|
||||
editor: OutlineEditor,
|
||||
state: EventHandlerState,
|
||||
mutations: Array<MutationRecord>,
|
||||
): void {
|
||||
event.preventDefault();
|
||||
editor.update((view) => {
|
||||
editor.update((view: View) => {
|
||||
const selection = view.getSelection();
|
||||
const data = event.data;
|
||||
if (data != null && selection !== null) {
|
||||
const compositionSelection = state.compositionSelection;
|
||||
state.compositionSelection = null;
|
||||
if (compositionSelection !== null) {
|
||||
selection.setRange(
|
||||
compositionSelection.anchorKey,
|
||||
compositionSelection.anchorOffset,
|
||||
compositionSelection.focusKey,
|
||||
compositionSelection.focusOffset,
|
||||
);
|
||||
|
||||
for (let i = 0; i < mutations.length; i++) {
|
||||
const mutation = mutations[i];
|
||||
const type = mutation.type;
|
||||
|
||||
if (type === 'characterData') {
|
||||
updateTextNodeFromDOMContent(mutation.target, view, editor);
|
||||
} else if (type === 'childList') {
|
||||
// This occurs when the DOM tree has been mutated in terms of
|
||||
// structure. This is actually not good. Outline should control
|
||||
// the contenteditable. This can typically happen because of
|
||||
// third party extensions and tools that directly mutate the DOM.
|
||||
// Given this code-path shouldn't happen often so we can use
|
||||
// slightly slower code but code that takes up less bytes.
|
||||
const {addedNodes, removedNodes} = mutation;
|
||||
|
||||
addedNodes.forEach((addedDOM) => {
|
||||
const addedNode = getNodeFromDOMNode(view, addedDOM);
|
||||
// For now we don't want nodes that weren't added by Outline.
|
||||
// So lets remove this node if it's not managed by Outline
|
||||
if (addedNode === null) {
|
||||
const parent = addedDOM.parentNode;
|
||||
console.log('remove', addedDOM);
|
||||
if (parent != null) {
|
||||
parent.removeChild(addedDOM);
|
||||
}
|
||||
} else {
|
||||
console.log('remove', null);
|
||||
}
|
||||
});
|
||||
removedNodes.forEach((removedDOM) => {
|
||||
// If a node was removed that we control, we should re-attach it!
|
||||
const removedNode = getNodeFromDOMNode(view, removedDOM);
|
||||
if (removedNode !== null) {
|
||||
const parentDOM = getDOMFromNode(editor, removedNode.getParent());
|
||||
// We should be re-adding this back to the DOM
|
||||
if (parentDOM !== null) {
|
||||
// See if we have a sibling to insert before
|
||||
const siblingDOM = getDOMFromNode(
|
||||
editor,
|
||||
removedNode.getNextSibling(),
|
||||
);
|
||||
console.log('add', removedNode);
|
||||
if (siblingDOM === null) {
|
||||
parentDOM.appendChild(removedDOM);
|
||||
} else {
|
||||
parentDOM.insertBefore(removedDOM, siblingDOM);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (selection === null) {
|
||||
// Looks like a text node was added and selection was moved to it.
|
||||
// We can attempt to restore the last selection.
|
||||
const lastSelection = getLastSelection(editor);
|
||||
if (lastSelection !== null) {
|
||||
view.setSelection(lastSelection);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (handleBlockTextInputOnNode(selection.getAnchorNode(), view, editor)) {
|
||||
return;
|
||||
}
|
||||
insertText(selection, data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -23,12 +23,12 @@ import {
|
||||
onCompositionEnd,
|
||||
onCut,
|
||||
onCopy,
|
||||
onNativeBeforeInputForPlainText,
|
||||
onBeforeInputForPlainText,
|
||||
onPasteForPlainText,
|
||||
onDropPolyfill,
|
||||
onDragStartPolyfill,
|
||||
onPolyfilledBeforeInput,
|
||||
onNativeInput,
|
||||
onInput,
|
||||
onMutation,
|
||||
} from './shared/EventHandlers';
|
||||
import useOutlineDragonSupport from './shared/useOutlineDragonSupport';
|
||||
import useOutlineHistory from './shared/useOutlineHistory';
|
||||
@ -44,8 +44,6 @@ function initEditor(editor: OutlineEditor): void {
|
||||
});
|
||||
}
|
||||
|
||||
const emptyObject: {} = {};
|
||||
|
||||
const events: InputEvents = [
|
||||
['selectionchange', onSelectionChange],
|
||||
['keydown', onKeyDownForPlainText],
|
||||
@ -55,25 +53,21 @@ const events: InputEvents = [
|
||||
['copy', onCopy],
|
||||
['dragstart', onDragStartPolyfill],
|
||||
['paste', onPasteForPlainText],
|
||||
['beforeinput', onBeforeInputForPlainText],
|
||||
['input', onInput],
|
||||
];
|
||||
|
||||
if (CAN_USE_BEFORE_INPUT) {
|
||||
events.push(
|
||||
['beforeinput', onNativeBeforeInputForPlainText],
|
||||
['input', onNativeInput],
|
||||
);
|
||||
} else {
|
||||
if (!CAN_USE_BEFORE_INPUT) {
|
||||
events.push(['drop', onDropPolyfill]);
|
||||
}
|
||||
|
||||
export default function useOutlinePlainText(
|
||||
editor: OutlineEditor,
|
||||
isReadOnly?: boolean = false,
|
||||
): {} | {onBeforeInput: (SyntheticInputEvent<EventTarget>) => void} {
|
||||
): void {
|
||||
const eventHandlerState: EventHandlerState = useMemo(
|
||||
() => ({
|
||||
isReadOnly: false,
|
||||
compositionSelection: null,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@ -83,23 +77,34 @@ export default function useOutlinePlainText(
|
||||
}, [isReadOnly, eventHandlerState]);
|
||||
|
||||
useEffect(() => {
|
||||
return editor.addEditorElementListener((editorElement) => {
|
||||
if (editorElement !== null) {
|
||||
initEditor(editor);
|
||||
editor.registerNodeType('paragraph', ParagraphNode);
|
||||
}
|
||||
const removeElementListner = editor.addEditorElementListener(
|
||||
(editorElement) => {
|
||||
if (editorElement !== null) {
|
||||
initEditor(editor);
|
||||
editor.registerNodeType('paragraph', ParagraphNode);
|
||||
}
|
||||
},
|
||||
);
|
||||
const observer = new MutationObserver(onMutation.bind(null, editor));
|
||||
const removeMutationListener = editor.addMutationListener(() => {
|
||||
observer.disconnect();
|
||||
|
||||
return (editorElement: HTMLElement) => {
|
||||
observer.observe(editorElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeMutationListener();
|
||||
removeElementListner();
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
useOutlineEditorEvents(events, editor, eventHandlerState);
|
||||
useOutlineDragonSupport(editor);
|
||||
useOutlineHistory(editor);
|
||||
|
||||
return CAN_USE_BEFORE_INPUT
|
||||
? emptyObject
|
||||
: {
|
||||
onBeforeInput: (event: SyntheticInputEvent<EventTarget>) => {
|
||||
onPolyfilledBeforeInput(event, editor, eventHandlerState);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -29,12 +29,12 @@ import {
|
||||
onCompositionEnd,
|
||||
onCut,
|
||||
onCopy,
|
||||
onNativeBeforeInputForRichText,
|
||||
onBeforeInputForRichText,
|
||||
onPasteForRichText,
|
||||
onDropPolyfill,
|
||||
onDragStartPolyfill,
|
||||
onPolyfilledBeforeInput,
|
||||
onNativeInput,
|
||||
onInput,
|
||||
onMutation,
|
||||
} from './shared/EventHandlers';
|
||||
import useOutlineDragonSupport from './shared/useOutlineDragonSupport';
|
||||
import useOutlineHistory from './shared/useOutlineHistory';
|
||||
@ -50,8 +50,6 @@ function initEditor(editor: OutlineEditor): void {
|
||||
});
|
||||
}
|
||||
|
||||
const emptyObject: {} = {};
|
||||
|
||||
const events: InputEvents = [
|
||||
['selectionchange', onSelectionChange],
|
||||
['keydown', onKeyDownForRichText],
|
||||
@ -61,26 +59,22 @@ const events: InputEvents = [
|
||||
['copy', onCopy],
|
||||
['dragstart', onDragStartPolyfill],
|
||||
['paste', onPasteForRichText],
|
||||
['input', onInput],
|
||||
['beforeinput', onBeforeInputForRichText],
|
||||
];
|
||||
|
||||
if (CAN_USE_BEFORE_INPUT) {
|
||||
events.push(
|
||||
['beforeinput', onNativeBeforeInputForRichText],
|
||||
['input', onNativeInput],
|
||||
);
|
||||
} else {
|
||||
if (!CAN_USE_BEFORE_INPUT) {
|
||||
events.push(['drop', onDropPolyfill]);
|
||||
}
|
||||
|
||||
export default function useOutlineRichText(
|
||||
editor: OutlineEditor,
|
||||
isReadOnly?: boolean = false,
|
||||
): {} | {onBeforeInput: (SyntheticInputEvent<EventTarget>) => void} {
|
||||
): void {
|
||||
const eventHandlerState: EventHandlerState = useMemo(
|
||||
() => ({
|
||||
isReadOnly: false,
|
||||
richText: true,
|
||||
compositionSelection: null,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@ -90,28 +84,39 @@ export default function useOutlineRichText(
|
||||
}, [isReadOnly, eventHandlerState]);
|
||||
|
||||
useEffect(() => {
|
||||
return editor.addEditorElementListener((editorElement) => {
|
||||
if (editorElement !== null) {
|
||||
editor.registerNodeType('heading', HeadingNode);
|
||||
editor.registerNodeType('list', ListNode);
|
||||
editor.registerNodeType('quote', QuoteNode);
|
||||
editor.registerNodeType('code', CodeNode);
|
||||
editor.registerNodeType('paragraph', ParagraphNode);
|
||||
editor.registerNodeType('listitem', ListItemNode);
|
||||
initEditor(editor);
|
||||
}
|
||||
const removeElementListner = editor.addEditorElementListener(
|
||||
(editorElement) => {
|
||||
if (editorElement !== null) {
|
||||
editor.registerNodeType('heading', HeadingNode);
|
||||
editor.registerNodeType('list', ListNode);
|
||||
editor.registerNodeType('quote', QuoteNode);
|
||||
editor.registerNodeType('code', CodeNode);
|
||||
editor.registerNodeType('paragraph', ParagraphNode);
|
||||
editor.registerNodeType('listitem', ListItemNode);
|
||||
initEditor(editor);
|
||||
}
|
||||
},
|
||||
);
|
||||
const observer = new MutationObserver(onMutation.bind(null, editor));
|
||||
const removeMutationListener = editor.addMutationListener(() => {
|
||||
observer.disconnect();
|
||||
|
||||
return (editorElement: HTMLElement) => {
|
||||
observer.observe(editorElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeMutationListener();
|
||||
removeElementListner();
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
useOutlineEditorEvents(events, editor, eventHandlerState);
|
||||
useOutlineDragonSupport(editor);
|
||||
useOutlineHistory(editor);
|
||||
|
||||
return CAN_USE_BEFORE_INPUT
|
||||
? emptyObject
|
||||
: {
|
||||
onBeforeInput: (event: SyntheticInputEvent<EventTarget>) => {
|
||||
onPolyfilledBeforeInput(event, editor, eventHandlerState);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ import {
|
||||
errorOnProcessingTextNodeTransforms,
|
||||
applySelectionTransforms,
|
||||
triggerUpdateListeners,
|
||||
triggerEndMutationListeners,
|
||||
} from './OutlineView';
|
||||
import {createSelection} from './OutlineSelection';
|
||||
import {
|
||||
@ -80,6 +81,8 @@ export type TextNodeTransform = (node: TextNode, view: View) => void;
|
||||
|
||||
export type EditorElementListener = (element: null | HTMLElement) => void;
|
||||
|
||||
export type MutationListener = () => (editorElement: HTMLElement) => void;
|
||||
|
||||
export function resetEditor(editor: OutlineEditor): void {
|
||||
const root = createRoot();
|
||||
const emptyViewModel = new ViewModel({root});
|
||||
@ -104,6 +107,7 @@ export function resetEditor(editor: OutlineEditor): void {
|
||||
keyToDOMMap.clear();
|
||||
editor._textContent = '';
|
||||
triggerUpdateListeners(editor);
|
||||
triggerEndMutationListeners(editor);
|
||||
}
|
||||
|
||||
export function createEditor(
|
||||
@ -220,6 +224,7 @@ export class OutlineEditor {
|
||||
_errorListeners: Set<ErrorListener>;
|
||||
_updateListeners: Set<UpdateListener>;
|
||||
_elementListeners: Set<EditorElementListener>;
|
||||
_mutationListeners: Set<MutationListener>;
|
||||
_decoratorListeners: Set<DecoratorListener>;
|
||||
_textNodeTransforms: Set<TextNodeTransform>;
|
||||
_nodeTypes: Map<string, Class<OutlineNode>>;
|
||||
@ -248,6 +253,8 @@ export class OutlineEditor {
|
||||
this._decoratorListeners = new Set();
|
||||
// Editor element listeners
|
||||
this._elementListeners = new Set();
|
||||
// Mutation listeners
|
||||
this._mutationListeners = new Set();
|
||||
// Class name mappings for nodes/placeholders
|
||||
this._editorThemeClasses = editorThemeClasses;
|
||||
// Handling of text node transforms
|
||||
@ -271,13 +278,13 @@ export class OutlineEditor {
|
||||
setCompositionKey(nodeKey: null | NodeKey): void {
|
||||
if (nodeKey === null) {
|
||||
this._compositionKey = null;
|
||||
updateEditor(this, emptyFunction, true);
|
||||
updateEditor(this, emptyFunction, false);
|
||||
const pendingViewModel = this._pendingViewModel;
|
||||
if (pendingViewModel !== null) {
|
||||
pendingViewModel.markDirty();
|
||||
}
|
||||
} else {
|
||||
updateEditor(this, emptyFunction, true);
|
||||
updateEditor(this, emptyFunction, false);
|
||||
}
|
||||
this._deferred.push(() => {
|
||||
this._compositionKey = nodeKey;
|
||||
@ -298,6 +305,12 @@ export class OutlineEditor {
|
||||
this._errorListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
addMutationListener(listener: MutationListener): () => void {
|
||||
this._mutationListeners.add(listener);
|
||||
return () => {
|
||||
this._mutationListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
addEditorElementListener(listener: EditorElementListener): () => void {
|
||||
this._elementListeners.add(listener);
|
||||
listener(this._editorElement);
|
||||
|
@ -464,6 +464,10 @@ export class OutlineNode {
|
||||
isDirectionless(): boolean {
|
||||
return (this.getLatest().__flags & IS_DIRECTIONLESS) !== 0;
|
||||
}
|
||||
isDirty(): boolean {
|
||||
const viewModel = getActiveViewModel();
|
||||
return viewModel._dirtyNodes.has(this.__key);
|
||||
}
|
||||
getLatest<N: OutlineNode>(): N {
|
||||
const latest = getNodeByKey<N>(this.__key);
|
||||
if (latest === null) {
|
||||
|
@ -8,7 +8,11 @@
|
||||
*/
|
||||
|
||||
import type {NodeKey, NodeMapType} from './OutlineNode';
|
||||
import type {ViewModel} from './OutlineView';
|
||||
import {
|
||||
triggerEndMutationListeners,
|
||||
triggerStartMutationListeners,
|
||||
ViewModel,
|
||||
} from './OutlineView';
|
||||
import type {OutlineEditor, EditorThemeClasses} from './OutlineEditor';
|
||||
import type {Selection} from './OutlineSelection';
|
||||
import type {Node as ReactNode} from 'react';
|
||||
@ -491,6 +495,7 @@ function reconcileRoot(
|
||||
}
|
||||
|
||||
export function reconcileViewModel(
|
||||
editorElement: HTMLElement,
|
||||
prevViewModel: ViewModel,
|
||||
nextViewModel: ViewModel,
|
||||
editor: OutlineEditor,
|
||||
@ -505,13 +510,18 @@ export function reconcileViewModel(
|
||||
|
||||
if (needsUpdate) {
|
||||
const {anchorOffset, focusOffset} = window.getSelection();
|
||||
reconcileRoot(
|
||||
prevViewModel,
|
||||
nextViewModel,
|
||||
editor,
|
||||
dirtySubTrees,
|
||||
dirtyNodes,
|
||||
);
|
||||
const startMutationListeners = triggerEndMutationListeners(editor);
|
||||
try {
|
||||
reconcileRoot(
|
||||
prevViewModel,
|
||||
nextViewModel,
|
||||
editor,
|
||||
dirtySubTrees,
|
||||
dirtyNodes,
|
||||
);
|
||||
} finally {
|
||||
triggerStartMutationListeners(editorElement, startMutationListeners);
|
||||
}
|
||||
const selectionAfter = window.getSelection();
|
||||
if (
|
||||
anchorOffset !== selectionAfter.anchorOffset ||
|
||||
|
@ -356,6 +356,7 @@ export function createSelection(
|
||||
const useDOMSelection =
|
||||
isSelectionChange ||
|
||||
eventType === 'beforeinput' ||
|
||||
eventType === 'input' ||
|
||||
eventType === 'compositionstart';
|
||||
let anchorDOM, focusDOM, anchorOffset, focusOffset;
|
||||
|
||||
|
@ -303,7 +303,8 @@ export function garbageCollectDetachedNodes(
|
||||
|
||||
export function commitPendingUpdates(editor: OutlineEditor): void {
|
||||
const pendingViewModel = editor._pendingViewModel;
|
||||
if (editor._editorElement === null || pendingViewModel === null) {
|
||||
const editorElement = editor._editorElement;
|
||||
if (editorElement === null || pendingViewModel === null) {
|
||||
return;
|
||||
}
|
||||
const currentViewModel = editor._viewModel;
|
||||
@ -312,13 +313,17 @@ export function commitPendingUpdates(editor: OutlineEditor): void {
|
||||
const previousActiveViewModel = activeViewModel;
|
||||
activeViewModel = pendingViewModel;
|
||||
try {
|
||||
reconcileViewModel(currentViewModel, pendingViewModel, editor);
|
||||
reconcileViewModel(
|
||||
editorElement,
|
||||
currentViewModel,
|
||||
pendingViewModel,
|
||||
editor,
|
||||
);
|
||||
} catch (error) {
|
||||
// Report errors
|
||||
triggerErrorListeners(editor, error);
|
||||
// Reset editor and restore incoming view model to the DOM
|
||||
const editorElement = editor._editorElement;
|
||||
if (editorElement !== null && !isAttemptingToRecoverFromReconcilerError) {
|
||||
if (!isAttemptingToRecoverFromReconcilerError) {
|
||||
resetEditor(editor);
|
||||
editor._keyToDOMMap.set('root', editorElement);
|
||||
editor._pendingViewModel = pendingViewModel;
|
||||
@ -357,6 +362,26 @@ export function triggerDecoratorListeners(
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerEndMutationListeners(
|
||||
editor: OutlineEditor,
|
||||
): Array<(HTMLElement) => void> {
|
||||
const endMutationListeners = Array.from(editor._mutationListeners);
|
||||
const startMutationListeners = [];
|
||||
for (let i = 0; i < endMutationListeners.length; i++) {
|
||||
startMutationListeners.push(endMutationListeners[i]());
|
||||
}
|
||||
return startMutationListeners;
|
||||
}
|
||||
|
||||
export function triggerStartMutationListeners(
|
||||
editorElement: HTMLElement,
|
||||
startMutationListeners: Array<(HTMLElement) => void>,
|
||||
): void {
|
||||
for (let i = 0; i < startMutationListeners.length; i++) {
|
||||
startMutationListeners[i](editorElement);
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerUpdateListeners(editor: OutlineEditor): void {
|
||||
const viewModel = editor._viewModel;
|
||||
const listeners = Array.from(editor._updateListeners);
|
||||
|
Reference in New Issue
Block a user