323 lines
9.6 KiB
TypeScript
323 lines
9.6 KiB
TypeScript
|
/**
|
||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||
|
*
|
||
|
* This source code is licensed under the MIT license found in the
|
||
|
* LICENSE file in the root directory of this source tree.
|
||
|
*
|
||
|
*/
|
||
|
|
||
|
import type {TextNode} from '.';
|
||
|
import type {LexicalEditor} from './LexicalEditor';
|
||
|
import type {BaseSelection} from './LexicalSelection';
|
||
|
|
||
|
import {IS_FIREFOX} from 'lexical/shared/environment';
|
||
|
|
||
|
import {
|
||
|
$getSelection,
|
||
|
$isDecoratorNode,
|
||
|
$isElementNode,
|
||
|
$isRangeSelection,
|
||
|
$isTextNode,
|
||
|
$setSelection,
|
||
|
} from '.';
|
||
|
import {DOM_TEXT_TYPE} from './LexicalConstants';
|
||
|
import {updateEditor} from './LexicalUpdates';
|
||
|
import {
|
||
|
$getNearestNodeFromDOMNode,
|
||
|
$getNodeFromDOMNode,
|
||
|
$updateTextNodeFromDOMContent,
|
||
|
getDOMSelection,
|
||
|
getWindow,
|
||
|
internalGetRoot,
|
||
|
isFirefoxClipboardEvents,
|
||
|
} from './LexicalUtils';
|
||
|
// The time between a text entry event and the mutation observer firing.
|
||
|
const TEXT_MUTATION_VARIANCE = 100;
|
||
|
|
||
|
let isProcessingMutations = false;
|
||
|
let lastTextEntryTimeStamp = 0;
|
||
|
|
||
|
export function getIsProcessingMutations(): boolean {
|
||
|
return isProcessingMutations;
|
||
|
}
|
||
|
|
||
|
function updateTimeStamp(event: Event) {
|
||
|
lastTextEntryTimeStamp = event.timeStamp;
|
||
|
}
|
||
|
|
||
|
function initTextEntryListener(editor: LexicalEditor): void {
|
||
|
if (lastTextEntryTimeStamp === 0) {
|
||
|
getWindow(editor).addEventListener('textInput', updateTimeStamp, true);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function isManagedLineBreak(
|
||
|
dom: Node,
|
||
|
target: Node,
|
||
|
editor: LexicalEditor,
|
||
|
): boolean {
|
||
|
return (
|
||
|
// @ts-expect-error: internal field
|
||
|
target.__lexicalLineBreak === dom ||
|
||
|
// @ts-ignore We intentionally add this to the Node.
|
||
|
dom[`__lexicalKey_${editor._key}`] !== undefined
|
||
|
);
|
||
|
}
|
||
|
|
||
|
function getLastSelection(editor: LexicalEditor): null | BaseSelection {
|
||
|
return editor.getEditorState().read(() => {
|
||
|
const selection = $getSelection();
|
||
|
return selection !== null ? selection.clone() : null;
|
||
|
});
|
||
|
}
|
||
|
|
||
|
function $handleTextMutation(
|
||
|
target: Text,
|
||
|
node: TextNode,
|
||
|
editor: LexicalEditor,
|
||
|
): void {
|
||
|
const domSelection = getDOMSelection(editor._window);
|
||
|
let anchorOffset = null;
|
||
|
let focusOffset = null;
|
||
|
|
||
|
if (domSelection !== null && domSelection.anchorNode === target) {
|
||
|
anchorOffset = domSelection.anchorOffset;
|
||
|
focusOffset = domSelection.focusOffset;
|
||
|
}
|
||
|
|
||
|
const text = target.nodeValue;
|
||
|
if (text !== null) {
|
||
|
$updateTextNodeFromDOMContent(node, text, anchorOffset, focusOffset, false);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function shouldUpdateTextNodeFromMutation(
|
||
|
selection: null | BaseSelection,
|
||
|
targetDOM: Node,
|
||
|
targetNode: TextNode,
|
||
|
): boolean {
|
||
|
if ($isRangeSelection(selection)) {
|
||
|
const anchorNode = selection.anchor.getNode();
|
||
|
if (
|
||
|
anchorNode.is(targetNode) &&
|
||
|
selection.format !== anchorNode.getFormat()
|
||
|
) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached();
|
||
|
}
|
||
|
|
||
|
export function $flushMutations(
|
||
|
editor: LexicalEditor,
|
||
|
mutations: Array<MutationRecord>,
|
||
|
observer: MutationObserver,
|
||
|
): void {
|
||
|
isProcessingMutations = true;
|
||
|
const shouldFlushTextMutations =
|
||
|
performance.now() - lastTextEntryTimeStamp > TEXT_MUTATION_VARIANCE;
|
||
|
|
||
|
try {
|
||
|
updateEditor(editor, () => {
|
||
|
const selection = $getSelection() || getLastSelection(editor);
|
||
|
const badDOMTargets = new Map();
|
||
|
const rootElement = editor.getRootElement();
|
||
|
// We use the current editor state, as that reflects what is
|
||
|
// actually "on screen".
|
||
|
const currentEditorState = editor._editorState;
|
||
|
const blockCursorElement = editor._blockCursorElement;
|
||
|
let shouldRevertSelection = false;
|
||
|
let possibleTextForFirefoxPaste = '';
|
||
|
|
||
|
for (let i = 0; i < mutations.length; i++) {
|
||
|
const mutation = mutations[i];
|
||
|
const type = mutation.type;
|
||
|
const targetDOM = mutation.target;
|
||
|
let targetNode = $getNearestNodeFromDOMNode(
|
||
|
targetDOM,
|
||
|
currentEditorState,
|
||
|
);
|
||
|
|
||
|
if (
|
||
|
(targetNode === null && targetDOM !== rootElement) ||
|
||
|
$isDecoratorNode(targetNode)
|
||
|
) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (type === 'characterData') {
|
||
|
// Text mutations are deferred and passed to mutation listeners to be
|
||
|
// processed outside of the Lexical engine.
|
||
|
if (
|
||
|
shouldFlushTextMutations &&
|
||
|
$isTextNode(targetNode) &&
|
||
|
shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode)
|
||
|
) {
|
||
|
$handleTextMutation(
|
||
|
// nodeType === DOM_TEXT_TYPE is a Text DOM node
|
||
|
targetDOM as Text,
|
||
|
targetNode,
|
||
|
editor,
|
||
|
);
|
||
|
}
|
||
|
} else if (type === 'childList') {
|
||
|
shouldRevertSelection = true;
|
||
|
// We attempt to "undo" any changes that have occurred outside
|
||
|
// of Lexical. We want Lexical's editor state to be source of truth.
|
||
|
// To the user, these will look like no-ops.
|
||
|
const addedDOMs = mutation.addedNodes;
|
||
|
|
||
|
for (let s = 0; s < addedDOMs.length; s++) {
|
||
|
const addedDOM = addedDOMs[s];
|
||
|
const node = $getNodeFromDOMNode(addedDOM);
|
||
|
const parentDOM = addedDOM.parentNode;
|
||
|
|
||
|
if (
|
||
|
parentDOM != null &&
|
||
|
addedDOM !== blockCursorElement &&
|
||
|
node === null &&
|
||
|
(addedDOM.nodeName !== 'BR' ||
|
||
|
!isManagedLineBreak(addedDOM, parentDOM, editor))
|
||
|
) {
|
||
|
if (IS_FIREFOX) {
|
||
|
const possibleText =
|
||
|
(addedDOM as HTMLElement).innerText || addedDOM.nodeValue;
|
||
|
|
||
|
if (possibleText) {
|
||
|
possibleTextForFirefoxPaste += possibleText;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
parentDOM.removeChild(addedDOM);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const removedDOMs = mutation.removedNodes;
|
||
|
const removedDOMsLength = removedDOMs.length;
|
||
|
|
||
|
if (removedDOMsLength > 0) {
|
||
|
let unremovedBRs = 0;
|
||
|
|
||
|
for (let s = 0; s < removedDOMsLength; s++) {
|
||
|
const removedDOM = removedDOMs[s];
|
||
|
|
||
|
if (
|
||
|
(removedDOM.nodeName === 'BR' &&
|
||
|
isManagedLineBreak(removedDOM, targetDOM, editor)) ||
|
||
|
blockCursorElement === removedDOM
|
||
|
) {
|
||
|
targetDOM.appendChild(removedDOM);
|
||
|
unremovedBRs++;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (removedDOMsLength !== unremovedBRs) {
|
||
|
if (targetDOM === rootElement) {
|
||
|
targetNode = internalGetRoot(currentEditorState);
|
||
|
}
|
||
|
|
||
|
badDOMTargets.set(targetDOM, targetNode);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Now we process each of the unique target nodes, attempting
|
||
|
// to restore their contents back to the source of truth, which
|
||
|
// is Lexical's "current" editor state. This is basically like
|
||
|
// an internal revert on the DOM.
|
||
|
if (badDOMTargets.size > 0) {
|
||
|
for (const [targetDOM, targetNode] of badDOMTargets) {
|
||
|
if ($isElementNode(targetNode)) {
|
||
|
const childKeys = targetNode.getChildrenKeys();
|
||
|
let currentDOM = targetDOM.firstChild;
|
||
|
|
||
|
for (let s = 0; s < childKeys.length; s++) {
|
||
|
const key = childKeys[s];
|
||
|
const correctDOM = editor.getElementByKey(key);
|
||
|
|
||
|
if (correctDOM === null) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (currentDOM == null) {
|
||
|
targetDOM.appendChild(correctDOM);
|
||
|
currentDOM = correctDOM;
|
||
|
} else if (currentDOM !== correctDOM) {
|
||
|
targetDOM.replaceChild(correctDOM, currentDOM);
|
||
|
}
|
||
|
|
||
|
currentDOM = currentDOM.nextSibling;
|
||
|
}
|
||
|
} else if ($isTextNode(targetNode)) {
|
||
|
targetNode.markDirty();
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Capture all the mutations made during this function. This
|
||
|
// also prevents us having to process them on the next cycle
|
||
|
// of onMutation, as these mutations were made by us.
|
||
|
const records = observer.takeRecords();
|
||
|
|
||
|
// Check for any random auto-added <br> elements, and remove them.
|
||
|
// These get added by the browser when we undo the above mutations
|
||
|
// and this can lead to a broken UI.
|
||
|
if (records.length > 0) {
|
||
|
for (let i = 0; i < records.length; i++) {
|
||
|
const record = records[i];
|
||
|
const addedNodes = record.addedNodes;
|
||
|
const target = record.target;
|
||
|
|
||
|
for (let s = 0; s < addedNodes.length; s++) {
|
||
|
const addedDOM = addedNodes[s];
|
||
|
const parentDOM = addedDOM.parentNode;
|
||
|
|
||
|
if (
|
||
|
parentDOM != null &&
|
||
|
addedDOM.nodeName === 'BR' &&
|
||
|
!isManagedLineBreak(addedDOM, target, editor)
|
||
|
) {
|
||
|
parentDOM.removeChild(addedDOM);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Clear any of those removal mutations
|
||
|
observer.takeRecords();
|
||
|
}
|
||
|
|
||
|
if (selection !== null) {
|
||
|
if (shouldRevertSelection) {
|
||
|
selection.dirty = true;
|
||
|
$setSelection(selection);
|
||
|
}
|
||
|
|
||
|
if (IS_FIREFOX && isFirefoxClipboardEvents(editor)) {
|
||
|
selection.insertRawText(possibleTextForFirefoxPaste);
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
} finally {
|
||
|
isProcessingMutations = false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export function $flushRootMutations(editor: LexicalEditor): void {
|
||
|
const observer = editor._observer;
|
||
|
|
||
|
if (observer !== null) {
|
||
|
const mutations = observer.takeRecords();
|
||
|
$flushMutations(editor, mutations, observer);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export function initMutationObserver(editor: LexicalEditor): void {
|
||
|
initTextEntryListener(editor);
|
||
|
editor._observer = new MutationObserver(
|
||
|
(mutations: Array<MutationRecord>, observer: MutationObserver) => {
|
||
|
$flushMutations(editor, mutations, observer);
|
||
|
},
|
||
|
);
|
||
|
}
|