mirror of
https://github.com/facebook/lexical.git
synced 2025-08-06 16:39:33 +08:00

* Move KeyHelpers to Outline package * More tweaks * More tweaks * More fixes * Fix test * Fix test * Fix test
1014 lines
29 KiB
JavaScript
1014 lines
29 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 {CAN_USE_BEFORE_INPUT, IS_FIREFOX, IS_SAFARI} from 'shared/environment';
|
||
import {
|
||
isDeleteBackward,
|
||
isDeleteForward,
|
||
isDeleteLineBackward,
|
||
isDeleteLineForward,
|
||
isDeleteWordBackward,
|
||
isDeleteWordForward,
|
||
isLineBreak,
|
||
isParagraph,
|
||
isBold,
|
||
isItalic,
|
||
isUnderline,
|
||
isTab,
|
||
isSelectAll,
|
||
isMoveWordBackward,
|
||
isMoveBackward,
|
||
isMoveForward,
|
||
isMoveWordForward,
|
||
} from 'outline/KeyHelpers';
|
||
import getDOMTextNodeFromElement from 'shared/getDOMTextNodeFromElement';
|
||
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';
|
||
|
||
// Safari triggers composition before keydown, meaning
|
||
// we need to account for this when handling key events.
|
||
let wasRecentlyComposing = false;
|
||
let lastKeyWasMaybeAndroidSoftKey = false;
|
||
const RESOLVE_DELAY = 20;
|
||
const BYTE_ORDER_MARK = '\uFEFF';
|
||
|
||
// 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,
|
||
compositionSelection: null | Selection,
|
||
isHandlingPointer: boolean,
|
||
};
|
||
|
||
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 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();
|
||
const direction = topLevelBlock.getDirection();
|
||
return direction === 'rtl';
|
||
}
|
||
|
||
export function onKeyDownForPlainText(
|
||
event: KeyboardEvent,
|
||
editor: OutlineEditor,
|
||
state: EventHandlerState,
|
||
): void {
|
||
updateAndroidSoftKeyFlagIfAny(event);
|
||
if (editor.isComposing() || wasRecentlyComposing) {
|
||
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 (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 {
|
||
updateAndroidSoftKeyFlagIfAny(event);
|
||
if (editor.isComposing() || wasRecentlyComposing) {
|
||
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 (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) {
|
||
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);
|
||
}
|
||
}
|
||
if (IS_FIREFOX) {
|
||
// Not sure why we have to do this, but it seems to fix a bunch
|
||
// of FF related composition bugs to do with selection.
|
||
selection.isDirty = true;
|
||
}
|
||
editor.setCompositionKey(selection.anchorKey);
|
||
}
|
||
});
|
||
}
|
||
|
||
export function onCompositionEnd(
|
||
event: CompositionEvent,
|
||
editor: OutlineEditor,
|
||
state: EventHandlerState,
|
||
): void {
|
||
editor.setCompositionKey(null);
|
||
wasRecentlyComposing = true;
|
||
setTimeout(() => {
|
||
wasRecentlyComposing = false;
|
||
}, RESOLVE_DELAY);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
editor.update((view) => {
|
||
// This update also functions as a way of reconciling a bad selection
|
||
// to a good selection.
|
||
});
|
||
}
|
||
|
||
export function onPointerDown(
|
||
event: PointerEvent,
|
||
editor: OutlineEditor,
|
||
state: EventHandlerState,
|
||
): void {
|
||
state.isHandlingPointer = true;
|
||
// Throttle setting of the flag for 50ms, as we don't want this to trigger
|
||
// for simple clicks.
|
||
setTimeout(() => {
|
||
if (state.isHandlingPointer) {
|
||
editor.setPointerDown(true);
|
||
}
|
||
}, 50);
|
||
}
|
||
|
||
export function onPointerUp(
|
||
event: PointerEvent,
|
||
editor: OutlineEditor,
|
||
state: EventHandlerState,
|
||
): void {
|
||
state.isHandlingPointer = false;
|
||
editor.setPointerDown(false);
|
||
}
|
||
|
||
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;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export function onNativeInput(
|
||
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;
|
||
}
|
||
|
||
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(BYTE_ORDER_MARK, '');
|
||
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] === BYTE_ORDER_MARK) {
|
||
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 {
|
||
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 onNativeBeforeInputForPlainText(
|
||
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;
|
||
const inputText = inputType === 'insertText';
|
||
|
||
if (selection.isCaret()) {
|
||
applyTargetRange(selection, event);
|
||
}
|
||
if (isBadDoubleSpacePeriodReplacment(event, selection)) {
|
||
event.preventDefault();
|
||
insertText(selection, ' ');
|
||
return;
|
||
}
|
||
const anchorNode = selection.getAnchorNode();
|
||
const focusNode = selection.getAnchorNode();
|
||
|
||
if (
|
||
inputText ||
|
||
inputType === 'insertCompositionText' ||
|
||
inputType === 'deleteCompositionText'
|
||
) {
|
||
if (selection.isCaret() && isImmutableOrInertOrSegmented(anchorNode)) {
|
||
event.preventDefault();
|
||
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') {
|
||
event.preventDefault();
|
||
insertLineBreak(selection);
|
||
} else if (data === '\n\n') {
|
||
event.preventDefault();
|
||
insertLineBreak(selection);
|
||
insertLineBreak(selection);
|
||
} else if (!selection.isCaret()) {
|
||
const anchorKey = selection.anchorKey;
|
||
const focusKey = selection.focusKey;
|
||
|
||
if (canRemoveText(anchorNode, focusNode)) {
|
||
removeText(selection);
|
||
}
|
||
if (inputText && anchorKey !== focusKey && data) {
|
||
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 'deleteHardLineForward':
|
||
case 'deleteSoftLineForward': {
|
||
deleteLineForward(selection);
|
||
break;
|
||
}
|
||
default:
|
||
// NO-OP
|
||
}
|
||
});
|
||
}
|
||
|
||
export function onNativeBeforeInputForRichText(
|
||
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;
|
||
const inputText = inputType === 'insertText';
|
||
|
||
if (selection.isCaret()) {
|
||
applyTargetRange(selection, event);
|
||
}
|
||
if (isBadDoubleSpacePeriodReplacment(event, selection)) {
|
||
event.preventDefault();
|
||
insertText(selection, ' ');
|
||
return;
|
||
}
|
||
const anchorNode = selection.getAnchorNode();
|
||
const focusNode = selection.getAnchorNode();
|
||
|
||
if (
|
||
inputText ||
|
||
inputType === 'insertCompositionText' ||
|
||
inputType === 'deleteCompositionText'
|
||
) {
|
||
if (selection.isCaret() && isImmutableOrInertOrSegmented(anchorNode)) {
|
||
event.preventDefault();
|
||
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') {
|
||
event.preventDefault();
|
||
insertLineBreak(selection);
|
||
} else if (data === '\n\n') {
|
||
event.preventDefault();
|
||
insertParagraph(selection);
|
||
} else if (!selection.isCaret()) {
|
||
const anchorKey = selection.anchorKey;
|
||
const focusKey = selection.focusKey;
|
||
|
||
if (canRemoveText(anchorNode, focusNode)) {
|
||
removeText(selection);
|
||
}
|
||
if (inputText && anchorKey !== focusKey && data) {
|
||
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 'deleteHardLineForward':
|
||
case 'deleteSoftLineForward': {
|
||
deleteLineForward(selection);
|
||
break;
|
||
}
|
||
default:
|
||
// NO-OP
|
||
}
|
||
});
|
||
}
|
||
|
||
export function onPolyfilledBeforeInput(
|
||
event: SyntheticInputEvent<EventTarget>,
|
||
editor: OutlineEditor,
|
||
state: EventHandlerState,
|
||
): void {
|
||
event.preventDefault();
|
||
editor.update((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,
|
||
);
|
||
}
|
||
if (handleBlockTextInputOnNode(selection.getAnchorNode(), view, editor)) {
|
||
return;
|
||
}
|
||
insertText(selection, data);
|
||
}
|
||
});
|
||
}
|