Files
Dominic Gannaway b5e36c660b 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
2022-04-09 00:42:23 -07:00

1077 lines
31 KiB
JavaScript

/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
*/
import type {
OutlineEditor,
Selection,
OutlineNode,
ParsedNodeMap,
NodeKey,
TextNode,
View,
} from 'outline';
import {IS_SAFARI} from 'shared/environment';
import {
isDeleteBackward,
isDeleteForward,
isDeleteLineBackward,
isDeleteLineForward,
isDeleteWordBackward,
isDeleteWordForward,
isLineBreak,
isOpenLineBreak,
isParagraph,
isBold,
isItalic,
isUnderline,
isTab,
isSelectAll,
isMoveWordBackward,
isMoveBackward,
isMoveForward,
isMoveWordForward,
} from 'outline/KeyHelpers';
import isImmutableOrInertOrSegmented from 'shared/isImmutableOrInertOrSegmented';
import {
deleteBackward,
deleteForward,
deleteLineBackward,
deleteLineForward,
deleteWordBackward,
deleteWordForward,
insertParagraph,
formatText,
insertText,
removeText,
getNodesInRange,
insertNodes,
insertLineBreak,
selectAll,
moveWordBackward,
insertRichText,
moveBackward,
moveForward,
moveWordForward,
} from 'outline/SelectionHelpers';
import {createTextNode, isTextNode} from 'outline';
const ZERO_WIDTH_SPACE_CHAR = '\u200B';
const ZERO_WIDTH_JOINER_CHAR = '\u2060';
// TODO the Flow types here needs fixing
export type EventHandler = (
// $FlowFixMe: not sure how to handle this generic properly
event: Object,
editor: OutlineEditor,
state: EventHandlerState,
) => void;
export type EventHandlerState = {
isReadOnly: boolean,
};
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,
): Array<OutlineNode> {
const {range, nodeMap: parsedNodeMap} = nodeRange;
const nodes = [];
for (let i = 0; i < range.length; i++) {
const key = range[i];
const parsedNode = parsedNodeMap[key];
const node = view.createNodeFromParse(parsedNode, parsedNodeMap);
nodes.push(node);
}
return nodes;
}
function insertDataTransferForRichText(
dataTransfer: DataTransfer,
selection: Selection,
view: View,
): void {
const outlineNodesString = dataTransfer.getData(
'application/x-outline-nodes',
);
if (outlineNodesString) {
const nodeRange = JSON.parse(outlineNodesString);
const nodes = generateNodes(nodeRange, view);
insertNodes(selection, nodes);
return;
}
insertDataTransferForPlainText(dataTransfer, selection, view);
}
function insertDataTransferForPlainText(
dataTransfer: DataTransfer,
selection: Selection,
view: View,
): void {
const text = dataTransfer.getData('text/plain');
if (text != null) {
insertRichText(selection, text);
}
}
function shouldOverrideBrowserDefault(
selection: Selection,
isHoldingShift: boolean,
isBackward: boolean,
): boolean {
const anchorOffset = selection.anchorOffset;
const focusOffset = selection.focusOffset;
const anchorTextContentSize = selection.getAnchorNode().getTextContentSize();
const selectionAtBoundary = isBackward
? anchorOffset < 2 || focusOffset < 2
: anchorOffset > anchorTextContentSize - 2 ||
focusOffset > anchorTextContentSize - 2;
return selection.isCaret()
? isHoldingShift || selectionAtBoundary
: isHoldingShift && selectionAtBoundary;
}
function isTopLevelBlockRTL(selection: Selection) {
const anchorNode = selection.getAnchorNode();
const topLevelBlock = anchorNode.getTopParentBlockOrThrow();
const direction = topLevelBlock.getDirection();
return direction === 'rtl';
}
export function onKeyDownForPlainText(
event: KeyboardEvent,
editor: OutlineEditor,
state: EventHandlerState,
): void {
if (editor.isComposing()) {
return;
}
editor.update((view) => {
const selection = view.getSelection();
if (selection === null) {
return;
}
const isHoldingShift = event.shiftKey;
const isRTL = isTopLevelBlockRTL(selection);
if (isMoveBackward(event)) {
if (shouldOverrideBrowserDefault(selection, isHoldingShift, !isRTL)) {
event.preventDefault();
moveBackward(selection, isHoldingShift, isRTL);
}
} else if (isMoveForward(event)) {
if (shouldOverrideBrowserDefault(selection, isHoldingShift, isRTL)) {
event.preventDefault();
moveForward(selection, isHoldingShift, isRTL);
}
} else if (isParagraph(event) || isLineBreak(event)) {
event.preventDefault();
insertLineBreak(selection);
} else if (isOpenLineBreak(event)) {
event.preventDefault();
insertLineBreak(selection, true);
} else if (isDeleteBackward(event)) {
event.preventDefault();
deleteBackward(selection);
} else if (isDeleteForward(event)) {
event.preventDefault();
deleteForward(selection);
} else if (isMoveWordBackward(event)) {
if (
IS_SAFARI ||
shouldOverrideBrowserDefault(selection, isHoldingShift, !isRTL)
) {
event.preventDefault();
moveWordBackward(selection, isHoldingShift, isRTL);
}
} else if (isMoveWordForward(event)) {
if (
IS_SAFARI ||
shouldOverrideBrowserDefault(selection, isHoldingShift, isRTL)
) {
event.preventDefault();
moveWordForward(selection, isHoldingShift, isRTL);
}
} else if (isDeleteWordBackward(event)) {
event.preventDefault();
deleteWordBackward(selection);
} else if (isDeleteWordForward(event)) {
event.preventDefault();
deleteWordForward(selection);
} else if (isDeleteLineBackward(event)) {
event.preventDefault();
deleteLineBackward(selection);
} else if (isDeleteLineForward(event)) {
event.preventDefault();
deleteLineForward(selection);
} else if (isSelectAll(event)) {
event.preventDefault();
selectAll(selection);
}
});
}
export function onKeyDownForRichText(
event: KeyboardEvent,
editor: OutlineEditor,
state: EventHandlerState,
): void {
if (editor.isComposing()) {
return;
}
editor.update((view) => {
const selection = view.getSelection();
if (selection === null) {
return;
}
const isHoldingShift = event.shiftKey;
const isRTL = isTopLevelBlockRTL(selection);
if (isMoveBackward(event)) {
if (shouldOverrideBrowserDefault(selection, isHoldingShift, !isRTL)) {
event.preventDefault();
moveBackward(selection, isHoldingShift, isRTL);
}
} else if (isMoveForward(event)) {
if (shouldOverrideBrowserDefault(selection, isHoldingShift, isRTL)) {
event.preventDefault();
moveForward(selection, isHoldingShift, isRTL);
}
} else if (isLineBreak(event)) {
event.preventDefault();
insertLineBreak(selection);
} else if (isOpenLineBreak(event)) {
event.preventDefault();
insertLineBreak(selection, true);
} else if (isParagraph(event)) {
event.preventDefault();
insertParagraph(selection);
} else if (isDeleteBackward(event)) {
event.preventDefault();
deleteBackward(selection);
} else if (isDeleteForward(event)) {
event.preventDefault();
deleteForward(selection);
} else if (isMoveWordBackward(event)) {
if (
IS_SAFARI ||
shouldOverrideBrowserDefault(selection, isHoldingShift, !isRTL)
) {
event.preventDefault();
moveWordBackward(selection, isHoldingShift, isRTL);
}
} else if (isMoveWordForward(event)) {
if (
IS_SAFARI ||
shouldOverrideBrowserDefault(selection, isHoldingShift, isRTL)
) {
event.preventDefault();
moveWordForward(selection, isHoldingShift, isRTL);
}
} else if (isDeleteWordBackward(event)) {
event.preventDefault();
deleteWordBackward(selection);
} else if (isDeleteWordForward(event)) {
event.preventDefault();
deleteWordForward(selection);
} else if (isDeleteLineBackward(event)) {
event.preventDefault();
deleteLineBackward(selection);
} else if (isDeleteLineForward(event)) {
event.preventDefault();
deleteLineForward(selection);
} else if (isBold(event)) {
event.preventDefault();
formatText(selection, 'bold');
} else if (isUnderline(event)) {
event.preventDefault();
formatText(selection, 'underline');
} else if (isItalic(event)) {
event.preventDefault();
formatText(selection, 'italic');
} else if (isTab(event)) {
// Handle code blocks
const anchorNode = selection.getAnchorNode();
const parentBlock = anchorNode.getParentBlockOrThrow();
if (parentBlock.canInsertTab()) {
if (event.shiftKey) {
const textContent = anchorNode.getTextContent();
const character = textContent[selection.anchorOffset - 1];
if (character === '\t') {
deleteBackward(selection);
}
} else {
insertText(selection, '\t');
}
event.preventDefault();
}
} else if (isSelectAll(event)) {
event.preventDefault();
selectAll(selection);
}
});
}
export function onPasteForPlainText(
event: ClipboardEvent,
editor: OutlineEditor,
state: EventHandlerState,
): void {
event.preventDefault();
editor.update((view) => {
const selection = view.getSelection();
const clipboardData = event.clipboardData;
if (clipboardData != null && selection !== null) {
insertDataTransferForPlainText(clipboardData, selection, view);
}
});
}
export function onPasteForRichText(
event: ClipboardEvent,
editor: OutlineEditor,
state: EventHandlerState,
): void {
event.preventDefault();
editor.update((view) => {
const selection = view.getSelection();
const clipboardData = event.clipboardData;
if (clipboardData != null && selection !== null) {
insertDataTransferForRichText(clipboardData, selection, view);
}
});
}
export function onDropPolyfill(
event: ClipboardEvent,
editor: OutlineEditor,
state: EventHandlerState,
): void {
// This should only occur without beforeInput. Block it as it's too much
// hassle to make work at this point.
event.preventDefault();
}
export function onDragStartPolyfill(
event: ClipboardEvent,
editor: OutlineEditor,
state: EventHandlerState,
): void {
// Block dragging.
event.preventDefault();
}
export function onCut(
event: ClipboardEvent,
editor: OutlineEditor,
state: EventHandlerState,
): void {
onCopy(event, editor, state);
editor.update((view) => {
const selection = view.getSelection();
if (selection !== null) {
removeText(selection);
}
});
}
export function onCopy(
event: ClipboardEvent,
editor: OutlineEditor,
state: EventHandlerState,
): void {
event.preventDefault();
editor.update((view) => {
const clipboardData = event.clipboardData;
const selection = view.getSelection();
if (selection !== null) {
if (clipboardData != null) {
const domSelection = window.getSelection();
// If we haven't selected a range, then don't copy anything
if (domSelection.isCollapsed) {
return;
}
const range = domSelection.getRangeAt(0);
if (range) {
const container = document.createElement('div');
const frag = range.cloneContents();
container.appendChild(frag);
clipboardData.setData('text/html', container.innerHTML);
}
clipboardData.setData('text/plain', selection.getTextContent());
clipboardData.setData(
'application/x-outline-nodes',
JSON.stringify(getNodesInRange(selection)),
);
}
}
});
}
export function onCompositionStart(
event: CompositionEvent,
editor: OutlineEditor,
state: EventHandlerState,
): void {
editor.update((view) => {
const selection = view.getSelection();
if (selection !== null) {
editor.setCompositionKey(selection.anchorKey);
}
});
}
export function onCompositionEnd(
event: CompositionEvent,
editor: OutlineEditor,
state: EventHandlerState,
): void {
editor.setCompositionKey(null);
editor.update((view) => {});
}
export function onSelectionChange(
event: Event,
editor: OutlineEditor,
state: EventHandlerState,
): void {
const domSelection = window.getSelection();
const editorElement = editor.getEditorElement();
// This is a hot-path, so let's avoid doing an update when
// the anchorNode is not actually inside the editor.
if (editorElement && !editorElement.contains(domSelection.anchorNode)) {
return;
}
const prevSelection = editor.getViewModel()._selection;
// This update also functions as a way of reconciling a bad selection
// to a good selection. So if the below logic is removed, remember to
// not remove the editor update.
editor.update((view) => {
const selection = view.getSelection();
// In some cases, selection can move without an precursor operation
// occuring. For example, iOS Voice Control moves selection without
// triggering keydown events. In order to account for zero-width
// characters, and moving selection to the right place, we need to
// do the below.
if (
prevSelection !== null &&
selection !== null &&
selection.isCaret() &&
prevSelection.isCaret()
) {
const prevAnchorOffset = prevSelection.anchorOffset;
const prevAnchorKey = prevSelection.anchorKey;
const anchorOffset = selection.anchorOffset;
const anchorNode = selection.getAnchorNode();
if (
prevAnchorOffset === 0 &&
anchorOffset === anchorNode.getTextContentSize()
) {
const nextSibling = anchorNode.getNextSibling();
if (isTextNode(nextSibling) && nextSibling.getKey() === prevAnchorKey) {
const isRTL = isTopLevelBlockRTL(selection);
moveBackward(selection, false, isRTL);
}
} else if (anchorOffset === 0) {
const prevSibling = anchorNode.getPreviousSibling();
if (
isTextNode(prevSibling) &&
prevSibling.getKey() === prevAnchorKey &&
prevSibling.getTextContentSize() === prevAnchorOffset
) {
const isRTL = isTopLevelBlockRTL(selection);
moveForward(selection, false, isRTL);
}
}
}
});
}
export function checkForBadInsertion(
anchorElement: HTMLElement,
anchorNode: TextNode,
editor: OutlineEditor,
): boolean {
const nextSibling = anchorNode.getNextSibling();
return (
anchorElement.parentNode === null ||
(nextSibling !== null &&
editor.getElementByKey(nextSibling.getKey()) !==
anchorElement.nextSibling)
);
}
export function handleBlockTextInputOnNode(
anchorNode: TextNode,
view: View,
editor: OutlineEditor,
): boolean {
// If we are mutating an immutable or segmented node, then reset
// the content back to what it was before, as this is not allowed.
if (isImmutableOrInertOrSegmented(anchorNode)) {
// If this node has a decorator, then we'll make it as needing an
// update by React.
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;
}
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;
if (
inputType === 'insertText' ||
inputType === 'insertCompositionText' ||
inputType === 'deleteCompositionText'
) {
editor.update((view) => {
const domSelection = window.getSelection();
const anchorDOM = domSelection.anchorNode;
updateTextNodeFromDOMContent(anchorDOM, view, editor);
});
}
}
function applyTargetRange(selection: Selection, event: InputEvent): void {
if (event.getTargetRanges) {
const targetRange = event.getTargetRanges()[0];
if (targetRange) {
selection.applyDOMRange(targetRange);
}
}
}
function canRemoveText(anchorNode: TextNode, focusNode: TextNode): boolean {
return (
anchorNode !== focusNode ||
!isImmutableOrInertOrSegmented(anchorNode) ||
!isImmutableOrInertOrSegmented(focusNode)
);
}
// Block double space auto-period insertion if we're
// at the start of a an empty text node. This happens
// because the BOM gets treated like "text".
function isBadDoubleSpacePeriodReplacment(
event: InputEvent,
selection: Selection,
): boolean {
const inputType = event.inputType;
if (
(inputType === 'insertText' || inputType === 'insertReplacementText') &&
selection.anchorOffset === 0 &&
selection.focusOffset === 1 &&
selection.anchorKey === selection.focusKey
) {
const dataTransfer = event.dataTransfer;
const data =
dataTransfer != null ? dataTransfer.getData('text/plain') : event.data;
return data === '. ';
}
return false;
}
export function onBeforeInputForPlainText(
event: InputEvent,
editor: OutlineEditor,
state: EventHandlerState,
): void {
const inputType = event.inputType;
editor.update((view) => {
const selection = view.getSelection();
if (selection === null) {
return;
}
if (inputType === 'deleteContentBackward') {
// Used for Android
editor.setCompositionKey(null);
event.preventDefault();
deleteBackward(selection);
return;
}
const data = event.data;
if (selection.isCaret()) {
applyTargetRange(selection, event);
}
if (isBadDoubleSpacePeriodReplacment(event, selection)) {
event.preventDefault();
insertText(selection, ' ');
return;
}
// We let the browser do its own thing for these composition
// events. We handle their updates in our mutation observer.
if (
inputType === 'insertCompositionText' ||
inputType === 'deleteCompositionText'
) {
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();
insertLineBreak(selection);
insertLineBreak(selection);
} 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 (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);
}
}
return;
}
// Prevent the browser from carrying out
// the input event, so we can control the
// output.
event.preventDefault();
switch (inputType) {
case 'insertFromComposition': {
if (data) {
insertText(selection, data);
}
break;
}
case 'insertParagraph': {
// Used for Android
editor.setCompositionKey(null);
insertLineBreak(selection);
break;
}
case 'insertFromYank':
case 'insertFromDrop':
case 'insertReplacementText':
case 'insertFromPaste': {
const dataTransfer = event.dataTransfer;
if (dataTransfer != null) {
insertDataTransferForPlainText(dataTransfer, selection, view);
} else {
if (data) {
insertText(selection, data);
}
}
break;
}
case 'deleteByComposition': {
if (canRemoveText(anchorNode, focusNode)) {
removeText(selection);
}
break;
}
case 'deleteByDrag':
case 'deleteByCut': {
removeText(selection);
break;
}
case 'deleteContent': {
deleteForward(selection);
break;
}
case 'deleteWordBackward': {
deleteWordBackward(selection);
break;
}
case 'deleteWordForward': {
deleteWordForward(selection);
break;
}
case 'deleteHardLineBackward':
case 'deleteSoftLineBackward': {
deleteLineBackward(selection);
break;
}
case 'deleteContentForward':
case 'deleteHardLineForward':
case 'deleteSoftLineForward': {
deleteLineForward(selection);
break;
}
default:
// NO-OP
}
});
}
export function onBeforeInputForRichText(
event: InputEvent,
editor: OutlineEditor,
state: EventHandlerState,
): void {
const inputType = event.inputType;
editor.update((view) => {
const selection = view.getSelection();
if (selection === null) {
return;
}
if (inputType === 'deleteContentBackward') {
// Used for Android
editor.setCompositionKey(null);
event.preventDefault();
deleteBackward(selection);
return;
}
const data = event.data;
if (selection.isCaret()) {
applyTargetRange(selection, event);
}
if (isBadDoubleSpacePeriodReplacment(event, selection)) {
event.preventDefault();
insertText(selection, ' ');
return;
}
// We let the browser do its own thing for these composition
// events. We handle their updates in our mutation observer.
if (
inputType === 'insertCompositionText' ||
inputType === 'deleteCompositionText'
) {
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 (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 (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);
}
}
return;
}
// Prevent the browser from carrying out
// the input event, so we can control the
// output.
event.preventDefault();
switch (inputType) {
case 'insertFromComposition': {
if (data) {
insertText(selection, data);
}
break;
}
case 'insertParagraph': {
// Used for Android
editor.setCompositionKey(null);
insertParagraph(selection);
break;
}
case 'formatStrikeThrough': {
formatText(selection, 'strikethrough');
break;
}
case 'insertFromYank':
case 'insertFromDrop':
case 'insertReplacementText':
case 'insertFromPaste': {
const dataTransfer = event.dataTransfer;
if (dataTransfer != null) {
insertDataTransferForRichText(dataTransfer, selection, view);
} else {
if (data) {
insertText(selection, data);
}
}
break;
}
case 'deleteByComposition': {
if (canRemoveText(anchorNode, focusNode)) {
removeText(selection);
}
break;
}
case 'deleteByDrag':
case 'deleteByCut': {
removeText(selection);
break;
}
case 'deleteContent': {
deleteForward(selection);
break;
}
case 'deleteWordBackward': {
deleteWordBackward(selection);
break;
}
case 'deleteWordForward': {
deleteWordForward(selection);
break;
}
case 'deleteHardLineBackward':
case 'deleteSoftLineBackward': {
deleteLineBackward(selection);
break;
}
case 'deleteContentForward':
case 'deleteHardLineForward':
case 'deleteSoftLineForward': {
deleteLineForward(selection);
break;
}
default:
// NO-OP
}
});
}
export function onMutation(
editor: OutlineEditor,
mutations: Array<MutationRecord>,
): void {
editor.update((view: View) => {
const selection = view.getSelection();
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);
}
}
}
}
});
}