mirror of
https://github.com/facebook/lexical.git
synced 2025-05-17 15:18:47 +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({
|
function ContentEditable({
|
||||||
props,
|
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
editorElementRef,
|
editorElementRef,
|
||||||
}: {
|
}: {
|
||||||
props: {...},
|
|
||||||
isReadOnly?: boolean,
|
isReadOnly?: boolean,
|
||||||
editorElementRef: (null | HTMLElement) => void,
|
editorElementRef: (null | HTMLElement) => void,
|
||||||
}): React$Node {
|
}): React$Node {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
|
||||||
className="editor"
|
className="editor"
|
||||||
contentEditable={isReadOnly !== true}
|
contentEditable={isReadOnly !== true}
|
||||||
role="textbox"
|
role="textbox"
|
||||||
@ -105,7 +102,7 @@ export const useRichTextEditor = ({
|
|||||||
editorThemeClasses,
|
editorThemeClasses,
|
||||||
);
|
);
|
||||||
const mentionsTypeahead = useMentions(editor);
|
const mentionsTypeahead = useMentions(editor);
|
||||||
const props = useOutlineRichText(editor, isReadOnly);
|
useOutlineRichText(editor, isReadOnly);
|
||||||
const toolbar = useToolbar(editor);
|
const toolbar = useToolbar(editor);
|
||||||
const decorators = useOutlineDecorators(editor);
|
const decorators = useOutlineDecorators(editor);
|
||||||
useEmojis(editor);
|
useEmojis(editor);
|
||||||
@ -117,7 +114,6 @@ export const useRichTextEditor = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
props={props}
|
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
editorElementRef={editorElementRef}
|
editorElementRef={editorElementRef}
|
||||||
/>
|
/>
|
||||||
@ -131,7 +127,6 @@ export const useRichTextEditor = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
props,
|
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
editorElementRef,
|
editorElementRef,
|
||||||
showPlaceholder,
|
showPlaceholder,
|
||||||
@ -161,7 +156,7 @@ export const usePlainTextEditor = ({
|
|||||||
editorThemeClasses,
|
editorThemeClasses,
|
||||||
);
|
);
|
||||||
const mentionsTypeahead = useMentions(editor);
|
const mentionsTypeahead = useMentions(editor);
|
||||||
const props = usePlainText(editor, isReadOnly);
|
usePlainText(editor, isReadOnly);
|
||||||
const decorators = useOutlineDecorators(editor);
|
const decorators = useOutlineDecorators(editor);
|
||||||
useEmojis(editor);
|
useEmojis(editor);
|
||||||
useHashtags(editor);
|
useHashtags(editor);
|
||||||
@ -171,7 +166,6 @@ export const usePlainTextEditor = ({
|
|||||||
() => (
|
() => (
|
||||||
<>
|
<>
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
props={props}
|
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
editorElementRef={editorElementRef}
|
editorElementRef={editorElementRef}
|
||||||
/>
|
/>
|
||||||
@ -183,7 +177,6 @@ export const usePlainTextEditor = ({
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
props,
|
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
editorElementRef,
|
editorElementRef,
|
||||||
showPlaceholder,
|
showPlaceholder,
|
||||||
|
@ -17,7 +17,7 @@ import type {
|
|||||||
View,
|
View,
|
||||||
} from 'outline';
|
} from 'outline';
|
||||||
|
|
||||||
import {CAN_USE_BEFORE_INPUT, IS_FIREFOX, IS_SAFARI} from 'shared/environment';
|
import {IS_SAFARI} from 'shared/environment';
|
||||||
import {
|
import {
|
||||||
isDeleteBackward,
|
isDeleteBackward,
|
||||||
isDeleteForward,
|
isDeleteForward,
|
||||||
@ -38,7 +38,6 @@ import {
|
|||||||
isMoveForward,
|
isMoveForward,
|
||||||
isMoveWordForward,
|
isMoveWordForward,
|
||||||
} from 'outline/KeyHelpers';
|
} from 'outline/KeyHelpers';
|
||||||
import getDOMTextNodeFromElement from 'shared/getDOMTextNodeFromElement';
|
|
||||||
import isImmutableOrInertOrSegmented from 'shared/isImmutableOrInertOrSegmented';
|
import isImmutableOrInertOrSegmented from 'shared/isImmutableOrInertOrSegmented';
|
||||||
import {
|
import {
|
||||||
deleteBackward,
|
deleteBackward,
|
||||||
@ -63,9 +62,6 @@ import {
|
|||||||
} from 'outline/SelectionHelpers';
|
} from 'outline/SelectionHelpers';
|
||||||
import {createTextNode, isTextNode} from 'outline';
|
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_SPACE_CHAR = '\u200B';
|
||||||
const ZERO_WIDTH_JOINER_CHAR = '\u2060';
|
const ZERO_WIDTH_JOINER_CHAR = '\u2060';
|
||||||
|
|
||||||
@ -79,9 +75,32 @@ export type EventHandler = (
|
|||||||
|
|
||||||
export type EventHandlerState = {
|
export type EventHandlerState = {
|
||||||
isReadOnly: boolean,
|
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(
|
function generateNodes(
|
||||||
nodeRange: {range: Array<NodeKey>, nodeMap: ParsedNodeMap},
|
nodeRange: {range: Array<NodeKey>, nodeMap: ParsedNodeMap},
|
||||||
view: View,
|
view: View,
|
||||||
@ -144,11 +163,6 @@ function shouldOverrideBrowserDefault(
|
|||||||
: isHoldingShift && selectionAtBoundary;
|
: isHoldingShift && selectionAtBoundary;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAndroidSoftKeyFlagIfAny(event: KeyboardEvent): void {
|
|
||||||
lastKeyWasMaybeAndroidSoftKey =
|
|
||||||
event.key === 'Unidentified' && event.isComposing && event.keyCode === 229;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTopLevelBlockRTL(selection: Selection) {
|
function isTopLevelBlockRTL(selection: Selection) {
|
||||||
const anchorNode = selection.getAnchorNode();
|
const anchorNode = selection.getAnchorNode();
|
||||||
const topLevelBlock = anchorNode.getTopParentBlockOrThrow();
|
const topLevelBlock = anchorNode.getTopParentBlockOrThrow();
|
||||||
@ -161,7 +175,6 @@ export function onKeyDownForPlainText(
|
|||||||
editor: OutlineEditor,
|
editor: OutlineEditor,
|
||||||
state: EventHandlerState,
|
state: EventHandlerState,
|
||||||
): void {
|
): void {
|
||||||
updateAndroidSoftKeyFlagIfAny(event);
|
|
||||||
if (editor.isComposing()) {
|
if (editor.isComposing()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -235,7 +248,6 @@ export function onKeyDownForRichText(
|
|||||||
editor: OutlineEditor,
|
editor: OutlineEditor,
|
||||||
state: EventHandlerState,
|
state: EventHandlerState,
|
||||||
): void {
|
): void {
|
||||||
updateAndroidSoftKeyFlagIfAny(event);
|
|
||||||
if (editor.isComposing()) {
|
if (editor.isComposing()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -436,36 +448,6 @@ export function onCompositionStart(
|
|||||||
editor.update((view) => {
|
editor.update((view) => {
|
||||||
const selection = view.getSelection();
|
const selection = view.getSelection();
|
||||||
if (selection !== null) {
|
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);
|
editor.setCompositionKey(selection.anchorKey);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -477,6 +459,8 @@ export function onCompositionEnd(
|
|||||||
state: EventHandlerState,
|
state: EventHandlerState,
|
||||||
): void {
|
): void {
|
||||||
editor.setCompositionKey(null);
|
editor.setCompositionKey(null);
|
||||||
|
|
||||||
|
editor.update((view) => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onSelectionChange(
|
export function onSelectionChange(
|
||||||
@ -566,112 +550,74 @@ export function handleBlockTextInputOnNode(
|
|||||||
anchorNode.markDirtyDecorator();
|
anchorNode.markDirtyDecorator();
|
||||||
view.markNodeAsDirty(anchorNode);
|
view.markNodeAsDirty(anchorNode);
|
||||||
editor._compositionKey = null;
|
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 true;
|
||||||
}
|
}
|
||||||
return false;
|
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,
|
event: InputEvent,
|
||||||
editor: OutlineEditor,
|
editor: OutlineEditor,
|
||||||
state: EventHandlerState,
|
state: EventHandlerState,
|
||||||
): void {
|
): void {
|
||||||
const inputType = event.inputType;
|
const inputType = event.inputType;
|
||||||
const isInsertText = inputType === 'insertText';
|
if (
|
||||||
const isInsertCompositionText = inputType === 'insertCompositionText';
|
inputType === 'insertText' ||
|
||||||
const isDeleteCompositionText = inputType === 'deleteCompositionText';
|
inputType === 'insertCompositionText' ||
|
||||||
|
inputType === 'deleteCompositionText'
|
||||||
if (!isInsertText && !isInsertCompositionText && !isDeleteCompositionText) {
|
) {
|
||||||
return;
|
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 {
|
function applyTargetRange(selection: Selection, event: InputEvent): void {
|
||||||
@ -714,7 +660,7 @@ function isBadDoubleSpacePeriodReplacment(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onNativeBeforeInputForPlainText(
|
export function onBeforeInputForPlainText(
|
||||||
event: InputEvent,
|
event: InputEvent,
|
||||||
editor: OutlineEditor,
|
editor: OutlineEditor,
|
||||||
state: EventHandlerState,
|
state: EventHandlerState,
|
||||||
@ -744,43 +690,22 @@ export function onNativeBeforeInputForPlainText(
|
|||||||
insertText(selection, ' ');
|
insertText(selection, ' ');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isInputText = inputType === 'insertText';
|
// We let the browser do its own thing for these composition
|
||||||
const anchorNode = selection.getAnchorNode();
|
// events. We handle their updates in our mutation observer.
|
||||||
const focusNode = selection.getAnchorNode();
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isInputText ||
|
|
||||||
inputType === 'insertCompositionText' ||
|
inputType === 'insertCompositionText' ||
|
||||||
inputType === 'deleteCompositionText'
|
inputType === 'deleteCompositionText'
|
||||||
) {
|
) {
|
||||||
if (
|
return;
|
||||||
isInputText &&
|
}
|
||||||
selection.isCaret() &&
|
const anchorNode = selection.getAnchorNode();
|
||||||
selection.anchorOffset === anchorNode.getTextContentSize() &&
|
const focusNode = selection.getAnchorNode();
|
||||||
(isImmutableOrInertOrSegmented(anchorNode) ||
|
|
||||||
!anchorNode.canInsertTextAtEnd())
|
// Standard text insertion goes through a different path.
|
||||||
) {
|
// For most text insertion, we let the browser do its own thing.
|
||||||
event.preventDefault();
|
// We update the view model in our mutation observer. However,
|
||||||
if (data != null) {
|
// we do have a few exceptions.
|
||||||
const nextSibling = anchorNode.getNextSibling();
|
if (inputType === 'insertText') {
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (data === '\n') {
|
if (data === '\n') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
insertLineBreak(selection);
|
insertLineBreak(selection);
|
||||||
@ -788,17 +713,44 @@ export function onNativeBeforeInputForPlainText(
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
insertLineBreak(selection);
|
insertLineBreak(selection);
|
||||||
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 anchorKey = selection.anchorKey;
|
||||||
const focusKey = selection.focusKey;
|
const focusKey = selection.focusKey;
|
||||||
|
|
||||||
if (
|
if (anchorKey === focusKey) {
|
||||||
(IS_FIREFOX || !isInputText) &&
|
const isAtEnd =
|
||||||
canRemoveText(anchorNode, focusNode)
|
selection.anchorOffset === anchorNode.getTextContentSize();
|
||||||
) {
|
const canInsertAtEnd = anchorNode.canInsertTextAtEnd();
|
||||||
removeText(selection);
|
|
||||||
}
|
// We should always block text insertion to imm/seg/inert nodes.
|
||||||
if (isInputText && anchorKey !== focusKey && data) {
|
// 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();
|
event.preventDefault();
|
||||||
editor.setCompositionKey(null);
|
editor.setCompositionKey(null);
|
||||||
insertText(selection, data);
|
insertText(selection, data);
|
||||||
@ -879,7 +831,7 @@ export function onNativeBeforeInputForPlainText(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onNativeBeforeInputForRichText(
|
export function onBeforeInputForRichText(
|
||||||
event: InputEvent,
|
event: InputEvent,
|
||||||
editor: OutlineEditor,
|
editor: OutlineEditor,
|
||||||
state: EventHandlerState,
|
state: EventHandlerState,
|
||||||
@ -909,60 +861,66 @@ export function onNativeBeforeInputForRichText(
|
|||||||
insertText(selection, ' ');
|
insertText(selection, ' ');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isInputText = inputType === 'insertText';
|
// We let the browser do its own thing for these composition
|
||||||
const anchorNode = selection.getAnchorNode();
|
// events. We handle their updates in our mutation observer.
|
||||||
const focusNode = selection.getAnchorNode();
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isInputText ||
|
|
||||||
inputType === 'insertCompositionText' ||
|
inputType === 'insertCompositionText' ||
|
||||||
inputType === 'deleteCompositionText'
|
inputType === 'deleteCompositionText'
|
||||||
) {
|
) {
|
||||||
if (
|
return;
|
||||||
isInputText &&
|
}
|
||||||
selection.isCaret() &&
|
const anchorNode = selection.getAnchorNode();
|
||||||
selection.anchorOffset === anchorNode.getTextContentSize() &&
|
const focusNode = selection.getAnchorNode();
|
||||||
(isImmutableOrInertOrSegmented(anchorNode) ||
|
|
||||||
!anchorNode.canInsertTextAtEnd())
|
// Standard text insertion goes through a different path.
|
||||||
) {
|
// For most text insertion, we let the browser do its own thing.
|
||||||
event.preventDefault();
|
// We update the view model in our mutation observer. However,
|
||||||
if (data != null) {
|
// we do have a few exceptions.
|
||||||
const nextSibling = anchorNode.getNextSibling();
|
if (inputType === 'insertText') {
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (data === '\n') {
|
if (data === '\n') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
insertLineBreak(selection);
|
insertLineBreak(selection);
|
||||||
} else if (data === '\n\n') {
|
} else if (data === '\n\n') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
insertParagraph(selection);
|
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 anchorKey = selection.anchorKey;
|
||||||
const focusKey = selection.focusKey;
|
const focusKey = selection.focusKey;
|
||||||
|
|
||||||
if (
|
if (anchorKey === focusKey) {
|
||||||
(IS_FIREFOX || !isInputText) &&
|
const isAtEnd =
|
||||||
canRemoveText(anchorNode, focusNode)
|
selection.anchorOffset === anchorNode.getTextContentSize();
|
||||||
) {
|
const canInsertAtEnd = anchorNode.canInsertTextAtEnd();
|
||||||
removeText(selection);
|
|
||||||
}
|
// We should always block text insertion to imm/seg/inert nodes.
|
||||||
if (isInputText && anchorKey !== focusKey && data) {
|
// 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();
|
event.preventDefault();
|
||||||
editor.setCompositionKey(null);
|
editor.setCompositionKey(null);
|
||||||
insertText(selection, data);
|
insertText(selection, data);
|
||||||
@ -1047,30 +1005,72 @@ export function onNativeBeforeInputForRichText(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onPolyfilledBeforeInput(
|
export function onMutation(
|
||||||
event: SyntheticInputEvent<EventTarget>,
|
|
||||||
editor: OutlineEditor,
|
editor: OutlineEditor,
|
||||||
state: EventHandlerState,
|
mutations: Array<MutationRecord>,
|
||||||
): void {
|
): void {
|
||||||
event.preventDefault();
|
editor.update((view: View) => {
|
||||||
editor.update((view) => {
|
|
||||||
const selection = view.getSelection();
|
const selection = view.getSelection();
|
||||||
const data = event.data;
|
|
||||||
if (data != null && selection !== null) {
|
for (let i = 0; i < mutations.length; i++) {
|
||||||
const compositionSelection = state.compositionSelection;
|
const mutation = mutations[i];
|
||||||
state.compositionSelection = null;
|
const type = mutation.type;
|
||||||
if (compositionSelection !== null) {
|
|
||||||
selection.setRange(
|
if (type === 'characterData') {
|
||||||
compositionSelection.anchorKey,
|
updateTextNodeFromDOMContent(mutation.target, view, editor);
|
||||||
compositionSelection.anchorOffset,
|
} else if (type === 'childList') {
|
||||||
compositionSelection.focusKey,
|
// This occurs when the DOM tree has been mutated in terms of
|
||||||
compositionSelection.focusOffset,
|
// 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,
|
onCompositionEnd,
|
||||||
onCut,
|
onCut,
|
||||||
onCopy,
|
onCopy,
|
||||||
onNativeBeforeInputForPlainText,
|
onBeforeInputForPlainText,
|
||||||
onPasteForPlainText,
|
onPasteForPlainText,
|
||||||
onDropPolyfill,
|
onDropPolyfill,
|
||||||
onDragStartPolyfill,
|
onDragStartPolyfill,
|
||||||
onPolyfilledBeforeInput,
|
onInput,
|
||||||
onNativeInput,
|
onMutation,
|
||||||
} from './shared/EventHandlers';
|
} from './shared/EventHandlers';
|
||||||
import useOutlineDragonSupport from './shared/useOutlineDragonSupport';
|
import useOutlineDragonSupport from './shared/useOutlineDragonSupport';
|
||||||
import useOutlineHistory from './shared/useOutlineHistory';
|
import useOutlineHistory from './shared/useOutlineHistory';
|
||||||
@ -44,8 +44,6 @@ function initEditor(editor: OutlineEditor): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyObject: {} = {};
|
|
||||||
|
|
||||||
const events: InputEvents = [
|
const events: InputEvents = [
|
||||||
['selectionchange', onSelectionChange],
|
['selectionchange', onSelectionChange],
|
||||||
['keydown', onKeyDownForPlainText],
|
['keydown', onKeyDownForPlainText],
|
||||||
@ -55,25 +53,21 @@ const events: InputEvents = [
|
|||||||
['copy', onCopy],
|
['copy', onCopy],
|
||||||
['dragstart', onDragStartPolyfill],
|
['dragstart', onDragStartPolyfill],
|
||||||
['paste', onPasteForPlainText],
|
['paste', onPasteForPlainText],
|
||||||
|
['beforeinput', onBeforeInputForPlainText],
|
||||||
|
['input', onInput],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (CAN_USE_BEFORE_INPUT) {
|
if (!CAN_USE_BEFORE_INPUT) {
|
||||||
events.push(
|
|
||||||
['beforeinput', onNativeBeforeInputForPlainText],
|
|
||||||
['input', onNativeInput],
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
events.push(['drop', onDropPolyfill]);
|
events.push(['drop', onDropPolyfill]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useOutlinePlainText(
|
export default function useOutlinePlainText(
|
||||||
editor: OutlineEditor,
|
editor: OutlineEditor,
|
||||||
isReadOnly?: boolean = false,
|
isReadOnly?: boolean = false,
|
||||||
): {} | {onBeforeInput: (SyntheticInputEvent<EventTarget>) => void} {
|
): void {
|
||||||
const eventHandlerState: EventHandlerState = useMemo(
|
const eventHandlerState: EventHandlerState = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
compositionSelection: null,
|
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@ -83,23 +77,34 @@ export default function useOutlinePlainText(
|
|||||||
}, [isReadOnly, eventHandlerState]);
|
}, [isReadOnly, eventHandlerState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return editor.addEditorElementListener((editorElement) => {
|
const removeElementListner = editor.addEditorElementListener(
|
||||||
if (editorElement !== null) {
|
(editorElement) => {
|
||||||
initEditor(editor);
|
if (editorElement !== null) {
|
||||||
editor.registerNodeType('paragraph', ParagraphNode);
|
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]);
|
}, [editor]);
|
||||||
|
|
||||||
useOutlineEditorEvents(events, editor, eventHandlerState);
|
useOutlineEditorEvents(events, editor, eventHandlerState);
|
||||||
useOutlineDragonSupport(editor);
|
useOutlineDragonSupport(editor);
|
||||||
useOutlineHistory(editor);
|
useOutlineHistory(editor);
|
||||||
|
|
||||||
return CAN_USE_BEFORE_INPUT
|
|
||||||
? emptyObject
|
|
||||||
: {
|
|
||||||
onBeforeInput: (event: SyntheticInputEvent<EventTarget>) => {
|
|
||||||
onPolyfilledBeforeInput(event, editor, eventHandlerState);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
@ -29,12 +29,12 @@ import {
|
|||||||
onCompositionEnd,
|
onCompositionEnd,
|
||||||
onCut,
|
onCut,
|
||||||
onCopy,
|
onCopy,
|
||||||
onNativeBeforeInputForRichText,
|
onBeforeInputForRichText,
|
||||||
onPasteForRichText,
|
onPasteForRichText,
|
||||||
onDropPolyfill,
|
onDropPolyfill,
|
||||||
onDragStartPolyfill,
|
onDragStartPolyfill,
|
||||||
onPolyfilledBeforeInput,
|
onInput,
|
||||||
onNativeInput,
|
onMutation,
|
||||||
} from './shared/EventHandlers';
|
} from './shared/EventHandlers';
|
||||||
import useOutlineDragonSupport from './shared/useOutlineDragonSupport';
|
import useOutlineDragonSupport from './shared/useOutlineDragonSupport';
|
||||||
import useOutlineHistory from './shared/useOutlineHistory';
|
import useOutlineHistory from './shared/useOutlineHistory';
|
||||||
@ -50,8 +50,6 @@ function initEditor(editor: OutlineEditor): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyObject: {} = {};
|
|
||||||
|
|
||||||
const events: InputEvents = [
|
const events: InputEvents = [
|
||||||
['selectionchange', onSelectionChange],
|
['selectionchange', onSelectionChange],
|
||||||
['keydown', onKeyDownForRichText],
|
['keydown', onKeyDownForRichText],
|
||||||
@ -61,26 +59,22 @@ const events: InputEvents = [
|
|||||||
['copy', onCopy],
|
['copy', onCopy],
|
||||||
['dragstart', onDragStartPolyfill],
|
['dragstart', onDragStartPolyfill],
|
||||||
['paste', onPasteForRichText],
|
['paste', onPasteForRichText],
|
||||||
|
['input', onInput],
|
||||||
|
['beforeinput', onBeforeInputForRichText],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (CAN_USE_BEFORE_INPUT) {
|
if (!CAN_USE_BEFORE_INPUT) {
|
||||||
events.push(
|
|
||||||
['beforeinput', onNativeBeforeInputForRichText],
|
|
||||||
['input', onNativeInput],
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
events.push(['drop', onDropPolyfill]);
|
events.push(['drop', onDropPolyfill]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useOutlineRichText(
|
export default function useOutlineRichText(
|
||||||
editor: OutlineEditor,
|
editor: OutlineEditor,
|
||||||
isReadOnly?: boolean = false,
|
isReadOnly?: boolean = false,
|
||||||
): {} | {onBeforeInput: (SyntheticInputEvent<EventTarget>) => void} {
|
): void {
|
||||||
const eventHandlerState: EventHandlerState = useMemo(
|
const eventHandlerState: EventHandlerState = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
richText: true,
|
richText: true,
|
||||||
compositionSelection: null,
|
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@ -90,28 +84,39 @@ export default function useOutlineRichText(
|
|||||||
}, [isReadOnly, eventHandlerState]);
|
}, [isReadOnly, eventHandlerState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return editor.addEditorElementListener((editorElement) => {
|
const removeElementListner = editor.addEditorElementListener(
|
||||||
if (editorElement !== null) {
|
(editorElement) => {
|
||||||
editor.registerNodeType('heading', HeadingNode);
|
if (editorElement !== null) {
|
||||||
editor.registerNodeType('list', ListNode);
|
editor.registerNodeType('heading', HeadingNode);
|
||||||
editor.registerNodeType('quote', QuoteNode);
|
editor.registerNodeType('list', ListNode);
|
||||||
editor.registerNodeType('code', CodeNode);
|
editor.registerNodeType('quote', QuoteNode);
|
||||||
editor.registerNodeType('paragraph', ParagraphNode);
|
editor.registerNodeType('code', CodeNode);
|
||||||
editor.registerNodeType('listitem', ListItemNode);
|
editor.registerNodeType('paragraph', ParagraphNode);
|
||||||
initEditor(editor);
|
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]);
|
}, [editor]);
|
||||||
|
|
||||||
useOutlineEditorEvents(events, editor, eventHandlerState);
|
useOutlineEditorEvents(events, editor, eventHandlerState);
|
||||||
useOutlineDragonSupport(editor);
|
useOutlineDragonSupport(editor);
|
||||||
useOutlineHistory(editor);
|
useOutlineHistory(editor);
|
||||||
|
|
||||||
return CAN_USE_BEFORE_INPUT
|
|
||||||
? emptyObject
|
|
||||||
: {
|
|
||||||
onBeforeInput: (event: SyntheticInputEvent<EventTarget>) => {
|
|
||||||
onPolyfilledBeforeInput(event, editor, eventHandlerState);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,7 @@ import {
|
|||||||
errorOnProcessingTextNodeTransforms,
|
errorOnProcessingTextNodeTransforms,
|
||||||
applySelectionTransforms,
|
applySelectionTransforms,
|
||||||
triggerUpdateListeners,
|
triggerUpdateListeners,
|
||||||
|
triggerEndMutationListeners,
|
||||||
} from './OutlineView';
|
} from './OutlineView';
|
||||||
import {createSelection} from './OutlineSelection';
|
import {createSelection} from './OutlineSelection';
|
||||||
import {
|
import {
|
||||||
@ -80,6 +81,8 @@ export type TextNodeTransform = (node: TextNode, view: View) => void;
|
|||||||
|
|
||||||
export type EditorElementListener = (element: null | HTMLElement) => void;
|
export type EditorElementListener = (element: null | HTMLElement) => void;
|
||||||
|
|
||||||
|
export type MutationListener = () => (editorElement: HTMLElement) => void;
|
||||||
|
|
||||||
export function resetEditor(editor: OutlineEditor): void {
|
export function resetEditor(editor: OutlineEditor): void {
|
||||||
const root = createRoot();
|
const root = createRoot();
|
||||||
const emptyViewModel = new ViewModel({root});
|
const emptyViewModel = new ViewModel({root});
|
||||||
@ -104,6 +107,7 @@ export function resetEditor(editor: OutlineEditor): void {
|
|||||||
keyToDOMMap.clear();
|
keyToDOMMap.clear();
|
||||||
editor._textContent = '';
|
editor._textContent = '';
|
||||||
triggerUpdateListeners(editor);
|
triggerUpdateListeners(editor);
|
||||||
|
triggerEndMutationListeners(editor);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEditor(
|
export function createEditor(
|
||||||
@ -220,6 +224,7 @@ export class OutlineEditor {
|
|||||||
_errorListeners: Set<ErrorListener>;
|
_errorListeners: Set<ErrorListener>;
|
||||||
_updateListeners: Set<UpdateListener>;
|
_updateListeners: Set<UpdateListener>;
|
||||||
_elementListeners: Set<EditorElementListener>;
|
_elementListeners: Set<EditorElementListener>;
|
||||||
|
_mutationListeners: Set<MutationListener>;
|
||||||
_decoratorListeners: Set<DecoratorListener>;
|
_decoratorListeners: Set<DecoratorListener>;
|
||||||
_textNodeTransforms: Set<TextNodeTransform>;
|
_textNodeTransforms: Set<TextNodeTransform>;
|
||||||
_nodeTypes: Map<string, Class<OutlineNode>>;
|
_nodeTypes: Map<string, Class<OutlineNode>>;
|
||||||
@ -248,6 +253,8 @@ export class OutlineEditor {
|
|||||||
this._decoratorListeners = new Set();
|
this._decoratorListeners = new Set();
|
||||||
// Editor element listeners
|
// Editor element listeners
|
||||||
this._elementListeners = new Set();
|
this._elementListeners = new Set();
|
||||||
|
// Mutation listeners
|
||||||
|
this._mutationListeners = new Set();
|
||||||
// Class name mappings for nodes/placeholders
|
// Class name mappings for nodes/placeholders
|
||||||
this._editorThemeClasses = editorThemeClasses;
|
this._editorThemeClasses = editorThemeClasses;
|
||||||
// Handling of text node transforms
|
// Handling of text node transforms
|
||||||
@ -271,13 +278,13 @@ export class OutlineEditor {
|
|||||||
setCompositionKey(nodeKey: null | NodeKey): void {
|
setCompositionKey(nodeKey: null | NodeKey): void {
|
||||||
if (nodeKey === null) {
|
if (nodeKey === null) {
|
||||||
this._compositionKey = null;
|
this._compositionKey = null;
|
||||||
updateEditor(this, emptyFunction, true);
|
updateEditor(this, emptyFunction, false);
|
||||||
const pendingViewModel = this._pendingViewModel;
|
const pendingViewModel = this._pendingViewModel;
|
||||||
if (pendingViewModel !== null) {
|
if (pendingViewModel !== null) {
|
||||||
pendingViewModel.markDirty();
|
pendingViewModel.markDirty();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updateEditor(this, emptyFunction, true);
|
updateEditor(this, emptyFunction, false);
|
||||||
}
|
}
|
||||||
this._deferred.push(() => {
|
this._deferred.push(() => {
|
||||||
this._compositionKey = nodeKey;
|
this._compositionKey = nodeKey;
|
||||||
@ -298,6 +305,12 @@ export class OutlineEditor {
|
|||||||
this._errorListeners.delete(listener);
|
this._errorListeners.delete(listener);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
addMutationListener(listener: MutationListener): () => void {
|
||||||
|
this._mutationListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this._mutationListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
addEditorElementListener(listener: EditorElementListener): () => void {
|
addEditorElementListener(listener: EditorElementListener): () => void {
|
||||||
this._elementListeners.add(listener);
|
this._elementListeners.add(listener);
|
||||||
listener(this._editorElement);
|
listener(this._editorElement);
|
||||||
|
@ -464,6 +464,10 @@ export class OutlineNode {
|
|||||||
isDirectionless(): boolean {
|
isDirectionless(): boolean {
|
||||||
return (this.getLatest().__flags & IS_DIRECTIONLESS) !== 0;
|
return (this.getLatest().__flags & IS_DIRECTIONLESS) !== 0;
|
||||||
}
|
}
|
||||||
|
isDirty(): boolean {
|
||||||
|
const viewModel = getActiveViewModel();
|
||||||
|
return viewModel._dirtyNodes.has(this.__key);
|
||||||
|
}
|
||||||
getLatest<N: OutlineNode>(): N {
|
getLatest<N: OutlineNode>(): N {
|
||||||
const latest = getNodeByKey<N>(this.__key);
|
const latest = getNodeByKey<N>(this.__key);
|
||||||
if (latest === null) {
|
if (latest === null) {
|
||||||
|
@ -8,7 +8,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type {NodeKey, NodeMapType} from './OutlineNode';
|
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 {OutlineEditor, EditorThemeClasses} from './OutlineEditor';
|
||||||
import type {Selection} from './OutlineSelection';
|
import type {Selection} from './OutlineSelection';
|
||||||
import type {Node as ReactNode} from 'react';
|
import type {Node as ReactNode} from 'react';
|
||||||
@ -491,6 +495,7 @@ function reconcileRoot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function reconcileViewModel(
|
export function reconcileViewModel(
|
||||||
|
editorElement: HTMLElement,
|
||||||
prevViewModel: ViewModel,
|
prevViewModel: ViewModel,
|
||||||
nextViewModel: ViewModel,
|
nextViewModel: ViewModel,
|
||||||
editor: OutlineEditor,
|
editor: OutlineEditor,
|
||||||
@ -505,13 +510,18 @@ export function reconcileViewModel(
|
|||||||
|
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
const {anchorOffset, focusOffset} = window.getSelection();
|
const {anchorOffset, focusOffset} = window.getSelection();
|
||||||
reconcileRoot(
|
const startMutationListeners = triggerEndMutationListeners(editor);
|
||||||
prevViewModel,
|
try {
|
||||||
nextViewModel,
|
reconcileRoot(
|
||||||
editor,
|
prevViewModel,
|
||||||
dirtySubTrees,
|
nextViewModel,
|
||||||
dirtyNodes,
|
editor,
|
||||||
);
|
dirtySubTrees,
|
||||||
|
dirtyNodes,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
triggerStartMutationListeners(editorElement, startMutationListeners);
|
||||||
|
}
|
||||||
const selectionAfter = window.getSelection();
|
const selectionAfter = window.getSelection();
|
||||||
if (
|
if (
|
||||||
anchorOffset !== selectionAfter.anchorOffset ||
|
anchorOffset !== selectionAfter.anchorOffset ||
|
||||||
|
@ -356,6 +356,7 @@ export function createSelection(
|
|||||||
const useDOMSelection =
|
const useDOMSelection =
|
||||||
isSelectionChange ||
|
isSelectionChange ||
|
||||||
eventType === 'beforeinput' ||
|
eventType === 'beforeinput' ||
|
||||||
|
eventType === 'input' ||
|
||||||
eventType === 'compositionstart';
|
eventType === 'compositionstart';
|
||||||
let anchorDOM, focusDOM, anchorOffset, focusOffset;
|
let anchorDOM, focusDOM, anchorOffset, focusOffset;
|
||||||
|
|
||||||
|
@ -303,7 +303,8 @@ export function garbageCollectDetachedNodes(
|
|||||||
|
|
||||||
export function commitPendingUpdates(editor: OutlineEditor): void {
|
export function commitPendingUpdates(editor: OutlineEditor): void {
|
||||||
const pendingViewModel = editor._pendingViewModel;
|
const pendingViewModel = editor._pendingViewModel;
|
||||||
if (editor._editorElement === null || pendingViewModel === null) {
|
const editorElement = editor._editorElement;
|
||||||
|
if (editorElement === null || pendingViewModel === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentViewModel = editor._viewModel;
|
const currentViewModel = editor._viewModel;
|
||||||
@ -312,13 +313,17 @@ export function commitPendingUpdates(editor: OutlineEditor): void {
|
|||||||
const previousActiveViewModel = activeViewModel;
|
const previousActiveViewModel = activeViewModel;
|
||||||
activeViewModel = pendingViewModel;
|
activeViewModel = pendingViewModel;
|
||||||
try {
|
try {
|
||||||
reconcileViewModel(currentViewModel, pendingViewModel, editor);
|
reconcileViewModel(
|
||||||
|
editorElement,
|
||||||
|
currentViewModel,
|
||||||
|
pendingViewModel,
|
||||||
|
editor,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Report errors
|
// Report errors
|
||||||
triggerErrorListeners(editor, error);
|
triggerErrorListeners(editor, error);
|
||||||
// Reset editor and restore incoming view model to the DOM
|
// Reset editor and restore incoming view model to the DOM
|
||||||
const editorElement = editor._editorElement;
|
if (!isAttemptingToRecoverFromReconcilerError) {
|
||||||
if (editorElement !== null && !isAttemptingToRecoverFromReconcilerError) {
|
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
editor._keyToDOMMap.set('root', editorElement);
|
editor._keyToDOMMap.set('root', editorElement);
|
||||||
editor._pendingViewModel = pendingViewModel;
|
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 {
|
export function triggerUpdateListeners(editor: OutlineEditor): void {
|
||||||
const viewModel = editor._viewModel;
|
const viewModel = editor._viewModel;
|
||||||
const listeners = Array.from(editor._updateListeners);
|
const listeners = Array.from(editor._updateListeners);
|
||||||
|
Reference in New Issue
Block a user