9683 lines
332 KiB
JavaScript
9683 lines
332 KiB
JavaScript
/**
|
||
* 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.
|
||
*/
|
||
'use strict';
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
|
||
function createCommand(type) {
|
||
return {
|
||
type
|
||
} ;
|
||
}
|
||
const SELECTION_CHANGE_COMMAND = createCommand('SELECTION_CHANGE_COMMAND');
|
||
const SELECTION_INSERT_CLIPBOARD_NODES_COMMAND = createCommand('SELECTION_INSERT_CLIPBOARD_NODES_COMMAND');
|
||
const CLICK_COMMAND = createCommand('CLICK_COMMAND');
|
||
const DELETE_CHARACTER_COMMAND = createCommand('DELETE_CHARACTER_COMMAND');
|
||
const INSERT_LINE_BREAK_COMMAND = createCommand('INSERT_LINE_BREAK_COMMAND');
|
||
const INSERT_PARAGRAPH_COMMAND = createCommand('INSERT_PARAGRAPH_COMMAND');
|
||
const CONTROLLED_TEXT_INSERTION_COMMAND = createCommand('CONTROLLED_TEXT_INSERTION_COMMAND');
|
||
const PASTE_COMMAND = createCommand('PASTE_COMMAND');
|
||
const REMOVE_TEXT_COMMAND = createCommand('REMOVE_TEXT_COMMAND');
|
||
const DELETE_WORD_COMMAND = createCommand('DELETE_WORD_COMMAND');
|
||
const DELETE_LINE_COMMAND = createCommand('DELETE_LINE_COMMAND');
|
||
const FORMAT_TEXT_COMMAND = createCommand('FORMAT_TEXT_COMMAND');
|
||
const UNDO_COMMAND = createCommand('UNDO_COMMAND');
|
||
const REDO_COMMAND = createCommand('REDO_COMMAND');
|
||
const KEY_DOWN_COMMAND = createCommand('KEYDOWN_COMMAND');
|
||
const KEY_ARROW_RIGHT_COMMAND = createCommand('KEY_ARROW_RIGHT_COMMAND');
|
||
const MOVE_TO_END = createCommand('MOVE_TO_END');
|
||
const KEY_ARROW_LEFT_COMMAND = createCommand('KEY_ARROW_LEFT_COMMAND');
|
||
const MOVE_TO_START = createCommand('MOVE_TO_START');
|
||
const KEY_ARROW_UP_COMMAND = createCommand('KEY_ARROW_UP_COMMAND');
|
||
const KEY_ARROW_DOWN_COMMAND = createCommand('KEY_ARROW_DOWN_COMMAND');
|
||
const KEY_ENTER_COMMAND = createCommand('KEY_ENTER_COMMAND');
|
||
const KEY_SPACE_COMMAND = createCommand('KEY_SPACE_COMMAND');
|
||
const KEY_BACKSPACE_COMMAND = createCommand('KEY_BACKSPACE_COMMAND');
|
||
const KEY_ESCAPE_COMMAND = createCommand('KEY_ESCAPE_COMMAND');
|
||
const KEY_DELETE_COMMAND = createCommand('KEY_DELETE_COMMAND');
|
||
const KEY_TAB_COMMAND = createCommand('KEY_TAB_COMMAND');
|
||
const INSERT_TAB_COMMAND = createCommand('INSERT_TAB_COMMAND');
|
||
const INDENT_CONTENT_COMMAND = createCommand('INDENT_CONTENT_COMMAND');
|
||
const OUTDENT_CONTENT_COMMAND = createCommand('OUTDENT_CONTENT_COMMAND');
|
||
const DROP_COMMAND = createCommand('DROP_COMMAND');
|
||
const FORMAT_ELEMENT_COMMAND = createCommand('FORMAT_ELEMENT_COMMAND');
|
||
const DRAGSTART_COMMAND = createCommand('DRAGSTART_COMMAND');
|
||
const DRAGOVER_COMMAND = createCommand('DRAGOVER_COMMAND');
|
||
const DRAGEND_COMMAND = createCommand('DRAGEND_COMMAND');
|
||
const COPY_COMMAND = createCommand('COPY_COMMAND');
|
||
const CUT_COMMAND = createCommand('CUT_COMMAND');
|
||
const SELECT_ALL_COMMAND = createCommand('SELECT_ALL_COMMAND');
|
||
const CLEAR_EDITOR_COMMAND = createCommand('CLEAR_EDITOR_COMMAND');
|
||
const CLEAR_HISTORY_COMMAND = createCommand('CLEAR_HISTORY_COMMAND');
|
||
const CAN_REDO_COMMAND = createCommand('CAN_REDO_COMMAND');
|
||
const CAN_UNDO_COMMAND = createCommand('CAN_UNDO_COMMAND');
|
||
const FOCUS_COMMAND = createCommand('FOCUS_COMMAND');
|
||
const BLUR_COMMAND = createCommand('BLUR_COMMAND');
|
||
const KEY_MODIFIER_COMMAND = createCommand('KEY_MODIFIER_COMMAND');
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
|
||
const CAN_USE_DOM = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined';
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
const documentMode = CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null;
|
||
const IS_APPLE = CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||
const IS_FIREFOX = CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent);
|
||
const CAN_USE_BEFORE_INPUT = CAN_USE_DOM && 'InputEvent' in window && !documentMode ? 'getTargetRanges' in new window.InputEvent('input') : false;
|
||
const IS_SAFARI = CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent);
|
||
const IS_IOS = CAN_USE_DOM && /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||
const IS_ANDROID = CAN_USE_DOM && /Android/.test(navigator.userAgent);
|
||
|
||
// Keep these in case we need to use them in the future.
|
||
// export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform);
|
||
const IS_CHROME = CAN_USE_DOM && /^(?=.*Chrome).*/i.test(navigator.userAgent);
|
||
// export const canUseTextInputEvent: boolean = CAN_USE_DOM && 'TextEvent' in window && !documentMode;
|
||
|
||
const IS_APPLE_WEBKIT = CAN_USE_DOM && /AppleWebKit\/[\d.]+/.test(navigator.userAgent) && !IS_CHROME;
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
|
||
// DOM
|
||
const DOM_ELEMENT_TYPE = 1;
|
||
const DOM_TEXT_TYPE = 3;
|
||
|
||
// Reconciling
|
||
const NO_DIRTY_NODES = 0;
|
||
const HAS_DIRTY_NODES = 1;
|
||
const FULL_RECONCILE = 2;
|
||
|
||
// Text node modes
|
||
const IS_NORMAL = 0;
|
||
const IS_TOKEN = 1;
|
||
const IS_SEGMENTED = 2;
|
||
// IS_INERT = 3
|
||
|
||
// Text node formatting
|
||
const IS_BOLD = 1;
|
||
const IS_ITALIC = 1 << 1;
|
||
const IS_STRIKETHROUGH = 1 << 2;
|
||
const IS_UNDERLINE = 1 << 3;
|
||
const IS_CODE = 1 << 4;
|
||
const IS_SUBSCRIPT = 1 << 5;
|
||
const IS_SUPERSCRIPT = 1 << 6;
|
||
const IS_HIGHLIGHT = 1 << 7;
|
||
const IS_ALL_FORMATTING = IS_BOLD | IS_ITALIC | IS_STRIKETHROUGH | IS_UNDERLINE | IS_CODE | IS_SUBSCRIPT | IS_SUPERSCRIPT | IS_HIGHLIGHT;
|
||
|
||
// Text node details
|
||
const IS_DIRECTIONLESS = 1;
|
||
const IS_UNMERGEABLE = 1 << 1;
|
||
|
||
// Element node formatting
|
||
const IS_ALIGN_LEFT = 1;
|
||
const IS_ALIGN_CENTER = 2;
|
||
const IS_ALIGN_RIGHT = 3;
|
||
const IS_ALIGN_JUSTIFY = 4;
|
||
const IS_ALIGN_START = 5;
|
||
const IS_ALIGN_END = 6;
|
||
|
||
// Reconciliation
|
||
const NON_BREAKING_SPACE = '\u00A0';
|
||
const ZERO_WIDTH_SPACE = '\u200b';
|
||
|
||
// For iOS/Safari we use a non breaking space, otherwise the cursor appears
|
||
// overlapping the composed text.
|
||
const COMPOSITION_SUFFIX = IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT ? NON_BREAKING_SPACE : ZERO_WIDTH_SPACE;
|
||
const DOUBLE_LINE_BREAK = '\n\n';
|
||
|
||
// For FF, we need to use a non-breaking space, or it gets composition
|
||
// in a stuck state.
|
||
const COMPOSITION_START_CHAR = IS_FIREFOX ? NON_BREAKING_SPACE : COMPOSITION_SUFFIX;
|
||
const RTL = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC';
|
||
const LTR = 'A-Za-z\u00C0-\u00D6\u00D8-\u00F6' + '\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C' + '\uFE00-\uFE6F\uFEFD-\uFFFF';
|
||
|
||
// eslint-disable-next-line no-misleading-character-class
|
||
const RTL_REGEX = new RegExp('^[^' + LTR + ']*[' + RTL + ']');
|
||
// eslint-disable-next-line no-misleading-character-class
|
||
const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']');
|
||
const TEXT_TYPE_TO_FORMAT = {
|
||
bold: IS_BOLD,
|
||
code: IS_CODE,
|
||
highlight: IS_HIGHLIGHT,
|
||
italic: IS_ITALIC,
|
||
strikethrough: IS_STRIKETHROUGH,
|
||
subscript: IS_SUBSCRIPT,
|
||
superscript: IS_SUPERSCRIPT,
|
||
underline: IS_UNDERLINE
|
||
};
|
||
const DETAIL_TYPE_TO_DETAIL = {
|
||
directionless: IS_DIRECTIONLESS,
|
||
unmergeable: IS_UNMERGEABLE
|
||
};
|
||
const ELEMENT_TYPE_TO_FORMAT = {
|
||
center: IS_ALIGN_CENTER,
|
||
end: IS_ALIGN_END,
|
||
justify: IS_ALIGN_JUSTIFY,
|
||
left: IS_ALIGN_LEFT,
|
||
right: IS_ALIGN_RIGHT,
|
||
start: IS_ALIGN_START
|
||
};
|
||
const ELEMENT_FORMAT_TO_TYPE = {
|
||
[IS_ALIGN_CENTER]: 'center',
|
||
[IS_ALIGN_END]: 'end',
|
||
[IS_ALIGN_JUSTIFY]: 'justify',
|
||
[IS_ALIGN_LEFT]: 'left',
|
||
[IS_ALIGN_RIGHT]: 'right',
|
||
[IS_ALIGN_START]: 'start'
|
||
};
|
||
const TEXT_MODE_TO_TYPE = {
|
||
normal: IS_NORMAL,
|
||
segmented: IS_SEGMENTED,
|
||
token: IS_TOKEN
|
||
};
|
||
const TEXT_TYPE_TO_MODE = {
|
||
[IS_NORMAL]: 'normal',
|
||
[IS_SEGMENTED]: 'segmented',
|
||
[IS_TOKEN]: 'token'
|
||
};
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
// The time between a text entry event and the mutation observer firing.
|
||
const TEXT_MUTATION_VARIANCE = 100;
|
||
let isProcessingMutations = false;
|
||
let lastTextEntryTimeStamp = 0;
|
||
function getIsProcessingMutations() {
|
||
return isProcessingMutations;
|
||
}
|
||
function updateTimeStamp(event) {
|
||
lastTextEntryTimeStamp = event.timeStamp;
|
||
}
|
||
function initTextEntryListener(editor) {
|
||
if (lastTextEntryTimeStamp === 0) {
|
||
getWindow(editor).addEventListener('textInput', updateTimeStamp, true);
|
||
}
|
||
}
|
||
function isManagedLineBreak(dom, target, editor) {
|
||
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) {
|
||
return editor.getEditorState().read(() => {
|
||
const selection = $getSelection();
|
||
return selection !== null ? selection.clone() : null;
|
||
});
|
||
}
|
||
function handleTextMutation(target, node, editor) {
|
||
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, targetDOM, targetNode) {
|
||
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();
|
||
}
|
||
function $flushMutations$1(editor, mutations, observer) {
|
||
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, 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.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;
|
||
}
|
||
}
|
||
function flushRootMutations(editor) {
|
||
const observer = editor._observer;
|
||
if (observer !== null) {
|
||
const mutations = observer.takeRecords();
|
||
$flushMutations$1(editor, mutations, observer);
|
||
}
|
||
}
|
||
function initMutationObserver(editor) {
|
||
initTextEntryListener(editor);
|
||
editor._observer = new MutationObserver((mutations, observer) => {
|
||
$flushMutations$1(editor, mutations, observer);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
function $canSimpleTextNodesBeMerged(node1, node2) {
|
||
const node1Mode = node1.__mode;
|
||
const node1Format = node1.__format;
|
||
const node1Style = node1.__style;
|
||
const node2Mode = node2.__mode;
|
||
const node2Format = node2.__format;
|
||
const node2Style = node2.__style;
|
||
return (node1Mode === null || node1Mode === node2Mode) && (node1Format === null || node1Format === node2Format) && (node1Style === null || node1Style === node2Style);
|
||
}
|
||
function $mergeTextNodes(node1, node2) {
|
||
const writableNode1 = node1.mergeWithSibling(node2);
|
||
const normalizedNodes = getActiveEditor()._normalizedNodes;
|
||
normalizedNodes.add(node1.__key);
|
||
normalizedNodes.add(node2.__key);
|
||
return writableNode1;
|
||
}
|
||
function $normalizeTextNode(textNode) {
|
||
let node = textNode;
|
||
if (node.__text === '' && node.isSimpleText() && !node.isUnmergeable()) {
|
||
node.remove();
|
||
return;
|
||
}
|
||
|
||
// Backward
|
||
let previousNode;
|
||
while ((previousNode = node.getPreviousSibling()) !== null && $isTextNode(previousNode) && previousNode.isSimpleText() && !previousNode.isUnmergeable()) {
|
||
if (previousNode.__text === '') {
|
||
previousNode.remove();
|
||
} else if ($canSimpleTextNodesBeMerged(previousNode, node)) {
|
||
node = $mergeTextNodes(previousNode, node);
|
||
break;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Forward
|
||
let nextNode;
|
||
while ((nextNode = node.getNextSibling()) !== null && $isTextNode(nextNode) && nextNode.isSimpleText() && !nextNode.isUnmergeable()) {
|
||
if (nextNode.__text === '') {
|
||
nextNode.remove();
|
||
} else if ($canSimpleTextNodesBeMerged(node, nextNode)) {
|
||
node = $mergeTextNodes(node, nextNode);
|
||
break;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
function $normalizeSelection(selection) {
|
||
$normalizePoint(selection.anchor);
|
||
$normalizePoint(selection.focus);
|
||
return selection;
|
||
}
|
||
function $normalizePoint(point) {
|
||
while (point.type === 'element') {
|
||
const node = point.getNode();
|
||
const offset = point.offset;
|
||
let nextNode;
|
||
let nextOffsetAtEnd;
|
||
if (offset === node.getChildrenSize()) {
|
||
nextNode = node.getChildAtIndex(offset - 1);
|
||
nextOffsetAtEnd = true;
|
||
} else {
|
||
nextNode = node.getChildAtIndex(offset);
|
||
nextOffsetAtEnd = false;
|
||
}
|
||
if ($isTextNode(nextNode)) {
|
||
point.set(nextNode.__key, nextOffsetAtEnd ? nextNode.getTextContentSize() : 0, 'text');
|
||
break;
|
||
} else if (!$isElementNode(nextNode)) {
|
||
break;
|
||
}
|
||
point.set(nextNode.__key, nextOffsetAtEnd ? nextNode.getChildrenSize() : 0, 'element');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
let keyCounter = 1;
|
||
function generateRandomKey() {
|
||
return '' + keyCounter++;
|
||
}
|
||
function getRegisteredNodeOrThrow(editor, nodeType) {
|
||
const registeredNode = editor._nodes.get(nodeType);
|
||
if (registeredNode === undefined) {
|
||
{
|
||
throw Error(`registeredNode: Type ${nodeType} not found`);
|
||
}
|
||
}
|
||
return registeredNode;
|
||
}
|
||
const scheduleMicroTask = typeof queueMicrotask === 'function' ? queueMicrotask : fn => {
|
||
// No window prefix intended (#1400)
|
||
Promise.resolve().then(fn);
|
||
};
|
||
function $isSelectionCapturedInDecorator(node) {
|
||
return $isDecoratorNode($getNearestNodeFromDOMNode(node));
|
||
}
|
||
function isSelectionCapturedInDecoratorInput(anchorDOM) {
|
||
const activeElement = document.activeElement;
|
||
if (activeElement === null) {
|
||
return false;
|
||
}
|
||
const nodeName = activeElement.nodeName;
|
||
return $isDecoratorNode($getNearestNodeFromDOMNode(anchorDOM)) && (nodeName === 'INPUT' || nodeName === 'TEXTAREA' || activeElement.contentEditable === 'true' &&
|
||
// @ts-ignore iternal field
|
||
activeElement.__lexicalEditor == null);
|
||
}
|
||
function isSelectionWithinEditor(editor, anchorDOM, focusDOM) {
|
||
const rootElement = editor.getRootElement();
|
||
try {
|
||
return rootElement !== null && rootElement.contains(anchorDOM) && rootElement.contains(focusDOM) &&
|
||
// Ignore if selection is within nested editor
|
||
anchorDOM !== null && !isSelectionCapturedInDecoratorInput(anchorDOM) && getNearestEditorFromDOMNode(anchorDOM) === editor;
|
||
} catch (error) {
|
||
return false;
|
||
}
|
||
}
|
||
function getNearestEditorFromDOMNode(node) {
|
||
let currentNode = node;
|
||
while (currentNode != null) {
|
||
// @ts-expect-error: internal field
|
||
const editor = currentNode.__lexicalEditor;
|
||
if (editor != null) {
|
||
return editor;
|
||
}
|
||
currentNode = getParentElement(currentNode);
|
||
}
|
||
return null;
|
||
}
|
||
function getTextDirection(text) {
|
||
if (RTL_REGEX.test(text)) {
|
||
return 'rtl';
|
||
}
|
||
if (LTR_REGEX.test(text)) {
|
||
return 'ltr';
|
||
}
|
||
return null;
|
||
}
|
||
function $isTokenOrSegmented(node) {
|
||
return node.isToken() || node.isSegmented();
|
||
}
|
||
function isDOMNodeLexicalTextNode(node) {
|
||
return node.nodeType === DOM_TEXT_TYPE;
|
||
}
|
||
function getDOMTextNode(element) {
|
||
let node = element;
|
||
while (node != null) {
|
||
if (isDOMNodeLexicalTextNode(node)) {
|
||
return node;
|
||
}
|
||
node = node.firstChild;
|
||
}
|
||
return null;
|
||
}
|
||
function toggleTextFormatType(format, type, alignWithFormat) {
|
||
const activeFormat = TEXT_TYPE_TO_FORMAT[type];
|
||
if (alignWithFormat !== null && (format & activeFormat) === (alignWithFormat & activeFormat)) {
|
||
return format;
|
||
}
|
||
let newFormat = format ^ activeFormat;
|
||
if (type === 'subscript') {
|
||
newFormat &= ~TEXT_TYPE_TO_FORMAT.superscript;
|
||
} else if (type === 'superscript') {
|
||
newFormat &= ~TEXT_TYPE_TO_FORMAT.subscript;
|
||
}
|
||
return newFormat;
|
||
}
|
||
function $isLeafNode(node) {
|
||
return $isTextNode(node) || $isLineBreakNode(node) || $isDecoratorNode(node);
|
||
}
|
||
function $setNodeKey(node, existingKey) {
|
||
if (existingKey != null) {
|
||
node.__key = existingKey;
|
||
return;
|
||
}
|
||
errorOnReadOnly();
|
||
errorOnInfiniteTransforms();
|
||
const editor = getActiveEditor();
|
||
const editorState = getActiveEditorState();
|
||
const key = generateRandomKey();
|
||
editorState._nodeMap.set(key, node);
|
||
// TODO Split this function into leaf/element
|
||
if ($isElementNode(node)) {
|
||
editor._dirtyElements.set(key, true);
|
||
} else {
|
||
editor._dirtyLeaves.add(key);
|
||
}
|
||
editor._cloneNotNeeded.add(key);
|
||
editor._dirtyType = HAS_DIRTY_NODES;
|
||
node.__key = key;
|
||
}
|
||
function internalMarkParentElementsAsDirty(parentKey, nodeMap, dirtyElements) {
|
||
let nextParentKey = parentKey;
|
||
while (nextParentKey !== null) {
|
||
if (dirtyElements.has(nextParentKey)) {
|
||
return;
|
||
}
|
||
const node = nodeMap.get(nextParentKey);
|
||
if (node === undefined) {
|
||
break;
|
||
}
|
||
dirtyElements.set(nextParentKey, false);
|
||
nextParentKey = node.__parent;
|
||
}
|
||
}
|
||
function removeFromParent(node) {
|
||
const oldParent = node.getParent();
|
||
if (oldParent !== null) {
|
||
const writableNode = node.getWritable();
|
||
const writableParent = oldParent.getWritable();
|
||
const prevSibling = node.getPreviousSibling();
|
||
const nextSibling = node.getNextSibling();
|
||
// TODO: this function duplicates a bunch of operations, can be simplified.
|
||
if (prevSibling === null) {
|
||
if (nextSibling !== null) {
|
||
const writableNextSibling = nextSibling.getWritable();
|
||
writableParent.__first = nextSibling.__key;
|
||
writableNextSibling.__prev = null;
|
||
} else {
|
||
writableParent.__first = null;
|
||
}
|
||
} else {
|
||
const writablePrevSibling = prevSibling.getWritable();
|
||
if (nextSibling !== null) {
|
||
const writableNextSibling = nextSibling.getWritable();
|
||
writableNextSibling.__prev = writablePrevSibling.__key;
|
||
writablePrevSibling.__next = writableNextSibling.__key;
|
||
} else {
|
||
writablePrevSibling.__next = null;
|
||
}
|
||
writableNode.__prev = null;
|
||
}
|
||
if (nextSibling === null) {
|
||
if (prevSibling !== null) {
|
||
const writablePrevSibling = prevSibling.getWritable();
|
||
writableParent.__last = prevSibling.__key;
|
||
writablePrevSibling.__next = null;
|
||
} else {
|
||
writableParent.__last = null;
|
||
}
|
||
} else {
|
||
const writableNextSibling = nextSibling.getWritable();
|
||
if (prevSibling !== null) {
|
||
const writablePrevSibling = prevSibling.getWritable();
|
||
writablePrevSibling.__next = writableNextSibling.__key;
|
||
writableNextSibling.__prev = writablePrevSibling.__key;
|
||
} else {
|
||
writableNextSibling.__prev = null;
|
||
}
|
||
writableNode.__next = null;
|
||
}
|
||
writableParent.__size--;
|
||
writableNode.__parent = null;
|
||
}
|
||
}
|
||
|
||
// Never use this function directly! It will break
|
||
// the cloning heuristic. Instead use node.getWritable().
|
||
function internalMarkNodeAsDirty(node) {
|
||
errorOnInfiniteTransforms();
|
||
const latest = node.getLatest();
|
||
const parent = latest.__parent;
|
||
const editorState = getActiveEditorState();
|
||
const editor = getActiveEditor();
|
||
const nodeMap = editorState._nodeMap;
|
||
const dirtyElements = editor._dirtyElements;
|
||
if (parent !== null) {
|
||
internalMarkParentElementsAsDirty(parent, nodeMap, dirtyElements);
|
||
}
|
||
const key = latest.__key;
|
||
editor._dirtyType = HAS_DIRTY_NODES;
|
||
if ($isElementNode(node)) {
|
||
dirtyElements.set(key, true);
|
||
} else {
|
||
// TODO split internally MarkNodeAsDirty into two dedicated Element/leave functions
|
||
editor._dirtyLeaves.add(key);
|
||
}
|
||
}
|
||
function internalMarkSiblingsAsDirty(node) {
|
||
const previousNode = node.getPreviousSibling();
|
||
const nextNode = node.getNextSibling();
|
||
if (previousNode !== null) {
|
||
internalMarkNodeAsDirty(previousNode);
|
||
}
|
||
if (nextNode !== null) {
|
||
internalMarkNodeAsDirty(nextNode);
|
||
}
|
||
}
|
||
function $setCompositionKey(compositionKey) {
|
||
errorOnReadOnly();
|
||
const editor = getActiveEditor();
|
||
const previousCompositionKey = editor._compositionKey;
|
||
if (compositionKey !== previousCompositionKey) {
|
||
editor._compositionKey = compositionKey;
|
||
if (previousCompositionKey !== null) {
|
||
const node = $getNodeByKey(previousCompositionKey);
|
||
if (node !== null) {
|
||
node.getWritable();
|
||
}
|
||
}
|
||
if (compositionKey !== null) {
|
||
const node = $getNodeByKey(compositionKey);
|
||
if (node !== null) {
|
||
node.getWritable();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function $getCompositionKey() {
|
||
if (isCurrentlyReadOnlyMode()) {
|
||
return null;
|
||
}
|
||
const editor = getActiveEditor();
|
||
return editor._compositionKey;
|
||
}
|
||
function $getNodeByKey(key, _editorState) {
|
||
const editorState = _editorState || getActiveEditorState();
|
||
const node = editorState._nodeMap.get(key);
|
||
if (node === undefined) {
|
||
return null;
|
||
}
|
||
return node;
|
||
}
|
||
function getNodeFromDOMNode(dom, editorState) {
|
||
const editor = getActiveEditor();
|
||
// @ts-ignore We intentionally add this to the Node.
|
||
const key = dom[`__lexicalKey_${editor._key}`];
|
||
if (key !== undefined) {
|
||
return $getNodeByKey(key, editorState);
|
||
}
|
||
return null;
|
||
}
|
||
function $getNearestNodeFromDOMNode(startingDOM, editorState) {
|
||
let dom = startingDOM;
|
||
while (dom != null) {
|
||
const node = getNodeFromDOMNode(dom, editorState);
|
||
if (node !== null) {
|
||
return node;
|
||
}
|
||
dom = getParentElement(dom);
|
||
}
|
||
return null;
|
||
}
|
||
function cloneDecorators(editor) {
|
||
const currentDecorators = editor._decorators;
|
||
const pendingDecorators = Object.assign({}, currentDecorators);
|
||
editor._pendingDecorators = pendingDecorators;
|
||
return pendingDecorators;
|
||
}
|
||
function getEditorStateTextContent(editorState) {
|
||
return editorState.read(() => $getRoot().getTextContent());
|
||
}
|
||
function markAllNodesAsDirty(editor, type) {
|
||
// Mark all existing text nodes as dirty
|
||
updateEditor(editor, () => {
|
||
const editorState = getActiveEditorState();
|
||
if (editorState.isEmpty()) {
|
||
return;
|
||
}
|
||
if (type === 'root') {
|
||
$getRoot().markDirty();
|
||
return;
|
||
}
|
||
const nodeMap = editorState._nodeMap;
|
||
for (const [, node] of nodeMap) {
|
||
node.markDirty();
|
||
}
|
||
}, editor._pendingEditorState === null ? {
|
||
tag: 'history-merge'
|
||
} : undefined);
|
||
}
|
||
function $getRoot() {
|
||
return internalGetRoot(getActiveEditorState());
|
||
}
|
||
function internalGetRoot(editorState) {
|
||
return editorState._nodeMap.get('root');
|
||
}
|
||
function $setSelection(selection) {
|
||
errorOnReadOnly();
|
||
const editorState = getActiveEditorState();
|
||
if (selection !== null) {
|
||
{
|
||
if (Object.isFrozen(selection)) {
|
||
{
|
||
throw Error(`$setSelection called on frozen selection object. Ensure selection is cloned before passing in.`);
|
||
}
|
||
}
|
||
}
|
||
selection.dirty = true;
|
||
selection.setCachedNodes(null);
|
||
}
|
||
editorState._selection = selection;
|
||
}
|
||
function $flushMutations() {
|
||
errorOnReadOnly();
|
||
const editor = getActiveEditor();
|
||
flushRootMutations(editor);
|
||
}
|
||
function getNodeFromDOM(dom) {
|
||
const editor = getActiveEditor();
|
||
const nodeKey = getNodeKeyFromDOM(dom, editor);
|
||
if (nodeKey === null) {
|
||
const rootElement = editor.getRootElement();
|
||
if (dom === rootElement) {
|
||
return $getNodeByKey('root');
|
||
}
|
||
return null;
|
||
}
|
||
return $getNodeByKey(nodeKey);
|
||
}
|
||
function getTextNodeOffset(node, moveSelectionToEnd) {
|
||
return moveSelectionToEnd ? node.getTextContentSize() : 0;
|
||
}
|
||
function getNodeKeyFromDOM(
|
||
// Note that node here refers to a DOM Node, not an Lexical Node
|
||
dom, editor) {
|
||
let node = dom;
|
||
while (node != null) {
|
||
// @ts-ignore We intentionally add this to the Node.
|
||
const key = node[`__lexicalKey_${editor._key}`];
|
||
if (key !== undefined) {
|
||
return key;
|
||
}
|
||
node = getParentElement(node);
|
||
}
|
||
return null;
|
||
}
|
||
function doesContainGrapheme(str) {
|
||
return /[\uD800-\uDBFF][\uDC00-\uDFFF]/g.test(str);
|
||
}
|
||
function getEditorsToPropagate(editor) {
|
||
const editorsToPropagate = [];
|
||
let currentEditor = editor;
|
||
while (currentEditor !== null) {
|
||
editorsToPropagate.push(currentEditor);
|
||
currentEditor = currentEditor._parentEditor;
|
||
}
|
||
return editorsToPropagate;
|
||
}
|
||
function createUID() {
|
||
return Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 5);
|
||
}
|
||
function getAnchorTextFromDOM(anchorNode) {
|
||
if (anchorNode.nodeType === DOM_TEXT_TYPE) {
|
||
return anchorNode.nodeValue;
|
||
}
|
||
return null;
|
||
}
|
||
function $updateSelectedTextFromDOM(isCompositionEnd, editor, data) {
|
||
// Update the text content with the latest composition text
|
||
const domSelection = getDOMSelection(editor._window);
|
||
if (domSelection === null) {
|
||
return;
|
||
}
|
||
const anchorNode = domSelection.anchorNode;
|
||
let {
|
||
anchorOffset,
|
||
focusOffset
|
||
} = domSelection;
|
||
if (anchorNode !== null) {
|
||
let textContent = getAnchorTextFromDOM(anchorNode);
|
||
const node = $getNearestNodeFromDOMNode(anchorNode);
|
||
if (textContent !== null && $isTextNode(node)) {
|
||
// Data is intentionally truthy, as we check for boolean, null and empty string.
|
||
if (textContent === COMPOSITION_SUFFIX && data) {
|
||
const offset = data.length;
|
||
textContent = data;
|
||
anchorOffset = offset;
|
||
focusOffset = offset;
|
||
}
|
||
if (textContent !== null) {
|
||
$updateTextNodeFromDOMContent(node, textContent, anchorOffset, focusOffset, isCompositionEnd);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function $updateTextNodeFromDOMContent(textNode, textContent, anchorOffset, focusOffset, compositionEnd) {
|
||
let node = textNode;
|
||
if (node.isAttached() && (compositionEnd || !node.isDirty())) {
|
||
const isComposing = node.isComposing();
|
||
let normalizedTextContent = textContent;
|
||
if ((isComposing || compositionEnd) && textContent[textContent.length - 1] === COMPOSITION_SUFFIX) {
|
||
normalizedTextContent = textContent.slice(0, -1);
|
||
}
|
||
const prevTextContent = node.getTextContent();
|
||
if (compositionEnd || normalizedTextContent !== prevTextContent) {
|
||
if (normalizedTextContent === '') {
|
||
$setCompositionKey(null);
|
||
if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT) {
|
||
// For composition (mainly Android), we have to remove the node on a later update
|
||
const editor = getActiveEditor();
|
||
setTimeout(() => {
|
||
editor.update(() => {
|
||
if (node.isAttached()) {
|
||
node.remove();
|
||
}
|
||
});
|
||
}, 20);
|
||
} else {
|
||
node.remove();
|
||
}
|
||
return;
|
||
}
|
||
const parent = node.getParent();
|
||
const prevSelection = $getPreviousSelection();
|
||
const prevTextContentSize = node.getTextContentSize();
|
||
const compositionKey = $getCompositionKey();
|
||
const nodeKey = node.getKey();
|
||
if (node.isToken() || compositionKey !== null && nodeKey === compositionKey && !isComposing ||
|
||
// Check if character was added at the start or boundaries when not insertable, and we need
|
||
// to clear this input from occurring as that action wasn't permitted.
|
||
$isRangeSelection(prevSelection) && (parent !== null && !parent.canInsertTextBefore() && prevSelection.anchor.offset === 0 || prevSelection.anchor.key === textNode.__key && prevSelection.anchor.offset === 0 && !node.canInsertTextBefore() && !isComposing || prevSelection.focus.key === textNode.__key && prevSelection.focus.offset === prevTextContentSize && !node.canInsertTextAfter() && !isComposing)) {
|
||
node.markDirty();
|
||
return;
|
||
}
|
||
const selection = $getSelection();
|
||
if (!$isRangeSelection(selection) || anchorOffset === null || focusOffset === null) {
|
||
node.setTextContent(normalizedTextContent);
|
||
return;
|
||
}
|
||
selection.setTextNodeRange(node, anchorOffset, node, focusOffset);
|
||
if (node.isSegmented()) {
|
||
const originalTextContent = node.getTextContent();
|
||
const replacement = $createTextNode(originalTextContent);
|
||
node.replace(replacement);
|
||
node = replacement;
|
||
}
|
||
node.setTextContent(normalizedTextContent);
|
||
}
|
||
}
|
||
}
|
||
function $previousSiblingDoesNotAcceptText(node) {
|
||
const previousSibling = node.getPreviousSibling();
|
||
return ($isTextNode(previousSibling) || $isElementNode(previousSibling) && previousSibling.isInline()) && !previousSibling.canInsertTextAfter();
|
||
}
|
||
|
||
// This function is connected to $shouldPreventDefaultAndInsertText and determines whether the
|
||
// TextNode boundaries are writable or we should use the previous/next sibling instead. For example,
|
||
// in the case of a LinkNode, boundaries are not writable.
|
||
function $shouldInsertTextAfterOrBeforeTextNode(selection, node) {
|
||
if (node.isSegmented()) {
|
||
return true;
|
||
}
|
||
if (!selection.isCollapsed()) {
|
||
return false;
|
||
}
|
||
const offset = selection.anchor.offset;
|
||
const parent = node.getParentOrThrow();
|
||
const isToken = node.isToken();
|
||
if (offset === 0) {
|
||
return !node.canInsertTextBefore() || !parent.canInsertTextBefore() || isToken || $previousSiblingDoesNotAcceptText(node);
|
||
} else if (offset === node.getTextContentSize()) {
|
||
return !node.canInsertTextAfter() || !parent.canInsertTextAfter() || isToken;
|
||
} else {
|
||
return false;
|
||
}
|
||
}
|
||
function isTab(keyCode, altKey, ctrlKey, metaKey) {
|
||
return keyCode === 9 && !altKey && !ctrlKey && !metaKey;
|
||
}
|
||
function isBold(keyCode, altKey, metaKey, ctrlKey) {
|
||
return keyCode === 66 && !altKey && controlOrMeta(metaKey, ctrlKey);
|
||
}
|
||
function isItalic(keyCode, altKey, metaKey, ctrlKey) {
|
||
return keyCode === 73 && !altKey && controlOrMeta(metaKey, ctrlKey);
|
||
}
|
||
function isUnderline(keyCode, altKey, metaKey, ctrlKey) {
|
||
return keyCode === 85 && !altKey && controlOrMeta(metaKey, ctrlKey);
|
||
}
|
||
function isParagraph(keyCode, shiftKey) {
|
||
return isReturn(keyCode) && !shiftKey;
|
||
}
|
||
function isLineBreak(keyCode, shiftKey) {
|
||
return isReturn(keyCode) && shiftKey;
|
||
}
|
||
|
||
// Inserts a new line after the selection
|
||
|
||
function isOpenLineBreak(keyCode, ctrlKey) {
|
||
// 79 = KeyO
|
||
return IS_APPLE && ctrlKey && keyCode === 79;
|
||
}
|
||
function isDeleteWordBackward(keyCode, altKey, ctrlKey) {
|
||
return isBackspace(keyCode) && (IS_APPLE ? altKey : ctrlKey);
|
||
}
|
||
function isDeleteWordForward(keyCode, altKey, ctrlKey) {
|
||
return isDelete(keyCode) && (IS_APPLE ? altKey : ctrlKey);
|
||
}
|
||
function isDeleteLineBackward(keyCode, metaKey) {
|
||
return IS_APPLE && metaKey && isBackspace(keyCode);
|
||
}
|
||
function isDeleteLineForward(keyCode, metaKey) {
|
||
return IS_APPLE && metaKey && isDelete(keyCode);
|
||
}
|
||
function isDeleteBackward(keyCode, altKey, metaKey, ctrlKey) {
|
||
if (IS_APPLE) {
|
||
if (altKey || metaKey) {
|
||
return false;
|
||
}
|
||
return isBackspace(keyCode) || keyCode === 72 && ctrlKey;
|
||
}
|
||
if (ctrlKey || altKey || metaKey) {
|
||
return false;
|
||
}
|
||
return isBackspace(keyCode);
|
||
}
|
||
function isDeleteForward(keyCode, ctrlKey, shiftKey, altKey, metaKey) {
|
||
if (IS_APPLE) {
|
||
if (shiftKey || altKey || metaKey) {
|
||
return false;
|
||
}
|
||
return isDelete(keyCode) || keyCode === 68 && ctrlKey;
|
||
}
|
||
if (ctrlKey || altKey || metaKey) {
|
||
return false;
|
||
}
|
||
return isDelete(keyCode);
|
||
}
|
||
function isUndo(keyCode, shiftKey, metaKey, ctrlKey) {
|
||
return keyCode === 90 && !shiftKey && controlOrMeta(metaKey, ctrlKey);
|
||
}
|
||
function isRedo(keyCode, shiftKey, metaKey, ctrlKey) {
|
||
if (IS_APPLE) {
|
||
return keyCode === 90 && metaKey && shiftKey;
|
||
}
|
||
return keyCode === 89 && ctrlKey || keyCode === 90 && ctrlKey && shiftKey;
|
||
}
|
||
function isCopy(keyCode, shiftKey, metaKey, ctrlKey) {
|
||
if (shiftKey) {
|
||
return false;
|
||
}
|
||
if (keyCode === 67) {
|
||
return IS_APPLE ? metaKey : ctrlKey;
|
||
}
|
||
return false;
|
||
}
|
||
function isCut(keyCode, shiftKey, metaKey, ctrlKey) {
|
||
if (shiftKey) {
|
||
return false;
|
||
}
|
||
if (keyCode === 88) {
|
||
return IS_APPLE ? metaKey : ctrlKey;
|
||
}
|
||
return false;
|
||
}
|
||
function isArrowLeft(keyCode) {
|
||
return keyCode === 37;
|
||
}
|
||
function isArrowRight(keyCode) {
|
||
return keyCode === 39;
|
||
}
|
||
function isArrowUp(keyCode) {
|
||
return keyCode === 38;
|
||
}
|
||
function isArrowDown(keyCode) {
|
||
return keyCode === 40;
|
||
}
|
||
function isMoveBackward(keyCode, ctrlKey, altKey, metaKey) {
|
||
return isArrowLeft(keyCode) && !ctrlKey && !metaKey && !altKey;
|
||
}
|
||
function isMoveToStart(keyCode, ctrlKey, shiftKey, altKey, metaKey) {
|
||
return isArrowLeft(keyCode) && !altKey && !shiftKey && (ctrlKey || metaKey);
|
||
}
|
||
function isMoveForward(keyCode, ctrlKey, altKey, metaKey) {
|
||
return isArrowRight(keyCode) && !ctrlKey && !metaKey && !altKey;
|
||
}
|
||
function isMoveToEnd(keyCode, ctrlKey, shiftKey, altKey, metaKey) {
|
||
return isArrowRight(keyCode) && !altKey && !shiftKey && (ctrlKey || metaKey);
|
||
}
|
||
function isMoveUp(keyCode, ctrlKey, metaKey) {
|
||
return isArrowUp(keyCode) && !ctrlKey && !metaKey;
|
||
}
|
||
function isMoveDown(keyCode, ctrlKey, metaKey) {
|
||
return isArrowDown(keyCode) && !ctrlKey && !metaKey;
|
||
}
|
||
function isModifier(ctrlKey, shiftKey, altKey, metaKey) {
|
||
return ctrlKey || shiftKey || altKey || metaKey;
|
||
}
|
||
function isSpace(keyCode) {
|
||
return keyCode === 32;
|
||
}
|
||
function controlOrMeta(metaKey, ctrlKey) {
|
||
if (IS_APPLE) {
|
||
return metaKey;
|
||
}
|
||
return ctrlKey;
|
||
}
|
||
function isReturn(keyCode) {
|
||
return keyCode === 13;
|
||
}
|
||
function isBackspace(keyCode) {
|
||
return keyCode === 8;
|
||
}
|
||
function isEscape(keyCode) {
|
||
return keyCode === 27;
|
||
}
|
||
function isDelete(keyCode) {
|
||
return keyCode === 46;
|
||
}
|
||
function isSelectAll(keyCode, metaKey, ctrlKey) {
|
||
return keyCode === 65 && controlOrMeta(metaKey, ctrlKey);
|
||
}
|
||
function $selectAll() {
|
||
const root = $getRoot();
|
||
const selection = root.select(0, root.getChildrenSize());
|
||
$setSelection($normalizeSelection(selection));
|
||
}
|
||
function getCachedClassNameArray(classNamesTheme, classNameThemeType) {
|
||
if (classNamesTheme.__lexicalClassNameCache === undefined) {
|
||
classNamesTheme.__lexicalClassNameCache = {};
|
||
}
|
||
const classNamesCache = classNamesTheme.__lexicalClassNameCache;
|
||
const cachedClassNames = classNamesCache[classNameThemeType];
|
||
if (cachedClassNames !== undefined) {
|
||
return cachedClassNames;
|
||
}
|
||
const classNames = classNamesTheme[classNameThemeType];
|
||
// As we're using classList, we need
|
||
// to handle className tokens that have spaces.
|
||
// The easiest way to do this to convert the
|
||
// className tokens to an array that can be
|
||
// applied to classList.add()/remove().
|
||
if (typeof classNames === 'string') {
|
||
const classNamesArr = classNames.split(' ');
|
||
classNamesCache[classNameThemeType] = classNamesArr;
|
||
return classNamesArr;
|
||
}
|
||
return classNames;
|
||
}
|
||
function setMutatedNode(mutatedNodes, registeredNodes, mutationListeners, node, mutation) {
|
||
if (mutationListeners.size === 0) {
|
||
return;
|
||
}
|
||
const nodeType = node.__type;
|
||
const nodeKey = node.__key;
|
||
const registeredNode = registeredNodes.get(nodeType);
|
||
if (registeredNode === undefined) {
|
||
{
|
||
throw Error(`Type ${nodeType} not in registeredNodes`);
|
||
}
|
||
}
|
||
const klass = registeredNode.klass;
|
||
let mutatedNodesByType = mutatedNodes.get(klass);
|
||
if (mutatedNodesByType === undefined) {
|
||
mutatedNodesByType = new Map();
|
||
mutatedNodes.set(klass, mutatedNodesByType);
|
||
}
|
||
const prevMutation = mutatedNodesByType.get(nodeKey);
|
||
// If the node has already been "destroyed", yet we are
|
||
// re-making it, then this means a move likely happened.
|
||
// We should change the mutation to be that of "updated"
|
||
// instead.
|
||
const isMove = prevMutation === 'destroyed' && mutation === 'created';
|
||
if (prevMutation === undefined || isMove) {
|
||
mutatedNodesByType.set(nodeKey, isMove ? 'updated' : mutation);
|
||
}
|
||
}
|
||
function $nodesOfType(klass) {
|
||
const editorState = getActiveEditorState();
|
||
const readOnly = editorState._readOnly;
|
||
const klassType = klass.getType();
|
||
const nodes = editorState._nodeMap;
|
||
const nodesOfType = [];
|
||
for (const [, node] of nodes) {
|
||
if (node instanceof klass && node.__type === klassType && (readOnly || node.isAttached())) {
|
||
nodesOfType.push(node);
|
||
}
|
||
}
|
||
return nodesOfType;
|
||
}
|
||
function resolveElement(element, isBackward, focusOffset) {
|
||
const parent = element.getParent();
|
||
let offset = focusOffset;
|
||
let block = element;
|
||
if (parent !== null) {
|
||
if (isBackward && focusOffset === 0) {
|
||
offset = block.getIndexWithinParent();
|
||
block = parent;
|
||
} else if (!isBackward && focusOffset === block.getChildrenSize()) {
|
||
offset = block.getIndexWithinParent() + 1;
|
||
block = parent;
|
||
}
|
||
}
|
||
return block.getChildAtIndex(isBackward ? offset - 1 : offset);
|
||
}
|
||
function $getAdjacentNode(focus, isBackward) {
|
||
const focusOffset = focus.offset;
|
||
if (focus.type === 'element') {
|
||
const block = focus.getNode();
|
||
return resolveElement(block, isBackward, focusOffset);
|
||
} else {
|
||
const focusNode = focus.getNode();
|
||
if (isBackward && focusOffset === 0 || !isBackward && focusOffset === focusNode.getTextContentSize()) {
|
||
const possibleNode = isBackward ? focusNode.getPreviousSibling() : focusNode.getNextSibling();
|
||
if (possibleNode === null) {
|
||
return resolveElement(focusNode.getParentOrThrow(), isBackward, focusNode.getIndexWithinParent() + (isBackward ? 0 : 1));
|
||
}
|
||
return possibleNode;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function isFirefoxClipboardEvents(editor) {
|
||
const event = getWindow(editor).event;
|
||
const inputType = event && event.inputType;
|
||
return inputType === 'insertFromPaste' || inputType === 'insertFromPasteAsQuotation';
|
||
}
|
||
function dispatchCommand(editor, command, payload) {
|
||
return triggerCommandListeners(editor, command, payload);
|
||
}
|
||
function $textContentRequiresDoubleLinebreakAtEnd(node) {
|
||
return !$isRootNode(node) && !node.isLastChild() && !node.isInline();
|
||
}
|
||
function getElementByKeyOrThrow(editor, key) {
|
||
const element = editor._keyToDOMMap.get(key);
|
||
if (element === undefined) {
|
||
{
|
||
throw Error(`Reconciliation: could not find DOM element for node key ${key}`);
|
||
}
|
||
}
|
||
return element;
|
||
}
|
||
function getParentElement(node) {
|
||
const parentElement = node.assignedSlot || node.parentElement;
|
||
return parentElement !== null && parentElement.nodeType === 11 ? parentElement.host : parentElement;
|
||
}
|
||
function scrollIntoViewIfNeeded(editor, selectionRect, rootElement) {
|
||
const doc = rootElement.ownerDocument;
|
||
const defaultView = doc.defaultView;
|
||
if (defaultView === null) {
|
||
return;
|
||
}
|
||
let {
|
||
top: currentTop,
|
||
bottom: currentBottom
|
||
} = selectionRect;
|
||
let targetTop = 0;
|
||
let targetBottom = 0;
|
||
let element = rootElement;
|
||
while (element !== null) {
|
||
const isBodyElement = element === doc.body;
|
||
if (isBodyElement) {
|
||
targetTop = 0;
|
||
targetBottom = getWindow(editor).innerHeight;
|
||
} else {
|
||
const targetRect = element.getBoundingClientRect();
|
||
targetTop = targetRect.top;
|
||
targetBottom = targetRect.bottom;
|
||
}
|
||
let diff = 0;
|
||
if (currentTop < targetTop) {
|
||
diff = -(targetTop - currentTop);
|
||
} else if (currentBottom > targetBottom) {
|
||
diff = currentBottom - targetBottom;
|
||
}
|
||
if (diff !== 0) {
|
||
if (isBodyElement) {
|
||
// Only handles scrolling of Y axis
|
||
defaultView.scrollBy(0, diff);
|
||
} else {
|
||
const scrollTop = element.scrollTop;
|
||
element.scrollTop += diff;
|
||
const yOffset = element.scrollTop - scrollTop;
|
||
currentTop -= yOffset;
|
||
currentBottom -= yOffset;
|
||
}
|
||
}
|
||
if (isBodyElement) {
|
||
break;
|
||
}
|
||
element = getParentElement(element);
|
||
}
|
||
}
|
||
function $hasUpdateTag(tag) {
|
||
const editor = getActiveEditor();
|
||
return editor._updateTags.has(tag);
|
||
}
|
||
function $addUpdateTag(tag) {
|
||
errorOnReadOnly();
|
||
const editor = getActiveEditor();
|
||
editor._updateTags.add(tag);
|
||
}
|
||
function $maybeMoveChildrenSelectionToParent(parentNode) {
|
||
const selection = $getSelection();
|
||
if (!$isRangeSelection(selection) || !$isElementNode(parentNode)) {
|
||
return selection;
|
||
}
|
||
const {
|
||
anchor,
|
||
focus
|
||
} = selection;
|
||
const anchorNode = anchor.getNode();
|
||
const focusNode = focus.getNode();
|
||
if ($hasAncestor(anchorNode, parentNode)) {
|
||
anchor.set(parentNode.__key, 0, 'element');
|
||
}
|
||
if ($hasAncestor(focusNode, parentNode)) {
|
||
focus.set(parentNode.__key, 0, 'element');
|
||
}
|
||
return selection;
|
||
}
|
||
function $hasAncestor(child, targetNode) {
|
||
let parent = child.getParent();
|
||
while (parent !== null) {
|
||
if (parent.is(targetNode)) {
|
||
return true;
|
||
}
|
||
parent = parent.getParent();
|
||
}
|
||
return false;
|
||
}
|
||
function getDefaultView(domElem) {
|
||
const ownerDoc = domElem.ownerDocument;
|
||
return ownerDoc && ownerDoc.defaultView || null;
|
||
}
|
||
function getWindow(editor) {
|
||
const windowObj = editor._window;
|
||
if (windowObj === null) {
|
||
{
|
||
throw Error(`window object not found`);
|
||
}
|
||
}
|
||
return windowObj;
|
||
}
|
||
function $isInlineElementOrDecoratorNode(node) {
|
||
return $isElementNode(node) && node.isInline() || $isDecoratorNode(node) && node.isInline();
|
||
}
|
||
function $getNearestRootOrShadowRoot(node) {
|
||
let parent = node.getParentOrThrow();
|
||
while (parent !== null) {
|
||
if ($isRootOrShadowRoot(parent)) {
|
||
return parent;
|
||
}
|
||
parent = parent.getParentOrThrow();
|
||
}
|
||
return parent;
|
||
}
|
||
function $isRootOrShadowRoot(node) {
|
||
return $isRootNode(node) || $isElementNode(node) && node.isShadowRoot();
|
||
}
|
||
function $copyNode(node) {
|
||
const copy = node.constructor.clone(node);
|
||
$setNodeKey(copy, null);
|
||
// @ts-expect-error
|
||
return copy;
|
||
}
|
||
function $applyNodeReplacement(node) {
|
||
const editor = getActiveEditor();
|
||
const nodeType = node.constructor.getType();
|
||
const registeredNode = editor._nodes.get(nodeType);
|
||
if (registeredNode === undefined) {
|
||
{
|
||
throw Error(`$initializeNode failed. Ensure node has been registered to the editor. You can do this by passing the node class via the "nodes" array in the editor config.`);
|
||
}
|
||
}
|
||
const replaceFunc = registeredNode.replace;
|
||
if (replaceFunc !== null) {
|
||
const replacementNode = replaceFunc(node);
|
||
if (!(replacementNode instanceof node.constructor)) {
|
||
{
|
||
throw Error(`$initializeNode failed. Ensure replacement node is a subclass of the original node.`);
|
||
}
|
||
}
|
||
return replacementNode;
|
||
}
|
||
return node;
|
||
}
|
||
function errorOnInsertTextNodeOnRoot(node, insertNode) {
|
||
const parentNode = node.getParent();
|
||
if ($isRootNode(parentNode) && !$isElementNode(insertNode) && !$isDecoratorNode(insertNode)) {
|
||
{
|
||
throw Error(`Only element or decorator nodes can be inserted in to the root node`);
|
||
}
|
||
}
|
||
}
|
||
function createBlockCursorElement(editorConfig) {
|
||
const theme = editorConfig.theme;
|
||
const element = document.createElement('div');
|
||
element.contentEditable = 'false';
|
||
element.setAttribute('data-lexical-cursor', 'true');
|
||
let blockCursorTheme = theme.blockCursor;
|
||
if (blockCursorTheme !== undefined) {
|
||
if (typeof blockCursorTheme === 'string') {
|
||
const classNamesArr = blockCursorTheme.split(' ');
|
||
// @ts-expect-error: intentional
|
||
blockCursorTheme = theme.blockCursor = classNamesArr;
|
||
}
|
||
if (blockCursorTheme !== undefined) {
|
||
element.classList.add(...blockCursorTheme);
|
||
}
|
||
}
|
||
return element;
|
||
}
|
||
function needsBlockCursor(node) {
|
||
return ($isDecoratorNode(node) || $isElementNode(node) && !node.canBeEmpty()) && !node.isInline();
|
||
}
|
||
function removeDOMBlockCursorElement(blockCursorElement, editor, rootElement) {
|
||
rootElement.style.removeProperty('caret-color');
|
||
editor._blockCursorElement = null;
|
||
const parentElement = blockCursorElement.parentElement;
|
||
if (parentElement !== null) {
|
||
parentElement.removeChild(blockCursorElement);
|
||
}
|
||
}
|
||
function updateDOMBlockCursorElement(editor, rootElement, nextSelection) {
|
||
let blockCursorElement = editor._blockCursorElement;
|
||
if ($isRangeSelection(nextSelection) && nextSelection.isCollapsed() && nextSelection.anchor.type === 'element' && rootElement.contains(document.activeElement)) {
|
||
const anchor = nextSelection.anchor;
|
||
const elementNode = anchor.getNode();
|
||
const offset = anchor.offset;
|
||
const elementNodeSize = elementNode.getChildrenSize();
|
||
let isBlockCursor = false;
|
||
let insertBeforeElement = null;
|
||
if (offset === elementNodeSize) {
|
||
const child = elementNode.getChildAtIndex(offset - 1);
|
||
if (needsBlockCursor(child)) {
|
||
isBlockCursor = true;
|
||
}
|
||
} else {
|
||
const child = elementNode.getChildAtIndex(offset);
|
||
if (needsBlockCursor(child)) {
|
||
const sibling = child.getPreviousSibling();
|
||
if (sibling === null || needsBlockCursor(sibling)) {
|
||
isBlockCursor = true;
|
||
insertBeforeElement = editor.getElementByKey(child.__key);
|
||
}
|
||
}
|
||
}
|
||
if (isBlockCursor) {
|
||
const elementDOM = editor.getElementByKey(elementNode.__key);
|
||
if (blockCursorElement === null) {
|
||
editor._blockCursorElement = blockCursorElement = createBlockCursorElement(editor._config);
|
||
}
|
||
rootElement.style.caretColor = 'transparent';
|
||
if (insertBeforeElement === null) {
|
||
elementDOM.appendChild(blockCursorElement);
|
||
} else {
|
||
elementDOM.insertBefore(blockCursorElement, insertBeforeElement);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
// Remove cursor
|
||
if (blockCursorElement !== null) {
|
||
removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
|
||
}
|
||
}
|
||
function getDOMSelection(targetWindow) {
|
||
return !CAN_USE_DOM ? null : (targetWindow || window).getSelection();
|
||
}
|
||
function $splitNode(node, offset) {
|
||
let startNode = node.getChildAtIndex(offset);
|
||
if (startNode == null) {
|
||
startNode = node;
|
||
}
|
||
if (!!$isRootOrShadowRoot(node)) {
|
||
throw Error(`Can not call $splitNode() on root element`);
|
||
}
|
||
const recurse = currentNode => {
|
||
const parent = currentNode.getParentOrThrow();
|
||
const isParentRoot = $isRootOrShadowRoot(parent);
|
||
// The node we start split from (leaf) is moved, but its recursive
|
||
// parents are copied to create separate tree
|
||
const nodeToMove = currentNode === startNode && !isParentRoot ? currentNode : $copyNode(currentNode);
|
||
if (isParentRoot) {
|
||
if (!($isElementNode(currentNode) && $isElementNode(nodeToMove))) {
|
||
throw Error(`Children of a root must be ElementNode`);
|
||
}
|
||
currentNode.insertAfter(nodeToMove);
|
||
return [currentNode, nodeToMove, nodeToMove];
|
||
} else {
|
||
const [leftTree, rightTree, newParent] = recurse(parent);
|
||
const nextSiblings = currentNode.getNextSiblings();
|
||
newParent.append(nodeToMove, ...nextSiblings);
|
||
return [leftTree, rightTree, nodeToMove];
|
||
}
|
||
};
|
||
const [leftTree, rightTree] = recurse(startNode);
|
||
return [leftTree, rightTree];
|
||
}
|
||
|
||
/**
|
||
* @param x - The element being tested
|
||
* @returns Returns true if x is an HTML anchor tag, false otherwise
|
||
*/
|
||
function isHTMLAnchorElement(x) {
|
||
return isHTMLElement(x) && x.tagName === 'A';
|
||
}
|
||
|
||
/**
|
||
* @param x - The element being testing
|
||
* @returns Returns true if x is an HTML element, false otherwise.
|
||
*/
|
||
function isHTMLElement(x) {
|
||
// @ts-ignore-next-line - strict check on nodeType here should filter out non-Element EventTarget implementors
|
||
return x.nodeType === 1;
|
||
}
|
||
|
||
/**
|
||
* This function is for internal use of the library.
|
||
* Please do not use it as it may change in the future.
|
||
*/
|
||
function INTERNAL_$isBlock(node) {
|
||
if ($isDecoratorNode(node) && !node.isInline()) {
|
||
return true;
|
||
}
|
||
if (!$isElementNode(node) || $isRootOrShadowRoot(node)) {
|
||
return false;
|
||
}
|
||
const firstChild = node.getFirstChild();
|
||
const isLeafElement = firstChild === null || $isLineBreakNode(firstChild) || $isTextNode(firstChild) || firstChild.isInline();
|
||
return !node.isInline() && node.canBeEmpty() !== false && isLeafElement;
|
||
}
|
||
function $getAncestor(node, predicate) {
|
||
let parent = node;
|
||
while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
|
||
parent = parent.getParentOrThrow();
|
||
}
|
||
return predicate(parent) ? parent : null;
|
||
}
|
||
|
||
/**
|
||
* Utility function for accessing current active editor instance.
|
||
* @returns Current active editor
|
||
*/
|
||
function $getEditor() {
|
||
return getActiveEditor();
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
function $garbageCollectDetachedDecorators(editor, pendingEditorState) {
|
||
const currentDecorators = editor._decorators;
|
||
const pendingDecorators = editor._pendingDecorators;
|
||
let decorators = pendingDecorators || currentDecorators;
|
||
const nodeMap = pendingEditorState._nodeMap;
|
||
let key;
|
||
for (key in decorators) {
|
||
if (!nodeMap.has(key)) {
|
||
if (decorators === currentDecorators) {
|
||
decorators = cloneDecorators(editor);
|
||
}
|
||
delete decorators[key];
|
||
}
|
||
}
|
||
}
|
||
function $garbageCollectDetachedDeepChildNodes(node, parentKey, prevNodeMap, nodeMap, nodeMapDelete, dirtyNodes) {
|
||
let child = node.getFirstChild();
|
||
while (child !== null) {
|
||
const childKey = child.__key;
|
||
// TODO Revise condition below, redundant? LexicalNode already cleans up children when moving Nodes
|
||
if (child.__parent === parentKey) {
|
||
if ($isElementNode(child)) {
|
||
$garbageCollectDetachedDeepChildNodes(child, childKey, prevNodeMap, nodeMap, nodeMapDelete, dirtyNodes);
|
||
}
|
||
|
||
// If we have created a node and it was dereferenced, then also
|
||
// remove it from out dirty nodes Set.
|
||
if (!prevNodeMap.has(childKey)) {
|
||
dirtyNodes.delete(childKey);
|
||
}
|
||
nodeMapDelete.push(childKey);
|
||
}
|
||
child = child.getNextSibling();
|
||
}
|
||
}
|
||
function $garbageCollectDetachedNodes(prevEditorState, editorState, dirtyLeaves, dirtyElements) {
|
||
const prevNodeMap = prevEditorState._nodeMap;
|
||
const nodeMap = editorState._nodeMap;
|
||
// Store dirtyElements in a queue for later deletion; deleting dirty subtrees too early will
|
||
// hinder accessing .__next on child nodes
|
||
const nodeMapDelete = [];
|
||
for (const [nodeKey] of dirtyElements) {
|
||
const node = nodeMap.get(nodeKey);
|
||
if (node !== undefined) {
|
||
// Garbage collect node and its children if they exist
|
||
if (!node.isAttached()) {
|
||
if ($isElementNode(node)) {
|
||
$garbageCollectDetachedDeepChildNodes(node, nodeKey, prevNodeMap, nodeMap, nodeMapDelete, dirtyElements);
|
||
}
|
||
// If we have created a node and it was dereferenced, then also
|
||
// remove it from out dirty nodes Set.
|
||
if (!prevNodeMap.has(nodeKey)) {
|
||
dirtyElements.delete(nodeKey);
|
||
}
|
||
nodeMapDelete.push(nodeKey);
|
||
}
|
||
}
|
||
}
|
||
for (const nodeKey of nodeMapDelete) {
|
||
nodeMap.delete(nodeKey);
|
||
}
|
||
for (const nodeKey of dirtyLeaves) {
|
||
const node = nodeMap.get(nodeKey);
|
||
if (node !== undefined && !node.isAttached()) {
|
||
if (!prevNodeMap.has(nodeKey)) {
|
||
dirtyLeaves.delete(nodeKey);
|
||
}
|
||
nodeMap.delete(nodeKey);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
let subTreeTextContent = '';
|
||
let subTreeDirectionedTextContent = '';
|
||
let editorTextContent = '';
|
||
let activeEditorConfig;
|
||
let activeEditor$1;
|
||
let activeEditorNodes;
|
||
let treatAllNodesAsDirty = false;
|
||
let activeEditorStateReadOnly = false;
|
||
let activeMutationListeners;
|
||
let activeTextDirection = null;
|
||
let activeDirtyElements;
|
||
let activeDirtyLeaves;
|
||
let activePrevNodeMap;
|
||
let activeNextNodeMap;
|
||
let activePrevKeyToDOMMap;
|
||
let mutatedNodes;
|
||
function destroyNode(key, parentDOM) {
|
||
const node = activePrevNodeMap.get(key);
|
||
if (parentDOM !== null) {
|
||
const dom = getPrevElementByKeyOrThrow(key);
|
||
if (dom.parentNode === parentDOM) {
|
||
parentDOM.removeChild(dom);
|
||
}
|
||
}
|
||
|
||
// This logic is really important, otherwise we will leak DOM nodes
|
||
// when their corresponding LexicalNodes are removed from the editor state.
|
||
if (!activeNextNodeMap.has(key)) {
|
||
activeEditor$1._keyToDOMMap.delete(key);
|
||
}
|
||
if ($isElementNode(node)) {
|
||
const children = createChildrenArray(node, activePrevNodeMap);
|
||
destroyChildren(children, 0, children.length - 1, null);
|
||
}
|
||
if (node !== undefined) {
|
||
setMutatedNode(mutatedNodes, activeEditorNodes, activeMutationListeners, node, 'destroyed');
|
||
}
|
||
}
|
||
function destroyChildren(children, _startIndex, endIndex, dom) {
|
||
let startIndex = _startIndex;
|
||
for (; startIndex <= endIndex; ++startIndex) {
|
||
const child = children[startIndex];
|
||
if (child !== undefined) {
|
||
destroyNode(child, dom);
|
||
}
|
||
}
|
||
}
|
||
function setTextAlign(domStyle, value) {
|
||
domStyle.setProperty('text-align', value);
|
||
}
|
||
const DEFAULT_INDENT_VALUE = '40px';
|
||
function setElementIndent(dom, indent) {
|
||
const indentClassName = activeEditorConfig.theme.indent;
|
||
if (typeof indentClassName === 'string') {
|
||
const elementHasClassName = dom.classList.contains(indentClassName);
|
||
if (indent > 0 && !elementHasClassName) {
|
||
dom.classList.add(indentClassName);
|
||
} else if (indent < 1 && elementHasClassName) {
|
||
dom.classList.remove(indentClassName);
|
||
}
|
||
}
|
||
const indentationBaseValue = getComputedStyle(dom).getPropertyValue('--lexical-indent-base-value') || DEFAULT_INDENT_VALUE;
|
||
dom.style.setProperty('padding-inline-start', indent === 0 ? '' : `calc(${indent} * ${indentationBaseValue})`);
|
||
}
|
||
function setElementFormat(dom, format) {
|
||
const domStyle = dom.style;
|
||
if (format === 0) {
|
||
setTextAlign(domStyle, '');
|
||
} else if (format === IS_ALIGN_LEFT) {
|
||
setTextAlign(domStyle, 'left');
|
||
} else if (format === IS_ALIGN_CENTER) {
|
||
setTextAlign(domStyle, 'center');
|
||
} else if (format === IS_ALIGN_RIGHT) {
|
||
setTextAlign(domStyle, 'right');
|
||
} else if (format === IS_ALIGN_JUSTIFY) {
|
||
setTextAlign(domStyle, 'justify');
|
||
} else if (format === IS_ALIGN_START) {
|
||
setTextAlign(domStyle, 'start');
|
||
} else if (format === IS_ALIGN_END) {
|
||
setTextAlign(domStyle, 'end');
|
||
}
|
||
}
|
||
function createNode(key, parentDOM, insertDOM) {
|
||
const node = activeNextNodeMap.get(key);
|
||
if (node === undefined) {
|
||
{
|
||
throw Error(`createNode: node does not exist in nodeMap`);
|
||
}
|
||
}
|
||
const dom = node.createDOM(activeEditorConfig, activeEditor$1);
|
||
storeDOMWithKey(key, dom, activeEditor$1);
|
||
|
||
// This helps preserve the text, and stops spell check tools from
|
||
// merging or break the spans (which happens if they are missing
|
||
// this attribute).
|
||
if ($isTextNode(node)) {
|
||
dom.setAttribute('data-lexical-text', 'true');
|
||
} else if ($isDecoratorNode(node)) {
|
||
dom.setAttribute('data-lexical-decorator', 'true');
|
||
}
|
||
if ($isElementNode(node)) {
|
||
const indent = node.__indent;
|
||
const childrenSize = node.__size;
|
||
if (indent !== 0) {
|
||
setElementIndent(dom, indent);
|
||
}
|
||
if (childrenSize !== 0) {
|
||
const endIndex = childrenSize - 1;
|
||
const children = createChildrenArray(node, activeNextNodeMap);
|
||
createChildrenWithDirection(children, endIndex, node, dom);
|
||
}
|
||
const format = node.__format;
|
||
if (format !== 0) {
|
||
setElementFormat(dom, format);
|
||
}
|
||
if (!node.isInline()) {
|
||
reconcileElementTerminatingLineBreak(null, node, dom);
|
||
}
|
||
if ($textContentRequiresDoubleLinebreakAtEnd(node)) {
|
||
subTreeTextContent += DOUBLE_LINE_BREAK;
|
||
editorTextContent += DOUBLE_LINE_BREAK;
|
||
}
|
||
} else {
|
||
const text = node.getTextContent();
|
||
if ($isDecoratorNode(node)) {
|
||
const decorator = node.decorate(activeEditor$1, activeEditorConfig);
|
||
if (decorator !== null) {
|
||
reconcileDecorator(key, decorator);
|
||
}
|
||
// Decorators are always non editable
|
||
dom.contentEditable = 'false';
|
||
} else if ($isTextNode(node)) {
|
||
if (!node.isDirectionless()) {
|
||
subTreeDirectionedTextContent += text;
|
||
}
|
||
}
|
||
subTreeTextContent += text;
|
||
editorTextContent += text;
|
||
}
|
||
if (parentDOM !== null) {
|
||
if (insertDOM != null) {
|
||
parentDOM.insertBefore(dom, insertDOM);
|
||
} else {
|
||
// @ts-expect-error: internal field
|
||
const possibleLineBreak = parentDOM.__lexicalLineBreak;
|
||
if (possibleLineBreak != null) {
|
||
parentDOM.insertBefore(dom, possibleLineBreak);
|
||
} else {
|
||
parentDOM.appendChild(dom);
|
||
}
|
||
}
|
||
}
|
||
{
|
||
// Freeze the node in DEV to prevent accidental mutations
|
||
Object.freeze(node);
|
||
}
|
||
setMutatedNode(mutatedNodes, activeEditorNodes, activeMutationListeners, node, 'created');
|
||
return dom;
|
||
}
|
||
function createChildrenWithDirection(children, endIndex, element, dom) {
|
||
const previousSubTreeDirectionedTextContent = subTreeDirectionedTextContent;
|
||
subTreeDirectionedTextContent = '';
|
||
createChildren(children, element, 0, endIndex, dom, null);
|
||
reconcileBlockDirection(element, dom);
|
||
subTreeDirectionedTextContent = previousSubTreeDirectionedTextContent;
|
||
}
|
||
function createChildren(children, element, _startIndex, endIndex, dom, insertDOM) {
|
||
const previousSubTreeTextContent = subTreeTextContent;
|
||
subTreeTextContent = '';
|
||
let startIndex = _startIndex;
|
||
for (; startIndex <= endIndex; ++startIndex) {
|
||
createNode(children[startIndex], dom, insertDOM);
|
||
}
|
||
if ($textContentRequiresDoubleLinebreakAtEnd(element)) {
|
||
subTreeTextContent += DOUBLE_LINE_BREAK;
|
||
}
|
||
// @ts-expect-error: internal field
|
||
dom.__lexicalTextContent = subTreeTextContent;
|
||
subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
|
||
}
|
||
function isLastChildLineBreakOrDecorator(childKey, nodeMap) {
|
||
const node = nodeMap.get(childKey);
|
||
return $isLineBreakNode(node) || $isDecoratorNode(node) && node.isInline();
|
||
}
|
||
|
||
// If we end an element with a LineBreakNode, then we need to add an additional <br>
|
||
function reconcileElementTerminatingLineBreak(prevElement, nextElement, dom) {
|
||
const prevLineBreak = prevElement !== null && (prevElement.__size === 0 || isLastChildLineBreakOrDecorator(prevElement.__last, activePrevNodeMap));
|
||
const nextLineBreak = nextElement.__size === 0 || isLastChildLineBreakOrDecorator(nextElement.__last, activeNextNodeMap);
|
||
if (prevLineBreak) {
|
||
if (!nextLineBreak) {
|
||
// @ts-expect-error: internal field
|
||
const element = dom.__lexicalLineBreak;
|
||
if (element != null) {
|
||
dom.removeChild(element);
|
||
}
|
||
|
||
// @ts-expect-error: internal field
|
||
dom.__lexicalLineBreak = null;
|
||
}
|
||
} else if (nextLineBreak) {
|
||
const element = document.createElement('br');
|
||
// @ts-expect-error: internal field
|
||
dom.__lexicalLineBreak = element;
|
||
dom.appendChild(element);
|
||
}
|
||
}
|
||
function reconcileBlockDirection(element, dom) {
|
||
const previousSubTreeDirectionTextContent =
|
||
// @ts-expect-error: internal field
|
||
dom.__lexicalDirTextContent;
|
||
// @ts-expect-error: internal field
|
||
const previousDirection = dom.__lexicalDir;
|
||
if (previousSubTreeDirectionTextContent !== subTreeDirectionedTextContent || previousDirection !== activeTextDirection) {
|
||
const hasEmptyDirectionedTextContent = subTreeDirectionedTextContent === '';
|
||
const direction = hasEmptyDirectionedTextContent ? activeTextDirection : getTextDirection(subTreeDirectionedTextContent);
|
||
if (direction !== previousDirection) {
|
||
const classList = dom.classList;
|
||
const theme = activeEditorConfig.theme;
|
||
let previousDirectionTheme = previousDirection !== null ? theme[previousDirection] : undefined;
|
||
let nextDirectionTheme = direction !== null ? theme[direction] : undefined;
|
||
|
||
// Remove the old theme classes if they exist
|
||
if (previousDirectionTheme !== undefined) {
|
||
if (typeof previousDirectionTheme === 'string') {
|
||
const classNamesArr = previousDirectionTheme.split(' ');
|
||
previousDirectionTheme = theme[previousDirection] = classNamesArr;
|
||
}
|
||
|
||
// @ts-ignore: intentional
|
||
classList.remove(...previousDirectionTheme);
|
||
}
|
||
if (direction === null || hasEmptyDirectionedTextContent && direction === 'ltr') {
|
||
// Remove direction
|
||
dom.removeAttribute('dir');
|
||
} else {
|
||
// Apply the new theme classes if they exist
|
||
if (nextDirectionTheme !== undefined) {
|
||
if (typeof nextDirectionTheme === 'string') {
|
||
const classNamesArr = nextDirectionTheme.split(' ');
|
||
// @ts-expect-error: intentional
|
||
nextDirectionTheme = theme[direction] = classNamesArr;
|
||
}
|
||
if (nextDirectionTheme !== undefined) {
|
||
classList.add(...nextDirectionTheme);
|
||
}
|
||
}
|
||
|
||
// Update direction
|
||
dom.dir = direction;
|
||
}
|
||
if (!activeEditorStateReadOnly) {
|
||
const writableNode = element.getWritable();
|
||
writableNode.__dir = direction;
|
||
}
|
||
}
|
||
activeTextDirection = direction;
|
||
// @ts-expect-error: internal field
|
||
dom.__lexicalDirTextContent = subTreeDirectionedTextContent;
|
||
// @ts-expect-error: internal field
|
||
dom.__lexicalDir = direction;
|
||
}
|
||
}
|
||
function reconcileChildrenWithDirection(prevElement, nextElement, dom) {
|
||
const previousSubTreeDirectionTextContent = subTreeDirectionedTextContent;
|
||
subTreeDirectionedTextContent = '';
|
||
reconcileChildren(prevElement, nextElement, dom);
|
||
reconcileBlockDirection(nextElement, dom);
|
||
subTreeDirectionedTextContent = previousSubTreeDirectionTextContent;
|
||
}
|
||
function createChildrenArray(element, nodeMap) {
|
||
const children = [];
|
||
let nodeKey = element.__first;
|
||
while (nodeKey !== null) {
|
||
const node = nodeMap.get(nodeKey);
|
||
if (node === undefined) {
|
||
{
|
||
throw Error(`createChildrenArray: node does not exist in nodeMap`);
|
||
}
|
||
}
|
||
children.push(nodeKey);
|
||
nodeKey = node.__next;
|
||
}
|
||
return children;
|
||
}
|
||
function reconcileChildren(prevElement, nextElement, dom) {
|
||
const previousSubTreeTextContent = subTreeTextContent;
|
||
const prevChildrenSize = prevElement.__size;
|
||
const nextChildrenSize = nextElement.__size;
|
||
subTreeTextContent = '';
|
||
if (prevChildrenSize === 1 && nextChildrenSize === 1) {
|
||
const prevFirstChildKey = prevElement.__first;
|
||
const nextFrstChildKey = nextElement.__first;
|
||
if (prevFirstChildKey === nextFrstChildKey) {
|
||
reconcileNode(prevFirstChildKey, dom);
|
||
} else {
|
||
const lastDOM = getPrevElementByKeyOrThrow(prevFirstChildKey);
|
||
const replacementDOM = createNode(nextFrstChildKey, null, null);
|
||
dom.replaceChild(replacementDOM, lastDOM);
|
||
destroyNode(prevFirstChildKey, null);
|
||
}
|
||
} else {
|
||
const prevChildren = createChildrenArray(prevElement, activePrevNodeMap);
|
||
const nextChildren = createChildrenArray(nextElement, activeNextNodeMap);
|
||
if (prevChildrenSize === 0) {
|
||
if (nextChildrenSize !== 0) {
|
||
createChildren(nextChildren, nextElement, 0, nextChildrenSize - 1, dom, null);
|
||
}
|
||
} else if (nextChildrenSize === 0) {
|
||
if (prevChildrenSize !== 0) {
|
||
// @ts-expect-error: internal field
|
||
const lexicalLineBreak = dom.__lexicalLineBreak;
|
||
const canUseFastPath = lexicalLineBreak == null;
|
||
destroyChildren(prevChildren, 0, prevChildrenSize - 1, canUseFastPath ? null : dom);
|
||
if (canUseFastPath) {
|
||
// Fast path for removing DOM nodes
|
||
dom.textContent = '';
|
||
}
|
||
}
|
||
} else {
|
||
reconcileNodeChildren(nextElement, prevChildren, nextChildren, prevChildrenSize, nextChildrenSize, dom);
|
||
}
|
||
}
|
||
if ($textContentRequiresDoubleLinebreakAtEnd(nextElement)) {
|
||
subTreeTextContent += DOUBLE_LINE_BREAK;
|
||
}
|
||
|
||
// @ts-expect-error: internal field
|
||
dom.__lexicalTextContent = subTreeTextContent;
|
||
subTreeTextContent = previousSubTreeTextContent + subTreeTextContent;
|
||
}
|
||
function reconcileNode(key, parentDOM) {
|
||
const prevNode = activePrevNodeMap.get(key);
|
||
let nextNode = activeNextNodeMap.get(key);
|
||
if (prevNode === undefined || nextNode === undefined) {
|
||
{
|
||
throw Error(`reconcileNode: prevNode or nextNode does not exist in nodeMap`);
|
||
}
|
||
}
|
||
const isDirty = treatAllNodesAsDirty || activeDirtyLeaves.has(key) || activeDirtyElements.has(key);
|
||
const dom = getElementByKeyOrThrow(activeEditor$1, key);
|
||
|
||
// If the node key points to the same instance in both states
|
||
// and isn't dirty, we just update the text content cache
|
||
// and return the existing DOM Node.
|
||
if (prevNode === nextNode && !isDirty) {
|
||
if ($isElementNode(prevNode)) {
|
||
// @ts-expect-error: internal field
|
||
const previousSubTreeTextContent = dom.__lexicalTextContent;
|
||
if (previousSubTreeTextContent !== undefined) {
|
||
subTreeTextContent += previousSubTreeTextContent;
|
||
editorTextContent += previousSubTreeTextContent;
|
||
}
|
||
|
||
// @ts-expect-error: internal field
|
||
const previousSubTreeDirectionTextContent = dom.__lexicalDirTextContent;
|
||
if (previousSubTreeDirectionTextContent !== undefined) {
|
||
subTreeDirectionedTextContent += previousSubTreeDirectionTextContent;
|
||
}
|
||
} else {
|
||
const text = prevNode.getTextContent();
|
||
if ($isTextNode(prevNode) && !prevNode.isDirectionless()) {
|
||
subTreeDirectionedTextContent += text;
|
||
}
|
||
editorTextContent += text;
|
||
subTreeTextContent += text;
|
||
}
|
||
return dom;
|
||
}
|
||
// If the node key doesn't point to the same instance in both maps,
|
||
// it means it were cloned. If they're also dirty, we mark them as mutated.
|
||
if (prevNode !== nextNode && isDirty) {
|
||
setMutatedNode(mutatedNodes, activeEditorNodes, activeMutationListeners, nextNode, 'updated');
|
||
}
|
||
|
||
// Update node. If it returns true, we need to unmount and re-create the node
|
||
if (nextNode.updateDOM(prevNode, dom, activeEditorConfig)) {
|
||
const replacementDOM = createNode(key, null, null);
|
||
if (parentDOM === null) {
|
||
{
|
||
throw Error(`reconcileNode: parentDOM is null`);
|
||
}
|
||
}
|
||
parentDOM.replaceChild(replacementDOM, dom);
|
||
destroyNode(key, null);
|
||
return replacementDOM;
|
||
}
|
||
if ($isElementNode(prevNode) && $isElementNode(nextNode)) {
|
||
// Reconcile element children
|
||
const nextIndent = nextNode.__indent;
|
||
if (nextIndent !== prevNode.__indent) {
|
||
setElementIndent(dom, nextIndent);
|
||
}
|
||
const nextFormat = nextNode.__format;
|
||
if (nextFormat !== prevNode.__format) {
|
||
setElementFormat(dom, nextFormat);
|
||
}
|
||
if (isDirty) {
|
||
reconcileChildrenWithDirection(prevNode, nextNode, dom);
|
||
if (!$isRootNode(nextNode) && !nextNode.isInline()) {
|
||
reconcileElementTerminatingLineBreak(prevNode, nextNode, dom);
|
||
}
|
||
}
|
||
if ($textContentRequiresDoubleLinebreakAtEnd(nextNode)) {
|
||
subTreeTextContent += DOUBLE_LINE_BREAK;
|
||
editorTextContent += DOUBLE_LINE_BREAK;
|
||
}
|
||
} else {
|
||
const text = nextNode.getTextContent();
|
||
if ($isDecoratorNode(nextNode)) {
|
||
const decorator = nextNode.decorate(activeEditor$1, activeEditorConfig);
|
||
if (decorator !== null) {
|
||
reconcileDecorator(key, decorator);
|
||
}
|
||
} else if ($isTextNode(nextNode) && !nextNode.isDirectionless()) {
|
||
// Handle text content, for LTR, LTR cases.
|
||
subTreeDirectionedTextContent += text;
|
||
}
|
||
subTreeTextContent += text;
|
||
editorTextContent += text;
|
||
}
|
||
if (!activeEditorStateReadOnly && $isRootNode(nextNode) && nextNode.__cachedText !== editorTextContent) {
|
||
// Cache the latest text content.
|
||
const nextRootNode = nextNode.getWritable();
|
||
nextRootNode.__cachedText = editorTextContent;
|
||
nextNode = nextRootNode;
|
||
}
|
||
{
|
||
// Freeze the node in DEV to prevent accidental mutations
|
||
Object.freeze(nextNode);
|
||
}
|
||
return dom;
|
||
}
|
||
function reconcileDecorator(key, decorator) {
|
||
let pendingDecorators = activeEditor$1._pendingDecorators;
|
||
const currentDecorators = activeEditor$1._decorators;
|
||
if (pendingDecorators === null) {
|
||
if (currentDecorators[key] === decorator) {
|
||
return;
|
||
}
|
||
pendingDecorators = cloneDecorators(activeEditor$1);
|
||
}
|
||
pendingDecorators[key] = decorator;
|
||
}
|
||
function getFirstChild(element) {
|
||
return element.firstChild;
|
||
}
|
||
function getNextSibling(element) {
|
||
let nextSibling = element.nextSibling;
|
||
if (nextSibling !== null && nextSibling === activeEditor$1._blockCursorElement) {
|
||
nextSibling = nextSibling.nextSibling;
|
||
}
|
||
return nextSibling;
|
||
}
|
||
function reconcileNodeChildren(nextElement, prevChildren, nextChildren, prevChildrenLength, nextChildrenLength, dom) {
|
||
const prevEndIndex = prevChildrenLength - 1;
|
||
const nextEndIndex = nextChildrenLength - 1;
|
||
let prevChildrenSet;
|
||
let nextChildrenSet;
|
||
let siblingDOM = getFirstChild(dom);
|
||
let prevIndex = 0;
|
||
let nextIndex = 0;
|
||
while (prevIndex <= prevEndIndex && nextIndex <= nextEndIndex) {
|
||
const prevKey = prevChildren[prevIndex];
|
||
const nextKey = nextChildren[nextIndex];
|
||
if (prevKey === nextKey) {
|
||
siblingDOM = getNextSibling(reconcileNode(nextKey, dom));
|
||
prevIndex++;
|
||
nextIndex++;
|
||
} else {
|
||
if (prevChildrenSet === undefined) {
|
||
prevChildrenSet = new Set(prevChildren);
|
||
}
|
||
if (nextChildrenSet === undefined) {
|
||
nextChildrenSet = new Set(nextChildren);
|
||
}
|
||
const nextHasPrevKey = nextChildrenSet.has(prevKey);
|
||
const prevHasNextKey = prevChildrenSet.has(nextKey);
|
||
if (!nextHasPrevKey) {
|
||
// Remove prev
|
||
siblingDOM = getNextSibling(getPrevElementByKeyOrThrow(prevKey));
|
||
destroyNode(prevKey, dom);
|
||
prevIndex++;
|
||
} else if (!prevHasNextKey) {
|
||
// Create next
|
||
createNode(nextKey, dom, siblingDOM);
|
||
nextIndex++;
|
||
} else {
|
||
// Move next
|
||
const childDOM = getElementByKeyOrThrow(activeEditor$1, nextKey);
|
||
if (childDOM === siblingDOM) {
|
||
siblingDOM = getNextSibling(reconcileNode(nextKey, dom));
|
||
} else {
|
||
if (siblingDOM != null) {
|
||
dom.insertBefore(childDOM, siblingDOM);
|
||
} else {
|
||
dom.appendChild(childDOM);
|
||
}
|
||
reconcileNode(nextKey, dom);
|
||
}
|
||
prevIndex++;
|
||
nextIndex++;
|
||
}
|
||
}
|
||
}
|
||
const appendNewChildren = prevIndex > prevEndIndex;
|
||
const removeOldChildren = nextIndex > nextEndIndex;
|
||
if (appendNewChildren && !removeOldChildren) {
|
||
const previousNode = nextChildren[nextEndIndex + 1];
|
||
const insertDOM = previousNode === undefined ? null : activeEditor$1.getElementByKey(previousNode);
|
||
createChildren(nextChildren, nextElement, nextIndex, nextEndIndex, dom, insertDOM);
|
||
} else if (removeOldChildren && !appendNewChildren) {
|
||
destroyChildren(prevChildren, prevIndex, prevEndIndex, dom);
|
||
}
|
||
}
|
||
function reconcileRoot(prevEditorState, nextEditorState, editor, dirtyType, dirtyElements, dirtyLeaves) {
|
||
// We cache text content to make retrieval more efficient.
|
||
// The cache must be rebuilt during reconciliation to account for any changes.
|
||
subTreeTextContent = '';
|
||
editorTextContent = '';
|
||
subTreeDirectionedTextContent = '';
|
||
// Rather than pass around a load of arguments through the stack recursively
|
||
// we instead set them as bindings within the scope of the module.
|
||
treatAllNodesAsDirty = dirtyType === FULL_RECONCILE;
|
||
activeTextDirection = null;
|
||
activeEditor$1 = editor;
|
||
activeEditorConfig = editor._config;
|
||
activeEditorNodes = editor._nodes;
|
||
activeMutationListeners = activeEditor$1._listeners.mutation;
|
||
activeDirtyElements = dirtyElements;
|
||
activeDirtyLeaves = dirtyLeaves;
|
||
activePrevNodeMap = prevEditorState._nodeMap;
|
||
activeNextNodeMap = nextEditorState._nodeMap;
|
||
activeEditorStateReadOnly = nextEditorState._readOnly;
|
||
activePrevKeyToDOMMap = new Map(editor._keyToDOMMap);
|
||
// We keep track of mutated nodes so we can trigger mutation
|
||
// listeners later in the update cycle.
|
||
const currentMutatedNodes = new Map();
|
||
mutatedNodes = currentMutatedNodes;
|
||
reconcileNode('root', null);
|
||
// We don't want a bunch of void checks throughout the scope
|
||
// so instead we make it seem that these values are always set.
|
||
// We also want to make sure we clear them down, otherwise we
|
||
// can leak memory.
|
||
// @ts-ignore
|
||
activeEditor$1 = undefined;
|
||
// @ts-ignore
|
||
activeEditorNodes = undefined;
|
||
// @ts-ignore
|
||
activeDirtyElements = undefined;
|
||
// @ts-ignore
|
||
activeDirtyLeaves = undefined;
|
||
// @ts-ignore
|
||
activePrevNodeMap = undefined;
|
||
// @ts-ignore
|
||
activeNextNodeMap = undefined;
|
||
// @ts-ignore
|
||
activeEditorConfig = undefined;
|
||
// @ts-ignore
|
||
activePrevKeyToDOMMap = undefined;
|
||
// @ts-ignore
|
||
mutatedNodes = undefined;
|
||
return currentMutatedNodes;
|
||
}
|
||
function storeDOMWithKey(key, dom, editor) {
|
||
const keyToDOMMap = editor._keyToDOMMap;
|
||
// @ts-ignore We intentionally add this to the Node.
|
||
dom['__lexicalKey_' + editor._key] = key;
|
||
keyToDOMMap.set(key, dom);
|
||
}
|
||
function getPrevElementByKeyOrThrow(key) {
|
||
const element = activePrevKeyToDOMMap.get(key);
|
||
if (element === undefined) {
|
||
{
|
||
throw Error(`Reconciliation: could not find DOM element for node key ${key}`);
|
||
}
|
||
}
|
||
return element;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
const PASS_THROUGH_COMMAND = Object.freeze({});
|
||
const ANDROID_COMPOSITION_LATENCY = 30;
|
||
const rootElementEvents = [['keydown', onKeyDown], ['pointerdown', onPointerDown], ['compositionstart', onCompositionStart], ['compositionend', onCompositionEnd], ['input', onInput], ['click', onClick], ['cut', PASS_THROUGH_COMMAND], ['copy', PASS_THROUGH_COMMAND], ['dragstart', PASS_THROUGH_COMMAND], ['dragover', PASS_THROUGH_COMMAND], ['dragend', PASS_THROUGH_COMMAND], ['paste', PASS_THROUGH_COMMAND], ['focus', PASS_THROUGH_COMMAND], ['blur', PASS_THROUGH_COMMAND], ['drop', PASS_THROUGH_COMMAND]];
|
||
if (CAN_USE_BEFORE_INPUT) {
|
||
rootElementEvents.push(['beforeinput', (event, editor) => onBeforeInput(event, editor)]);
|
||
}
|
||
let lastKeyDownTimeStamp = 0;
|
||
let lastKeyCode = 0;
|
||
let lastBeforeInputInsertTextTimeStamp = 0;
|
||
let unprocessedBeforeInputData = null;
|
||
let rootElementsRegistered = 0;
|
||
let isSelectionChangeFromDOMUpdate = false;
|
||
let isSelectionChangeFromMouseDown = false;
|
||
let isInsertLineBreak = false;
|
||
let isFirefoxEndingComposition = false;
|
||
let collapsedSelectionFormat = [0, '', 0, 'root', 0];
|
||
|
||
// This function is used to determine if Lexical should attempt to override
|
||
// the default browser behavior for insertion of text and use its own internal
|
||
// heuristics. This is an extremely important function, and makes much of Lexical
|
||
// work as intended between different browsers and across word, line and character
|
||
// boundary/formats. It also is important for text replacement, node schemas and
|
||
// composition mechanics.
|
||
|
||
function $shouldPreventDefaultAndInsertText(selection, domTargetRange, text, timeStamp, isBeforeInput) {
|
||
const anchor = selection.anchor;
|
||
const focus = selection.focus;
|
||
const anchorNode = anchor.getNode();
|
||
const editor = getActiveEditor();
|
||
const domSelection = getDOMSelection(editor._window);
|
||
const domAnchorNode = domSelection !== null ? domSelection.anchorNode : null;
|
||
const anchorKey = anchor.key;
|
||
const backingAnchorElement = editor.getElementByKey(anchorKey);
|
||
const textLength = text.length;
|
||
return anchorKey !== focus.key ||
|
||
// If we're working with a non-text node.
|
||
!$isTextNode(anchorNode) ||
|
||
// If we are replacing a range with a single character or grapheme, and not composing.
|
||
(!isBeforeInput && (!CAN_USE_BEFORE_INPUT ||
|
||
// We check to see if there has been
|
||
// a recent beforeinput event for "textInput". If there has been one in the last
|
||
// 50ms then we proceed as normal. However, if there is not, then this is likely
|
||
// a dangling `input` event caused by execCommand('insertText').
|
||
lastBeforeInputInsertTextTimeStamp < timeStamp + 50) || anchorNode.isDirty() && textLength < 2 || doesContainGrapheme(text)) && anchor.offset !== focus.offset && !anchorNode.isComposing() ||
|
||
// Any non standard text node.
|
||
$isTokenOrSegmented(anchorNode) ||
|
||
// If the text length is more than a single character and we're either
|
||
// dealing with this in "beforeinput" or where the node has already recently
|
||
// been changed (thus is dirty).
|
||
anchorNode.isDirty() && textLength > 1 ||
|
||
// If the DOM selection element is not the same as the backing node during beforeinput.
|
||
(isBeforeInput || !CAN_USE_BEFORE_INPUT) && backingAnchorElement !== null && !anchorNode.isComposing() && domAnchorNode !== getDOMTextNode(backingAnchorElement) ||
|
||
// If TargetRange is not the same as the DOM selection; browser trying to edit random parts
|
||
// of the editor.
|
||
domSelection !== null && domTargetRange !== null && (!domTargetRange.collapsed || domTargetRange.startContainer !== domSelection.anchorNode || domTargetRange.startOffset !== domSelection.anchorOffset) ||
|
||
// Check if we're changing from bold to italics, or some other format.
|
||
anchorNode.getFormat() !== selection.format || anchorNode.getStyle() !== selection.style ||
|
||
// One last set of heuristics to check against.
|
||
$shouldInsertTextAfterOrBeforeTextNode(selection, anchorNode);
|
||
}
|
||
function shouldSkipSelectionChange(domNode, offset) {
|
||
return domNode !== null && domNode.nodeValue !== null && domNode.nodeType === DOM_TEXT_TYPE && offset !== 0 && offset !== domNode.nodeValue.length;
|
||
}
|
||
function onSelectionChange(domSelection, editor, isActive) {
|
||
const {
|
||
anchorNode: anchorDOM,
|
||
anchorOffset,
|
||
focusNode: focusDOM,
|
||
focusOffset
|
||
} = domSelection;
|
||
if (isSelectionChangeFromDOMUpdate) {
|
||
isSelectionChangeFromDOMUpdate = false;
|
||
|
||
// If native DOM selection is on a DOM element, then
|
||
// we should continue as usual, as Lexical's selection
|
||
// may have normalized to a better child. If the DOM
|
||
// element is a text node, we can safely apply this
|
||
// optimization and skip the selection change entirely.
|
||
// We also need to check if the offset is at the boundary,
|
||
// because in this case, we might need to normalize to a
|
||
// sibling instead.
|
||
if (shouldSkipSelectionChange(anchorDOM, anchorOffset) && shouldSkipSelectionChange(focusDOM, focusOffset)) {
|
||
return;
|
||
}
|
||
}
|
||
updateEditor(editor, () => {
|
||
// Non-active editor don't need any extra logic for selection, it only needs update
|
||
// to reconcile selection (set it to null) to ensure that only one editor has non-null selection.
|
||
if (!isActive) {
|
||
$setSelection(null);
|
||
return;
|
||
}
|
||
if (!isSelectionWithinEditor(editor, anchorDOM, focusDOM)) {
|
||
return;
|
||
}
|
||
const selection = $getSelection();
|
||
|
||
// Update the selection format
|
||
if ($isRangeSelection(selection)) {
|
||
const anchor = selection.anchor;
|
||
const anchorNode = anchor.getNode();
|
||
if (selection.isCollapsed()) {
|
||
// Badly interpreted range selection when collapsed - #1482
|
||
if (domSelection.type === 'Range' && domSelection.anchorNode === domSelection.focusNode) {
|
||
selection.dirty = true;
|
||
}
|
||
|
||
// If we have marked a collapsed selection format, and we're
|
||
// within the given time range – then attempt to use that format
|
||
// instead of getting the format from the anchor node.
|
||
const windowEvent = getWindow(editor).event;
|
||
const currentTimeStamp = windowEvent ? windowEvent.timeStamp : performance.now();
|
||
const [lastFormat, lastStyle, lastOffset, lastKey, timeStamp] = collapsedSelectionFormat;
|
||
const root = $getRoot();
|
||
const isRootTextContentEmpty = editor.isComposing() === false && root.getTextContent() === '';
|
||
if (currentTimeStamp < timeStamp + 200 && anchor.offset === lastOffset && anchor.key === lastKey) {
|
||
selection.format = lastFormat;
|
||
selection.style = lastStyle;
|
||
} else {
|
||
if (anchor.type === 'text') {
|
||
if (!$isTextNode(anchorNode)) {
|
||
throw Error(`Point.getNode() must return TextNode when type is text`);
|
||
}
|
||
selection.format = anchorNode.getFormat();
|
||
selection.style = anchorNode.getStyle();
|
||
} else if (anchor.type === 'element' && !isRootTextContentEmpty) {
|
||
selection.format = 0;
|
||
selection.style = '';
|
||
}
|
||
}
|
||
} else {
|
||
const anchorKey = anchor.key;
|
||
const focus = selection.focus;
|
||
const focusKey = focus.key;
|
||
const nodes = selection.getNodes();
|
||
const nodesLength = nodes.length;
|
||
const isBackward = selection.isBackward();
|
||
const startOffset = isBackward ? focusOffset : anchorOffset;
|
||
const endOffset = isBackward ? anchorOffset : focusOffset;
|
||
const startKey = isBackward ? focusKey : anchorKey;
|
||
const endKey = isBackward ? anchorKey : focusKey;
|
||
let combinedFormat = IS_ALL_FORMATTING;
|
||
let hasTextNodes = false;
|
||
for (let i = 0; i < nodesLength; i++) {
|
||
const node = nodes[i];
|
||
const textContentSize = node.getTextContentSize();
|
||
if ($isTextNode(node) && textContentSize !== 0 &&
|
||
// Exclude empty text nodes at boundaries resulting from user's selection
|
||
!(i === 0 && node.__key === startKey && startOffset === textContentSize || i === nodesLength - 1 && node.__key === endKey && endOffset === 0)) {
|
||
// TODO: what about style?
|
||
hasTextNodes = true;
|
||
combinedFormat &= node.getFormat();
|
||
if (combinedFormat === 0) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
selection.format = hasTextNodes ? combinedFormat : 0;
|
||
}
|
||
}
|
||
dispatchCommand(editor, SELECTION_CHANGE_COMMAND, undefined);
|
||
});
|
||
}
|
||
|
||
// This is a work-around is mainly Chrome specific bug where if you select
|
||
// the contents of an empty block, you cannot easily unselect anything.
|
||
// This results in a tiny selection box that looks buggy/broken. This can
|
||
// also help other browsers when selection might "appear" lost, when it
|
||
// really isn't.
|
||
function onClick(event, editor) {
|
||
updateEditor(editor, () => {
|
||
const selection = $getSelection();
|
||
const domSelection = getDOMSelection(editor._window);
|
||
const lastSelection = $getPreviousSelection();
|
||
if (domSelection) {
|
||
if ($isRangeSelection(selection)) {
|
||
const anchor = selection.anchor;
|
||
const anchorNode = anchor.getNode();
|
||
if (anchor.type === 'element' && anchor.offset === 0 && selection.isCollapsed() && !$isRootNode(anchorNode) && $getRoot().getChildrenSize() === 1 && anchorNode.getTopLevelElementOrThrow().isEmpty() && lastSelection !== null && selection.is(lastSelection)) {
|
||
domSelection.removeAllRanges();
|
||
selection.dirty = true;
|
||
} else if (event.detail === 3 && !selection.isCollapsed()) {
|
||
// Tripple click causing selection to overflow into the nearest element. In that
|
||
// case visually it looks like a single element content is selected, focus node
|
||
// is actually at the beginning of the next element (if present) and any manipulations
|
||
// with selection (formatting) are affecting second element as well
|
||
const focus = selection.focus;
|
||
const focusNode = focus.getNode();
|
||
if (anchorNode !== focusNode) {
|
||
if ($isElementNode(anchorNode)) {
|
||
anchorNode.select(0);
|
||
} else {
|
||
anchorNode.getParentOrThrow().select(0);
|
||
}
|
||
}
|
||
}
|
||
} else if (event.pointerType === 'touch') {
|
||
// This is used to update the selection on touch devices when the user clicks on text after a
|
||
// node selection. See isSelectionChangeFromMouseDown for the inverse
|
||
const domAnchorNode = domSelection.anchorNode;
|
||
if (domAnchorNode !== null) {
|
||
const nodeType = domAnchorNode.nodeType;
|
||
// If the user is attempting to click selection back onto text, then
|
||
// we should attempt create a range selection.
|
||
// When we click on an empty paragraph node or the end of a paragraph that ends
|
||
// with an image/poll, the nodeType will be ELEMENT_NODE
|
||
if (nodeType === DOM_ELEMENT_TYPE || nodeType === DOM_TEXT_TYPE) {
|
||
const newSelection = internalCreateRangeSelection(lastSelection, domSelection, editor, event);
|
||
$setSelection(newSelection);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
dispatchCommand(editor, CLICK_COMMAND, event);
|
||
});
|
||
}
|
||
function onPointerDown(event, editor) {
|
||
// TODO implement text drag & drop
|
||
const target = event.target;
|
||
const pointerType = event.pointerType;
|
||
if (target instanceof Node && pointerType !== 'touch') {
|
||
updateEditor(editor, () => {
|
||
// Drag & drop should not recompute selection until mouse up; otherwise the initially
|
||
// selected content is lost.
|
||
if (!$isSelectionCapturedInDecorator(target)) {
|
||
isSelectionChangeFromMouseDown = true;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
function getTargetRange(event) {
|
||
if (!event.getTargetRanges) {
|
||
return null;
|
||
}
|
||
const targetRanges = event.getTargetRanges();
|
||
if (targetRanges.length === 0) {
|
||
return null;
|
||
}
|
||
return targetRanges[0];
|
||
}
|
||
function $canRemoveText(anchorNode, focusNode) {
|
||
return anchorNode !== focusNode || $isElementNode(anchorNode) || $isElementNode(focusNode) || !anchorNode.isToken() || !focusNode.isToken();
|
||
}
|
||
function isPossiblyAndroidKeyPress(timeStamp) {
|
||
return lastKeyCode === 229 && timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY;
|
||
}
|
||
function onBeforeInput(event, editor) {
|
||
const inputType = event.inputType;
|
||
const targetRange = getTargetRange(event);
|
||
|
||
// We let the browser do its own thing for composition.
|
||
if (inputType === 'deleteCompositionText' ||
|
||
// If we're pasting in FF, we shouldn't get this event
|
||
// as the `paste` event should have triggered, unless the
|
||
// user has dom.event.clipboardevents.enabled disabled in
|
||
// about:config. In that case, we need to process the
|
||
// pasted content in the DOM mutation phase.
|
||
IS_FIREFOX && isFirefoxClipboardEvents(editor)) {
|
||
return;
|
||
} else if (inputType === 'insertCompositionText') {
|
||
return;
|
||
}
|
||
updateEditor(editor, () => {
|
||
const selection = $getSelection();
|
||
if (inputType === 'deleteContentBackward') {
|
||
if (selection === null) {
|
||
// Use previous selection
|
||
const prevSelection = $getPreviousSelection();
|
||
if (!$isRangeSelection(prevSelection)) {
|
||
return;
|
||
}
|
||
$setSelection(prevSelection.clone());
|
||
}
|
||
if ($isRangeSelection(selection)) {
|
||
// Used for handling backspace in Android.
|
||
if (IS_ANDROID) {
|
||
$setCompositionKey(selection.anchor.key);
|
||
}
|
||
if (isPossiblyAndroidKeyPress(event.timeStamp) && editor.isComposing() && selection.anchor.key === selection.focus.key) {
|
||
$setCompositionKey(null);
|
||
lastKeyDownTimeStamp = 0;
|
||
// Fixes an Android bug where selection flickers when backspacing
|
||
setTimeout(() => {
|
||
updateEditor(editor, () => {
|
||
$setCompositionKey(null);
|
||
});
|
||
}, ANDROID_COMPOSITION_LATENCY);
|
||
if ($isRangeSelection(selection)) {
|
||
const anchorNode = selection.anchor.getNode();
|
||
anchorNode.markDirty();
|
||
selection.format = anchorNode.getFormat();
|
||
if (!$isTextNode(anchorNode)) {
|
||
throw Error(`Anchor node must be a TextNode`);
|
||
}
|
||
selection.style = anchorNode.getStyle();
|
||
}
|
||
const selectedText = selection.anchor.getNode().getTextContent();
|
||
if (selectedText.length <= 1) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
|
||
}
|
||
} else {
|
||
$setCompositionKey(null);
|
||
event.preventDefault();
|
||
dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
if (!$isRangeSelection(selection)) {
|
||
return;
|
||
}
|
||
const data = event.data;
|
||
|
||
// This represents the case when two beforeinput events are triggered at the same time (without a
|
||
// full event loop ending at input). This happens with MacOS with the default keyboard settings,
|
||
// a combination of autocorrection + autocapitalization.
|
||
// Having Lexical run everything in controlled mode would fix the issue without additional code
|
||
// but this would kill the massive performance win from the most common typing event.
|
||
// Alternatively, when this happens we can prematurely update our EditorState based on the DOM
|
||
// content, a job that would usually be the input event's responsibility.
|
||
if (unprocessedBeforeInputData !== null) {
|
||
$updateSelectedTextFromDOM(false, editor, unprocessedBeforeInputData);
|
||
}
|
||
if ((!selection.dirty || unprocessedBeforeInputData !== null) && selection.isCollapsed() && !$isRootNode(selection.anchor.getNode()) && targetRange !== null) {
|
||
selection.applyDOMRange(targetRange);
|
||
}
|
||
unprocessedBeforeInputData = null;
|
||
const anchor = selection.anchor;
|
||
const focus = selection.focus;
|
||
const anchorNode = anchor.getNode();
|
||
const focusNode = focus.getNode();
|
||
if (inputType === 'insertText' || inputType === 'insertTranspose') {
|
||
if (data === '\n') {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
|
||
} else if (data === DOUBLE_LINE_BREAK) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
|
||
} else if (data == null && event.dataTransfer) {
|
||
// Gets around a Safari text replacement bug.
|
||
const text = event.dataTransfer.getData('text/plain');
|
||
event.preventDefault();
|
||
selection.insertRawText(text);
|
||
} else if (data != null && $shouldPreventDefaultAndInsertText(selection, targetRange, data, event.timeStamp, true)) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
|
||
} else {
|
||
unprocessedBeforeInputData = data;
|
||
}
|
||
lastBeforeInputInsertTextTimeStamp = event.timeStamp;
|
||
return;
|
||
}
|
||
|
||
// Prevent the browser from carrying out
|
||
// the input event, so we can control the
|
||
// output.
|
||
event.preventDefault();
|
||
switch (inputType) {
|
||
case 'insertFromYank':
|
||
case 'insertFromDrop':
|
||
case 'insertReplacementText':
|
||
{
|
||
dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
|
||
break;
|
||
}
|
||
case 'insertFromComposition':
|
||
{
|
||
// This is the end of composition
|
||
$setCompositionKey(null);
|
||
dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, event);
|
||
break;
|
||
}
|
||
case 'insertLineBreak':
|
||
{
|
||
// Used for Android
|
||
$setCompositionKey(null);
|
||
dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
|
||
break;
|
||
}
|
||
case 'insertParagraph':
|
||
{
|
||
// Used for Android
|
||
$setCompositionKey(null);
|
||
|
||
// Safari does not provide the type "insertLineBreak".
|
||
// So instead, we need to infer it from the keyboard event.
|
||
// We do not apply this logic to iOS to allow newline auto-capitalization
|
||
// work without creating linebreaks when pressing Enter
|
||
if (isInsertLineBreak && !IS_IOS) {
|
||
isInsertLineBreak = false;
|
||
dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, false);
|
||
} else {
|
||
dispatchCommand(editor, INSERT_PARAGRAPH_COMMAND, undefined);
|
||
}
|
||
break;
|
||
}
|
||
case 'insertFromPaste':
|
||
case 'insertFromPasteAsQuotation':
|
||
{
|
||
dispatchCommand(editor, PASTE_COMMAND, event);
|
||
break;
|
||
}
|
||
case 'deleteByComposition':
|
||
{
|
||
if ($canRemoveText(anchorNode, focusNode)) {
|
||
dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
|
||
}
|
||
break;
|
||
}
|
||
case 'deleteByDrag':
|
||
case 'deleteByCut':
|
||
{
|
||
dispatchCommand(editor, REMOVE_TEXT_COMMAND, event);
|
||
break;
|
||
}
|
||
case 'deleteContent':
|
||
{
|
||
dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
|
||
break;
|
||
}
|
||
case 'deleteWordBackward':
|
||
{
|
||
dispatchCommand(editor, DELETE_WORD_COMMAND, true);
|
||
break;
|
||
}
|
||
case 'deleteWordForward':
|
||
{
|
||
dispatchCommand(editor, DELETE_WORD_COMMAND, false);
|
||
break;
|
||
}
|
||
case 'deleteHardLineBackward':
|
||
case 'deleteSoftLineBackward':
|
||
{
|
||
dispatchCommand(editor, DELETE_LINE_COMMAND, true);
|
||
break;
|
||
}
|
||
case 'deleteContentForward':
|
||
case 'deleteHardLineForward':
|
||
case 'deleteSoftLineForward':
|
||
{
|
||
dispatchCommand(editor, DELETE_LINE_COMMAND, false);
|
||
break;
|
||
}
|
||
case 'formatStrikeThrough':
|
||
{
|
||
dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'strikethrough');
|
||
break;
|
||
}
|
||
case 'formatBold':
|
||
{
|
||
dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
|
||
break;
|
||
}
|
||
case 'formatItalic':
|
||
{
|
||
dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
|
||
break;
|
||
}
|
||
case 'formatUnderline':
|
||
{
|
||
dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
|
||
break;
|
||
}
|
||
case 'historyUndo':
|
||
{
|
||
dispatchCommand(editor, UNDO_COMMAND, undefined);
|
||
break;
|
||
}
|
||
case 'historyRedo':
|
||
{
|
||
dispatchCommand(editor, REDO_COMMAND, undefined);
|
||
break;
|
||
}
|
||
// NO-OP
|
||
}
|
||
});
|
||
}
|
||
|
||
function onInput(event, editor) {
|
||
// We don't want the onInput to bubble, in the case of nested editors.
|
||
event.stopPropagation();
|
||
updateEditor(editor, () => {
|
||
const selection = $getSelection();
|
||
const data = event.data;
|
||
const targetRange = getTargetRange(event);
|
||
if (data != null && $isRangeSelection(selection) && $shouldPreventDefaultAndInsertText(selection, targetRange, data, event.timeStamp, false)) {
|
||
// Given we're over-riding the default behavior, we will need
|
||
// to ensure to disable composition before dispatching the
|
||
// insertText command for when changing the sequence for FF.
|
||
if (isFirefoxEndingComposition) {
|
||
onCompositionEndImpl(editor, data);
|
||
isFirefoxEndingComposition = false;
|
||
}
|
||
const anchor = selection.anchor;
|
||
const anchorNode = anchor.getNode();
|
||
const domSelection = getDOMSelection(editor._window);
|
||
if (domSelection === null) {
|
||
return;
|
||
}
|
||
const offset = anchor.offset;
|
||
// If the content is the same as inserted, then don't dispatch an insertion.
|
||
// Given onInput doesn't take the current selection (it uses the previous)
|
||
// we can compare that against what the DOM currently says.
|
||
if (!CAN_USE_BEFORE_INPUT || selection.isCollapsed() || !$isTextNode(anchorNode) || domSelection.anchorNode === null || anchorNode.getTextContent().slice(0, offset) + data + anchorNode.getTextContent().slice(offset + selection.focus.offset) !== getAnchorTextFromDOM(domSelection.anchorNode)) {
|
||
dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, data);
|
||
}
|
||
const textLength = data.length;
|
||
|
||
// Another hack for FF, as it's possible that the IME is still
|
||
// open, even though compositionend has already fired (sigh).
|
||
if (IS_FIREFOX && textLength > 1 && event.inputType === 'insertCompositionText' && !editor.isComposing()) {
|
||
selection.anchor.offset -= textLength;
|
||
}
|
||
|
||
// This ensures consistency on Android.
|
||
if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT && editor.isComposing()) {
|
||
lastKeyDownTimeStamp = 0;
|
||
$setCompositionKey(null);
|
||
}
|
||
} else {
|
||
const characterData = data !== null ? data : undefined;
|
||
$updateSelectedTextFromDOM(false, editor, characterData);
|
||
|
||
// onInput always fires after onCompositionEnd for FF.
|
||
if (isFirefoxEndingComposition) {
|
||
onCompositionEndImpl(editor, data || undefined);
|
||
isFirefoxEndingComposition = false;
|
||
}
|
||
}
|
||
|
||
// Also flush any other mutations that might have occurred
|
||
// since the change.
|
||
$flushMutations();
|
||
});
|
||
unprocessedBeforeInputData = null;
|
||
}
|
||
function onCompositionStart(event, editor) {
|
||
updateEditor(editor, () => {
|
||
const selection = $getSelection();
|
||
if ($isRangeSelection(selection) && !editor.isComposing()) {
|
||
const anchor = selection.anchor;
|
||
const node = selection.anchor.getNode();
|
||
$setCompositionKey(anchor.key);
|
||
if (
|
||
// If it has been 30ms since the last keydown, then we should
|
||
// apply the empty space heuristic. We can't do this for Safari,
|
||
// as the keydown fires after composition start.
|
||
event.timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY ||
|
||
// FF has issues around composing multibyte characters, so we also
|
||
// need to invoke the empty space heuristic below.
|
||
anchor.type === 'element' || !selection.isCollapsed() || node.getFormat() !== selection.format || $isTextNode(node) && node.getStyle() !== selection.style) {
|
||
// We insert a zero width character, ready for the composition
|
||
// to get inserted into the new node we create. If
|
||
// we don't do this, Safari will fail on us because
|
||
// there is no text node matching the selection.
|
||
dispatchCommand(editor, CONTROLLED_TEXT_INSERTION_COMMAND, COMPOSITION_START_CHAR);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
function onCompositionEndImpl(editor, data) {
|
||
const compositionKey = editor._compositionKey;
|
||
$setCompositionKey(null);
|
||
|
||
// Handle termination of composition.
|
||
if (compositionKey !== null && data != null) {
|
||
// Composition can sometimes move to an adjacent DOM node when backspacing.
|
||
// So check for the empty case.
|
||
if (data === '') {
|
||
const node = $getNodeByKey(compositionKey);
|
||
const textNode = getDOMTextNode(editor.getElementByKey(compositionKey));
|
||
if (textNode !== null && textNode.nodeValue !== null && $isTextNode(node)) {
|
||
$updateTextNodeFromDOMContent(node, textNode.nodeValue, null, null, true);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Composition can sometimes be that of a new line. In which case, we need to
|
||
// handle that accordingly.
|
||
if (data[data.length - 1] === '\n') {
|
||
const selection = $getSelection();
|
||
if ($isRangeSelection(selection)) {
|
||
// If the last character is a line break, we also need to insert
|
||
// a line break.
|
||
const focus = selection.focus;
|
||
selection.anchor.set(focus.key, focus.offset, focus.type);
|
||
dispatchCommand(editor, KEY_ENTER_COMMAND, null);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
$updateSelectedTextFromDOM(true, editor, data);
|
||
}
|
||
function onCompositionEnd(event, editor) {
|
||
// Firefox fires onCompositionEnd before onInput, but Chrome/Webkit,
|
||
// fire onInput before onCompositionEnd. To ensure the sequence works
|
||
// like Chrome/Webkit we use the isFirefoxEndingComposition flag to
|
||
// defer handling of onCompositionEnd in Firefox till we have processed
|
||
// the logic in onInput.
|
||
if (IS_FIREFOX) {
|
||
isFirefoxEndingComposition = true;
|
||
} else {
|
||
updateEditor(editor, () => {
|
||
onCompositionEndImpl(editor, event.data);
|
||
});
|
||
}
|
||
}
|
||
function onKeyDown(event, editor) {
|
||
lastKeyDownTimeStamp = event.timeStamp;
|
||
lastKeyCode = event.keyCode;
|
||
if (editor.isComposing()) {
|
||
return;
|
||
}
|
||
const {
|
||
keyCode,
|
||
shiftKey,
|
||
ctrlKey,
|
||
metaKey,
|
||
altKey
|
||
} = event;
|
||
if (dispatchCommand(editor, KEY_DOWN_COMMAND, event)) {
|
||
return;
|
||
}
|
||
if (isMoveForward(keyCode, ctrlKey, altKey, metaKey)) {
|
||
dispatchCommand(editor, KEY_ARROW_RIGHT_COMMAND, event);
|
||
} else if (isMoveToEnd(keyCode, ctrlKey, shiftKey, altKey, metaKey)) {
|
||
dispatchCommand(editor, MOVE_TO_END, event);
|
||
} else if (isMoveBackward(keyCode, ctrlKey, altKey, metaKey)) {
|
||
dispatchCommand(editor, KEY_ARROW_LEFT_COMMAND, event);
|
||
} else if (isMoveToStart(keyCode, ctrlKey, shiftKey, altKey, metaKey)) {
|
||
dispatchCommand(editor, MOVE_TO_START, event);
|
||
} else if (isMoveUp(keyCode, ctrlKey, metaKey)) {
|
||
dispatchCommand(editor, KEY_ARROW_UP_COMMAND, event);
|
||
} else if (isMoveDown(keyCode, ctrlKey, metaKey)) {
|
||
dispatchCommand(editor, KEY_ARROW_DOWN_COMMAND, event);
|
||
} else if (isLineBreak(keyCode, shiftKey)) {
|
||
isInsertLineBreak = true;
|
||
dispatchCommand(editor, KEY_ENTER_COMMAND, event);
|
||
} else if (isSpace(keyCode)) {
|
||
dispatchCommand(editor, KEY_SPACE_COMMAND, event);
|
||
} else if (isOpenLineBreak(keyCode, ctrlKey)) {
|
||
event.preventDefault();
|
||
isInsertLineBreak = true;
|
||
dispatchCommand(editor, INSERT_LINE_BREAK_COMMAND, true);
|
||
} else if (isParagraph(keyCode, shiftKey)) {
|
||
isInsertLineBreak = false;
|
||
dispatchCommand(editor, KEY_ENTER_COMMAND, event);
|
||
} else if (isDeleteBackward(keyCode, altKey, metaKey, ctrlKey)) {
|
||
if (isBackspace(keyCode)) {
|
||
dispatchCommand(editor, KEY_BACKSPACE_COMMAND, event);
|
||
} else {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, DELETE_CHARACTER_COMMAND, true);
|
||
}
|
||
} else if (isEscape(keyCode)) {
|
||
dispatchCommand(editor, KEY_ESCAPE_COMMAND, event);
|
||
} else if (isDeleteForward(keyCode, ctrlKey, shiftKey, altKey, metaKey)) {
|
||
if (isDelete(keyCode)) {
|
||
dispatchCommand(editor, KEY_DELETE_COMMAND, event);
|
||
} else {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, DELETE_CHARACTER_COMMAND, false);
|
||
}
|
||
} else if (isDeleteWordBackward(keyCode, altKey, ctrlKey)) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, DELETE_WORD_COMMAND, true);
|
||
} else if (isDeleteWordForward(keyCode, altKey, ctrlKey)) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, DELETE_WORD_COMMAND, false);
|
||
} else if (isDeleteLineBackward(keyCode, metaKey)) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, DELETE_LINE_COMMAND, true);
|
||
} else if (isDeleteLineForward(keyCode, metaKey)) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, DELETE_LINE_COMMAND, false);
|
||
} else if (isBold(keyCode, altKey, metaKey, ctrlKey)) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'bold');
|
||
} else if (isUnderline(keyCode, altKey, metaKey, ctrlKey)) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'underline');
|
||
} else if (isItalic(keyCode, altKey, metaKey, ctrlKey)) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, FORMAT_TEXT_COMMAND, 'italic');
|
||
} else if (isTab(keyCode, altKey, ctrlKey, metaKey)) {
|
||
dispatchCommand(editor, KEY_TAB_COMMAND, event);
|
||
} else if (isUndo(keyCode, shiftKey, metaKey, ctrlKey)) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, UNDO_COMMAND, undefined);
|
||
} else if (isRedo(keyCode, shiftKey, metaKey, ctrlKey)) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, REDO_COMMAND, undefined);
|
||
} else {
|
||
const prevSelection = editor._editorState._selection;
|
||
if ($isNodeSelection(prevSelection)) {
|
||
if (isCopy(keyCode, shiftKey, metaKey, ctrlKey)) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, COPY_COMMAND, event);
|
||
} else if (isCut(keyCode, shiftKey, metaKey, ctrlKey)) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, CUT_COMMAND, event);
|
||
} else if (isSelectAll(keyCode, metaKey, ctrlKey)) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, SELECT_ALL_COMMAND, event);
|
||
}
|
||
// FF does it well (no need to override behavior)
|
||
} else if (!IS_FIREFOX && isSelectAll(keyCode, metaKey, ctrlKey)) {
|
||
event.preventDefault();
|
||
dispatchCommand(editor, SELECT_ALL_COMMAND, event);
|
||
}
|
||
}
|
||
if (isModifier(ctrlKey, shiftKey, altKey, metaKey)) {
|
||
dispatchCommand(editor, KEY_MODIFIER_COMMAND, event);
|
||
}
|
||
}
|
||
function getRootElementRemoveHandles(rootElement) {
|
||
// @ts-expect-error: internal field
|
||
let eventHandles = rootElement.__lexicalEventHandles;
|
||
if (eventHandles === undefined) {
|
||
eventHandles = [];
|
||
// @ts-expect-error: internal field
|
||
rootElement.__lexicalEventHandles = eventHandles;
|
||
}
|
||
return eventHandles;
|
||
}
|
||
|
||
// Mapping root editors to their active nested editors, contains nested editors
|
||
// mapping only, so if root editor is selected map will have no reference to free up memory
|
||
const activeNestedEditorsMap = new Map();
|
||
function onDocumentSelectionChange(event) {
|
||
const target = event.target;
|
||
const targetWindow = target == null ? null : target.nodeType === 9 ? target.defaultView : target.ownerDocument.defaultView;
|
||
const domSelection = getDOMSelection(targetWindow);
|
||
if (domSelection === null) {
|
||
return;
|
||
}
|
||
const nextActiveEditor = getNearestEditorFromDOMNode(domSelection.anchorNode);
|
||
if (nextActiveEditor === null) {
|
||
return;
|
||
}
|
||
if (isSelectionChangeFromMouseDown) {
|
||
isSelectionChangeFromMouseDown = false;
|
||
updateEditor(nextActiveEditor, () => {
|
||
const lastSelection = $getPreviousSelection();
|
||
const domAnchorNode = domSelection.anchorNode;
|
||
if (domAnchorNode === null) {
|
||
return;
|
||
}
|
||
const nodeType = domAnchorNode.nodeType;
|
||
// If the user is attempting to click selection back onto text, then
|
||
// we should attempt create a range selection.
|
||
// When we click on an empty paragraph node or the end of a paragraph that ends
|
||
// with an image/poll, the nodeType will be ELEMENT_NODE
|
||
if (nodeType !== DOM_ELEMENT_TYPE && nodeType !== DOM_TEXT_TYPE) {
|
||
return;
|
||
}
|
||
const newSelection = internalCreateRangeSelection(lastSelection, domSelection, nextActiveEditor, event);
|
||
$setSelection(newSelection);
|
||
});
|
||
}
|
||
|
||
// When editor receives selection change event, we're checking if
|
||
// it has any sibling editors (within same parent editor) that were active
|
||
// before, and trigger selection change on it to nullify selection.
|
||
const editors = getEditorsToPropagate(nextActiveEditor);
|
||
const rootEditor = editors[editors.length - 1];
|
||
const rootEditorKey = rootEditor._key;
|
||
const activeNestedEditor = activeNestedEditorsMap.get(rootEditorKey);
|
||
const prevActiveEditor = activeNestedEditor || rootEditor;
|
||
if (prevActiveEditor !== nextActiveEditor) {
|
||
onSelectionChange(domSelection, prevActiveEditor, false);
|
||
}
|
||
onSelectionChange(domSelection, nextActiveEditor, true);
|
||
|
||
// If newly selected editor is nested, then add it to the map, clean map otherwise
|
||
if (nextActiveEditor !== rootEditor) {
|
||
activeNestedEditorsMap.set(rootEditorKey, nextActiveEditor);
|
||
} else if (activeNestedEditor) {
|
||
activeNestedEditorsMap.delete(rootEditorKey);
|
||
}
|
||
}
|
||
function stopLexicalPropagation(event) {
|
||
// We attach a special property to ensure the same event doesn't re-fire
|
||
// for parent editors.
|
||
// @ts-ignore
|
||
event._lexicalHandled = true;
|
||
}
|
||
function hasStoppedLexicalPropagation(event) {
|
||
// @ts-ignore
|
||
const stopped = event._lexicalHandled === true;
|
||
return stopped;
|
||
}
|
||
function addRootElementEvents(rootElement, editor) {
|
||
// We only want to have a single global selectionchange event handler, shared
|
||
// between all editor instances.
|
||
if (rootElementsRegistered === 0) {
|
||
const doc = rootElement.ownerDocument;
|
||
doc.addEventListener('selectionchange', onDocumentSelectionChange);
|
||
}
|
||
rootElementsRegistered++;
|
||
// @ts-expect-error: internal field
|
||
rootElement.__lexicalEditor = editor;
|
||
const removeHandles = getRootElementRemoveHandles(rootElement);
|
||
for (let i = 0; i < rootElementEvents.length; i++) {
|
||
const [eventName, onEvent] = rootElementEvents[i];
|
||
const eventHandler = typeof onEvent === 'function' ? event => {
|
||
if (hasStoppedLexicalPropagation(event)) {
|
||
return;
|
||
}
|
||
stopLexicalPropagation(event);
|
||
if (editor.isEditable()) {
|
||
onEvent(event, editor);
|
||
}
|
||
} : event => {
|
||
if (hasStoppedLexicalPropagation(event)) {
|
||
return;
|
||
}
|
||
stopLexicalPropagation(event);
|
||
if (editor.isEditable()) {
|
||
switch (eventName) {
|
||
case 'cut':
|
||
return dispatchCommand(editor, CUT_COMMAND, event);
|
||
case 'copy':
|
||
return dispatchCommand(editor, COPY_COMMAND, event);
|
||
case 'paste':
|
||
return dispatchCommand(editor, PASTE_COMMAND, event);
|
||
case 'dragstart':
|
||
return dispatchCommand(editor, DRAGSTART_COMMAND, event);
|
||
case 'dragover':
|
||
return dispatchCommand(editor, DRAGOVER_COMMAND, event);
|
||
case 'dragend':
|
||
return dispatchCommand(editor, DRAGEND_COMMAND, event);
|
||
case 'focus':
|
||
return dispatchCommand(editor, FOCUS_COMMAND, event);
|
||
case 'blur':
|
||
{
|
||
return dispatchCommand(editor, BLUR_COMMAND, event);
|
||
}
|
||
case 'drop':
|
||
return dispatchCommand(editor, DROP_COMMAND, event);
|
||
}
|
||
}
|
||
};
|
||
rootElement.addEventListener(eventName, eventHandler);
|
||
removeHandles.push(() => {
|
||
rootElement.removeEventListener(eventName, eventHandler);
|
||
});
|
||
}
|
||
}
|
||
function removeRootElementEvents(rootElement) {
|
||
if (rootElementsRegistered !== 0) {
|
||
rootElementsRegistered--;
|
||
|
||
// We only want to have a single global selectionchange event handler, shared
|
||
// between all editor instances.
|
||
if (rootElementsRegistered === 0) {
|
||
const doc = rootElement.ownerDocument;
|
||
doc.removeEventListener('selectionchange', onDocumentSelectionChange);
|
||
}
|
||
}
|
||
|
||
// @ts-expect-error: internal field
|
||
const editor = rootElement.__lexicalEditor;
|
||
if (editor !== null && editor !== undefined) {
|
||
cleanActiveNestedEditorsMap(editor);
|
||
// @ts-expect-error: internal field
|
||
rootElement.__lexicalEditor = null;
|
||
}
|
||
const removeHandles = getRootElementRemoveHandles(rootElement);
|
||
for (let i = 0; i < removeHandles.length; i++) {
|
||
removeHandles[i]();
|
||
}
|
||
|
||
// @ts-expect-error: internal field
|
||
rootElement.__lexicalEventHandles = [];
|
||
}
|
||
function cleanActiveNestedEditorsMap(editor) {
|
||
if (editor._parentEditor !== null) {
|
||
// For nested editor cleanup map if this editor was marked as active
|
||
const editors = getEditorsToPropagate(editor);
|
||
const rootEditor = editors[editors.length - 1];
|
||
const rootEditorKey = rootEditor._key;
|
||
if (activeNestedEditorsMap.get(rootEditorKey) === editor) {
|
||
activeNestedEditorsMap.delete(rootEditorKey);
|
||
}
|
||
} else {
|
||
// For top-level editors cleanup map
|
||
activeNestedEditorsMap.delete(editor._key);
|
||
}
|
||
}
|
||
function markSelectionChangeFromDOMUpdate() {
|
||
isSelectionChangeFromDOMUpdate = true;
|
||
}
|
||
function markCollapsedSelectionFormat(format, style, offset, key, timeStamp) {
|
||
collapsedSelectionFormat = [format, style, offset, key, timeStamp];
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
function removeNode(nodeToRemove, restoreSelection, preserveEmptyParent) {
|
||
errorOnReadOnly();
|
||
const key = nodeToRemove.__key;
|
||
const parent = nodeToRemove.getParent();
|
||
if (parent === null) {
|
||
return;
|
||
}
|
||
const selection = $maybeMoveChildrenSelectionToParent(nodeToRemove);
|
||
let selectionMoved = false;
|
||
if ($isRangeSelection(selection) && restoreSelection) {
|
||
const anchor = selection.anchor;
|
||
const focus = selection.focus;
|
||
if (anchor.key === key) {
|
||
moveSelectionPointToSibling(anchor, nodeToRemove, parent, nodeToRemove.getPreviousSibling(), nodeToRemove.getNextSibling());
|
||
selectionMoved = true;
|
||
}
|
||
if (focus.key === key) {
|
||
moveSelectionPointToSibling(focus, nodeToRemove, parent, nodeToRemove.getPreviousSibling(), nodeToRemove.getNextSibling());
|
||
selectionMoved = true;
|
||
}
|
||
} else if ($isNodeSelection(selection) && restoreSelection && nodeToRemove.isSelected()) {
|
||
nodeToRemove.selectPrevious();
|
||
}
|
||
if ($isRangeSelection(selection) && restoreSelection && !selectionMoved) {
|
||
// Doing this is O(n) so lets avoid it unless we need to do it
|
||
const index = nodeToRemove.getIndexWithinParent();
|
||
removeFromParent(nodeToRemove);
|
||
$updateElementSelectionOnCreateDeleteNode(selection, parent, index, -1);
|
||
} else {
|
||
removeFromParent(nodeToRemove);
|
||
}
|
||
if (!preserveEmptyParent && !$isRootOrShadowRoot(parent) && !parent.canBeEmpty() && parent.isEmpty()) {
|
||
removeNode(parent, restoreSelection);
|
||
}
|
||
if (restoreSelection && $isRootNode(parent) && parent.isEmpty()) {
|
||
parent.selectEnd();
|
||
}
|
||
}
|
||
class LexicalNode {
|
||
// Allow us to look up the type including static props
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */ //@ts-ignore We set the key in the constructor.
|
||
/** @internal */
|
||
/** @internal */
|
||
/** @internal */
|
||
// Flow doesn't support abstract classes unfortunately, so we can't _force_
|
||
// subclasses of Node to implement statics. All subclasses of Node should have
|
||
// a static getType and clone method though. We define getType and clone here so we can call it
|
||
// on any Node, and we throw this error by default since the subclass should provide
|
||
// their own implementation.
|
||
/**
|
||
* Returns the string type of this node. Every node must
|
||
* implement this and it MUST BE UNIQUE amongst nodes registered
|
||
* on the editor.
|
||
*
|
||
*/
|
||
static getType() {
|
||
{
|
||
throw Error(`LexicalNode: Node ${this.name} does not implement .getType().`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clones this node, creating a new node with a different key
|
||
* and adding it to the EditorState (but not attaching it anywhere!). All nodes must
|
||
* implement this method.
|
||
*
|
||
*/
|
||
static clone(_data) {
|
||
{
|
||
throw Error(`LexicalNode: Node ${this.name} does not implement .clone().`);
|
||
}
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
||
constructor(key) {
|
||
this.__type = this.constructor.getType();
|
||
this.__parent = null;
|
||
this.__prev = null;
|
||
this.__next = null;
|
||
$setNodeKey(this, key);
|
||
{
|
||
if (this.__type !== 'root') {
|
||
errorOnReadOnly();
|
||
errorOnTypeKlassMismatch(this.__type, this.constructor);
|
||
}
|
||
}
|
||
}
|
||
// Getters and Traversers
|
||
|
||
/**
|
||
* Returns the string type of this node.
|
||
*/
|
||
getType() {
|
||
return this.__type;
|
||
}
|
||
isInline() {
|
||
{
|
||
throw Error(`LexicalNode: Node ${this.constructor.name} does not implement .isInline().`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns true if there is a path between this node and the RootNode, false otherwise.
|
||
* This is a way of determining if the node is "attached" EditorState. Unattached nodes
|
||
* won't be reconciled and will ultimatelt be cleaned up by the Lexical GC.
|
||
*/
|
||
isAttached() {
|
||
let nodeKey = this.__key;
|
||
while (nodeKey !== null) {
|
||
if (nodeKey === 'root') {
|
||
return true;
|
||
}
|
||
const node = $getNodeByKey(nodeKey);
|
||
if (node === null) {
|
||
break;
|
||
}
|
||
nodeKey = node.__parent;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Returns true if this node is contained within the provided Selection., false otherwise.
|
||
* Relies on the algorithms implemented in {@link BaseSelection.getNodes} to determine
|
||
* what's included.
|
||
*
|
||
* @param selection - The selection that we want to determine if the node is in.
|
||
*/
|
||
isSelected(selection) {
|
||
const targetSelection = selection || $getSelection();
|
||
if (targetSelection == null) {
|
||
return false;
|
||
}
|
||
const isSelected = targetSelection.getNodes().some(n => n.__key === this.__key);
|
||
if ($isTextNode(this)) {
|
||
return isSelected;
|
||
}
|
||
// For inline images inside of element nodes.
|
||
// Without this change the image will be selected if the cursor is before or after it.
|
||
if ($isRangeSelection(targetSelection) && targetSelection.anchor.type === 'element' && targetSelection.focus.type === 'element' && targetSelection.anchor.key === targetSelection.focus.key && targetSelection.anchor.offset === targetSelection.focus.offset) {
|
||
return false;
|
||
}
|
||
return isSelected;
|
||
}
|
||
|
||
/**
|
||
* Returns this nodes key.
|
||
*/
|
||
getKey() {
|
||
// Key is stable between copies
|
||
return this.__key;
|
||
}
|
||
|
||
/**
|
||
* Returns the zero-based index of this node within the parent.
|
||
*/
|
||
getIndexWithinParent() {
|
||
const parent = this.getParent();
|
||
if (parent === null) {
|
||
return -1;
|
||
}
|
||
let node = parent.getFirstChild();
|
||
let index = 0;
|
||
while (node !== null) {
|
||
if (this.is(node)) {
|
||
return index;
|
||
}
|
||
index++;
|
||
node = node.getNextSibling();
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
/**
|
||
* Returns the parent of this node, or null if none is found.
|
||
*/
|
||
getParent() {
|
||
const parent = this.getLatest().__parent;
|
||
if (parent === null) {
|
||
return null;
|
||
}
|
||
return $getNodeByKey(parent);
|
||
}
|
||
|
||
/**
|
||
* Returns the parent of this node, or throws if none is found.
|
||
*/
|
||
getParentOrThrow() {
|
||
const parent = this.getParent();
|
||
if (parent === null) {
|
||
{
|
||
throw Error(`Expected node ${this.__key} to have a parent.`);
|
||
}
|
||
}
|
||
return parent;
|
||
}
|
||
|
||
/**
|
||
* Returns the highest (in the EditorState tree)
|
||
* non-root ancestor of this node, or null if none is found. See {@link lexical!$isRootOrShadowRoot}
|
||
* for more information on which Elements comprise "roots".
|
||
*/
|
||
getTopLevelElement() {
|
||
let node = this;
|
||
while (node !== null) {
|
||
const parent = node.getParent();
|
||
if ($isRootOrShadowRoot(parent)) {
|
||
if (!$isElementNode(node)) {
|
||
throw Error(`Children of root nodes must be elements`);
|
||
}
|
||
return node;
|
||
}
|
||
node = parent;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Returns the highest (in the EditorState tree)
|
||
* non-root ancestor of this node, or throws if none is found. See {@link lexical!$isRootOrShadowRoot}
|
||
* for more information on which Elements comprise "roots".
|
||
*/
|
||
getTopLevelElementOrThrow() {
|
||
const parent = this.getTopLevelElement();
|
||
if (parent === null) {
|
||
{
|
||
throw Error(`Expected node ${this.__key} to have a top parent element.`);
|
||
}
|
||
}
|
||
return parent;
|
||
}
|
||
|
||
/**
|
||
* Returns a list of the every ancestor of this node,
|
||
* all the way up to the RootNode.
|
||
*
|
||
*/
|
||
getParents() {
|
||
const parents = [];
|
||
let node = this.getParent();
|
||
while (node !== null) {
|
||
parents.push(node);
|
||
node = node.getParent();
|
||
}
|
||
return parents;
|
||
}
|
||
|
||
/**
|
||
* Returns a list of the keys of every ancestor of this node,
|
||
* all the way up to the RootNode.
|
||
*
|
||
*/
|
||
getParentKeys() {
|
||
const parents = [];
|
||
let node = this.getParent();
|
||
while (node !== null) {
|
||
parents.push(node.__key);
|
||
node = node.getParent();
|
||
}
|
||
return parents;
|
||
}
|
||
|
||
/**
|
||
* Returns the "previous" siblings - that is, the node that comes
|
||
* before this one in the same parent.
|
||
*
|
||
*/
|
||
getPreviousSibling() {
|
||
const self = this.getLatest();
|
||
const prevKey = self.__prev;
|
||
return prevKey === null ? null : $getNodeByKey(prevKey);
|
||
}
|
||
|
||
/**
|
||
* Returns the "previous" siblings - that is, the nodes that come between
|
||
* this one and the first child of it's parent, inclusive.
|
||
*
|
||
*/
|
||
getPreviousSiblings() {
|
||
const siblings = [];
|
||
const parent = this.getParent();
|
||
if (parent === null) {
|
||
return siblings;
|
||
}
|
||
let node = parent.getFirstChild();
|
||
while (node !== null) {
|
||
if (node.is(this)) {
|
||
break;
|
||
}
|
||
siblings.push(node);
|
||
node = node.getNextSibling();
|
||
}
|
||
return siblings;
|
||
}
|
||
|
||
/**
|
||
* Returns the "next" siblings - that is, the node that comes
|
||
* after this one in the same parent
|
||
*
|
||
*/
|
||
getNextSibling() {
|
||
const self = this.getLatest();
|
||
const nextKey = self.__next;
|
||
return nextKey === null ? null : $getNodeByKey(nextKey);
|
||
}
|
||
|
||
/**
|
||
* Returns all "next" siblings - that is, the nodes that come between this
|
||
* one and the last child of it's parent, inclusive.
|
||
*
|
||
*/
|
||
getNextSiblings() {
|
||
const siblings = [];
|
||
let node = this.getNextSibling();
|
||
while (node !== null) {
|
||
siblings.push(node);
|
||
node = node.getNextSibling();
|
||
}
|
||
return siblings;
|
||
}
|
||
|
||
/**
|
||
* Returns the closest common ancestor of this node and the provided one or null
|
||
* if one cannot be found.
|
||
*
|
||
* @param node - the other node to find the common ancestor of.
|
||
*/
|
||
getCommonAncestor(node) {
|
||
const a = this.getParents();
|
||
const b = node.getParents();
|
||
if ($isElementNode(this)) {
|
||
a.unshift(this);
|
||
}
|
||
if ($isElementNode(node)) {
|
||
b.unshift(node);
|
||
}
|
||
const aLength = a.length;
|
||
const bLength = b.length;
|
||
if (aLength === 0 || bLength === 0 || a[aLength - 1] !== b[bLength - 1]) {
|
||
return null;
|
||
}
|
||
const bSet = new Set(b);
|
||
for (let i = 0; i < aLength; i++) {
|
||
const ancestor = a[i];
|
||
if (bSet.has(ancestor)) {
|
||
return ancestor;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Returns true if the provided node is the exact same one as this node, from Lexical's perspective.
|
||
* Always use this instead of referential equality.
|
||
*
|
||
* @param object - the node to perform the equality comparison on.
|
||
*/
|
||
is(object) {
|
||
if (object == null) {
|
||
return false;
|
||
}
|
||
return this.__key === object.__key;
|
||
}
|
||
|
||
/**
|
||
* Returns true if this node logical precedes the target node in the editor state.
|
||
*
|
||
* @param targetNode - the node we're testing to see if it's after this one.
|
||
*/
|
||
isBefore(targetNode) {
|
||
if (this === targetNode) {
|
||
return false;
|
||
}
|
||
if (targetNode.isParentOf(this)) {
|
||
return true;
|
||
}
|
||
if (this.isParentOf(targetNode)) {
|
||
return false;
|
||
}
|
||
const commonAncestor = this.getCommonAncestor(targetNode);
|
||
let indexA = 0;
|
||
let indexB = 0;
|
||
let node = this;
|
||
while (true) {
|
||
const parent = node.getParentOrThrow();
|
||
if (parent === commonAncestor) {
|
||
indexA = node.getIndexWithinParent();
|
||
break;
|
||
}
|
||
node = parent;
|
||
}
|
||
node = targetNode;
|
||
while (true) {
|
||
const parent = node.getParentOrThrow();
|
||
if (parent === commonAncestor) {
|
||
indexB = node.getIndexWithinParent();
|
||
break;
|
||
}
|
||
node = parent;
|
||
}
|
||
return indexA < indexB;
|
||
}
|
||
|
||
/**
|
||
* Returns true if this node is the parent of the target node, false otherwise.
|
||
*
|
||
* @param targetNode - the would-be child node.
|
||
*/
|
||
isParentOf(targetNode) {
|
||
const key = this.__key;
|
||
if (key === targetNode.__key) {
|
||
return false;
|
||
}
|
||
let node = targetNode;
|
||
while (node !== null) {
|
||
if (node.__key === key) {
|
||
return true;
|
||
}
|
||
node = node.getParent();
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// TO-DO: this function can be simplified a lot
|
||
/**
|
||
* Returns a list of nodes that are between this node and
|
||
* the target node in the EditorState.
|
||
*
|
||
* @param targetNode - the node that marks the other end of the range of nodes to be returned.
|
||
*/
|
||
getNodesBetween(targetNode) {
|
||
const isBefore = this.isBefore(targetNode);
|
||
const nodes = [];
|
||
const visited = new Set();
|
||
let node = this;
|
||
while (true) {
|
||
const key = node.__key;
|
||
if (!visited.has(key)) {
|
||
visited.add(key);
|
||
nodes.push(node);
|
||
}
|
||
if (node === targetNode) {
|
||
break;
|
||
}
|
||
const child = $isElementNode(node) ? isBefore ? node.getFirstChild() : node.getLastChild() : null;
|
||
if (child !== null) {
|
||
node = child;
|
||
continue;
|
||
}
|
||
const nextSibling = isBefore ? node.getNextSibling() : node.getPreviousSibling();
|
||
if (nextSibling !== null) {
|
||
node = nextSibling;
|
||
continue;
|
||
}
|
||
const parent = node.getParentOrThrow();
|
||
if (!visited.has(parent.__key)) {
|
||
nodes.push(parent);
|
||
}
|
||
if (parent === targetNode) {
|
||
break;
|
||
}
|
||
let parentSibling = null;
|
||
let ancestor = parent;
|
||
do {
|
||
if (ancestor === null) {
|
||
{
|
||
throw Error(`getNodesBetween: ancestor is null`);
|
||
}
|
||
}
|
||
parentSibling = isBefore ? ancestor.getNextSibling() : ancestor.getPreviousSibling();
|
||
ancestor = ancestor.getParent();
|
||
if (ancestor !== null) {
|
||
if (parentSibling === null && !visited.has(ancestor.__key)) {
|
||
nodes.push(ancestor);
|
||
}
|
||
}
|
||
} while (parentSibling === null);
|
||
node = parentSibling;
|
||
}
|
||
if (!isBefore) {
|
||
nodes.reverse();
|
||
}
|
||
return nodes;
|
||
}
|
||
|
||
/**
|
||
* Returns true if this node has been marked dirty during this update cycle.
|
||
*
|
||
*/
|
||
isDirty() {
|
||
const editor = getActiveEditor();
|
||
const dirtyLeaves = editor._dirtyLeaves;
|
||
return dirtyLeaves !== null && dirtyLeaves.has(this.__key);
|
||
}
|
||
|
||
/**
|
||
* Returns the latest version of the node from the active EditorState.
|
||
* This is used to avoid getting values from stale node references.
|
||
*
|
||
*/
|
||
getLatest() {
|
||
const latest = $getNodeByKey(this.__key);
|
||
if (latest === null) {
|
||
{
|
||
throw Error(`Lexical node does not exist in active editor state. Avoid using the same node references between nested closures from editorState.read/editor.update.`);
|
||
}
|
||
}
|
||
return latest;
|
||
}
|
||
|
||
/**
|
||
* Returns a mutable version of the node. Will throw an error if
|
||
* called outside of a Lexical Editor {@link LexicalEditor.update} callback.
|
||
*
|
||
*/
|
||
getWritable() {
|
||
errorOnReadOnly();
|
||
const editorState = getActiveEditorState();
|
||
const editor = getActiveEditor();
|
||
const nodeMap = editorState._nodeMap;
|
||
const key = this.__key;
|
||
// Ensure we get the latest node from pending state
|
||
const latestNode = this.getLatest();
|
||
const parent = latestNode.__parent;
|
||
const cloneNotNeeded = editor._cloneNotNeeded;
|
||
const selection = $getSelection();
|
||
if (selection !== null) {
|
||
selection.setCachedNodes(null);
|
||
}
|
||
if (cloneNotNeeded.has(key)) {
|
||
// Transforms clear the dirty node set on each iteration to keep track on newly dirty nodes
|
||
internalMarkNodeAsDirty(latestNode);
|
||
return latestNode;
|
||
}
|
||
const constructor = latestNode.constructor;
|
||
const mutableNode = constructor.clone(latestNode);
|
||
mutableNode.__parent = parent;
|
||
mutableNode.__next = latestNode.__next;
|
||
mutableNode.__prev = latestNode.__prev;
|
||
if ($isElementNode(latestNode) && $isElementNode(mutableNode)) {
|
||
mutableNode.__first = latestNode.__first;
|
||
mutableNode.__last = latestNode.__last;
|
||
mutableNode.__size = latestNode.__size;
|
||
mutableNode.__indent = latestNode.__indent;
|
||
mutableNode.__format = latestNode.__format;
|
||
mutableNode.__dir = latestNode.__dir;
|
||
} else if ($isTextNode(latestNode) && $isTextNode(mutableNode)) {
|
||
mutableNode.__format = latestNode.__format;
|
||
mutableNode.__style = latestNode.__style;
|
||
mutableNode.__mode = latestNode.__mode;
|
||
mutableNode.__detail = latestNode.__detail;
|
||
}
|
||
cloneNotNeeded.add(key);
|
||
mutableNode.__key = key;
|
||
internalMarkNodeAsDirty(mutableNode);
|
||
// Update reference in node map
|
||
nodeMap.set(key, mutableNode);
|
||
|
||
// @ts-expect-error
|
||
return mutableNode;
|
||
}
|
||
|
||
/**
|
||
* Returns the text content of the node. Override this for
|
||
* custom nodes that should have a representation in plain text
|
||
* format (for copy + paste, for example)
|
||
*
|
||
*/
|
||
getTextContent() {
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* Returns the length of the string produced by calling getTextContent on this node.
|
||
*
|
||
*/
|
||
getTextContentSize() {
|
||
return this.getTextContent().length;
|
||
}
|
||
|
||
// View
|
||
|
||
/**
|
||
* Called during the reconciliation process to determine which nodes
|
||
* to insert into the DOM for this Lexical Node.
|
||
*
|
||
* This method must return exactly one HTMLElement. Nested elements are not supported.
|
||
*
|
||
* Do not attempt to update the Lexical EditorState during this phase of the update lifecyle.
|
||
*
|
||
* @param _config - allows access to things like the EditorTheme (to apply classes) during reconciliation.
|
||
* @param _editor - allows access to the editor for context during reconciliation.
|
||
*
|
||
* */
|
||
createDOM(_config, _editor) {
|
||
{
|
||
throw Error(`createDOM: base method not extended`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Called when a node changes and should update the DOM
|
||
* in whatever way is necessary to make it align with any changes that might
|
||
* have happened during the update.
|
||
*
|
||
* Returning "true" here will cause lexical to unmount and recreate the DOM node
|
||
* (by calling createDOM). You would need to do this if the element tag changes,
|
||
* for instance.
|
||
*
|
||
* */
|
||
updateDOM(_prevNode, _dom, _config) {
|
||
{
|
||
throw Error(`updateDOM: base method not extended`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Controls how the this node is serialized to HTML. This is important for
|
||
* copy and paste between Lexical and non-Lexical editors, or Lexical editors with different namespaces,
|
||
* in which case the primary transfer format is HTML. It's also important if you're serializing
|
||
* to HTML for any other reason via {@link @lexical/html!$generateHtmlFromNodes}. You could
|
||
* also use this method to build your own HTML renderer.
|
||
*
|
||
* */
|
||
exportDOM(editor) {
|
||
const element = this.createDOM(editor._config, editor);
|
||
return {
|
||
element
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Controls how the this node is serialized to JSON. This is important for
|
||
* copy and paste between Lexical editors sharing the same namespace. It's also important
|
||
* if you're serializing to JSON for persistent storage somewhere.
|
||
* See [Serialization & Deserialization](https://lexical.dev/docs/concepts/serialization#lexical---html).
|
||
*
|
||
* */
|
||
exportJSON() {
|
||
{
|
||
throw Error(`exportJSON: base method not extended`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Controls how the this node is deserialized from JSON. This is usually boilerplate,
|
||
* but provides an abstraction between the node implementation and serialized interface that can
|
||
* be important if you ever make breaking changes to a node schema (by adding or removing properties).
|
||
* See [Serialization & Deserialization](https://lexical.dev/docs/concepts/serialization#lexical---html).
|
||
*
|
||
* */
|
||
static importJSON(_serializedNode) {
|
||
{
|
||
throw Error(`LexicalNode: Node ${this.name} does not implement .importJSON().`);
|
||
}
|
||
}
|
||
/**
|
||
* @experimental
|
||
*
|
||
* Registers the returned function as a transform on the node during
|
||
* Editor initialization. Most such use cases should be addressed via
|
||
* the {@link LexicalEditor.registerNodeTransform} API.
|
||
*
|
||
* Experimental - use at your own risk.
|
||
*/
|
||
static transform() {
|
||
return null;
|
||
}
|
||
|
||
// Setters and mutators
|
||
|
||
/**
|
||
* Removes this LexicalNode from the EditorState. If the node isn't re-inserted
|
||
* somewhere, the Lexical garbage collector will eventually clean it up.
|
||
*
|
||
* @param preserveEmptyParent - If falsy, the node's parent will be removed if
|
||
* it's empty after the removal operation. This is the default behavior, subject to
|
||
* other node heuristics such as {@link ElementNode#canBeEmpty}
|
||
* */
|
||
remove(preserveEmptyParent) {
|
||
removeNode(this, true, preserveEmptyParent);
|
||
}
|
||
|
||
/**
|
||
* Replaces this LexicalNode with the provided node, optionally transferring the children
|
||
* of the replaced node to the replacing node.
|
||
*
|
||
* @param replaceWith - The node to replace this one with.
|
||
* @param includeChildren - Whether or not to transfer the children of this node to the replacing node.
|
||
* */
|
||
replace(replaceWith, includeChildren) {
|
||
errorOnReadOnly();
|
||
let selection = $getSelection();
|
||
if (selection !== null) selection = selection.clone();
|
||
errorOnInsertTextNodeOnRoot(this, replaceWith);
|
||
const self = this.getLatest();
|
||
const toReplaceKey = this.__key;
|
||
const key = replaceWith.__key;
|
||
const writableReplaceWith = replaceWith.getWritable();
|
||
const writableParent = this.getParentOrThrow().getWritable();
|
||
const size = writableParent.__size;
|
||
removeFromParent(writableReplaceWith);
|
||
const prevSibling = self.getPreviousSibling();
|
||
const nextSibling = self.getNextSibling();
|
||
const prevKey = self.__prev;
|
||
const nextKey = self.__next;
|
||
const parentKey = self.__parent;
|
||
removeNode(self, false, true);
|
||
if (prevSibling === null) {
|
||
writableParent.__first = key;
|
||
} else {
|
||
const writablePrevSibling = prevSibling.getWritable();
|
||
writablePrevSibling.__next = key;
|
||
}
|
||
writableReplaceWith.__prev = prevKey;
|
||
if (nextSibling === null) {
|
||
writableParent.__last = key;
|
||
} else {
|
||
const writableNextSibling = nextSibling.getWritable();
|
||
writableNextSibling.__prev = key;
|
||
}
|
||
writableReplaceWith.__next = nextKey;
|
||
writableReplaceWith.__parent = parentKey;
|
||
writableParent.__size = size;
|
||
if (includeChildren) {
|
||
if (!($isElementNode(this) && $isElementNode(writableReplaceWith))) {
|
||
throw Error(`includeChildren should only be true for ElementNodes`);
|
||
}
|
||
this.getChildren().forEach(child => {
|
||
writableReplaceWith.append(child);
|
||
});
|
||
}
|
||
if ($isRangeSelection(selection)) {
|
||
$setSelection(selection);
|
||
const anchor = selection.anchor;
|
||
const focus = selection.focus;
|
||
if (anchor.key === toReplaceKey) {
|
||
$moveSelectionPointToEnd(anchor, writableReplaceWith);
|
||
}
|
||
if (focus.key === toReplaceKey) {
|
||
$moveSelectionPointToEnd(focus, writableReplaceWith);
|
||
}
|
||
}
|
||
if ($getCompositionKey() === toReplaceKey) {
|
||
$setCompositionKey(key);
|
||
}
|
||
return writableReplaceWith;
|
||
}
|
||
|
||
/**
|
||
* Inserts a node after this LexicalNode (as the next sibling).
|
||
*
|
||
* @param nodeToInsert - The node to insert after this one.
|
||
* @param restoreSelection - Whether or not to attempt to resolve the
|
||
* selection to the appropriate place after the operation is complete.
|
||
* */
|
||
insertAfter(nodeToInsert, restoreSelection = true) {
|
||
errorOnReadOnly();
|
||
errorOnInsertTextNodeOnRoot(this, nodeToInsert);
|
||
const writableSelf = this.getWritable();
|
||
const writableNodeToInsert = nodeToInsert.getWritable();
|
||
const oldParent = writableNodeToInsert.getParent();
|
||
const selection = $getSelection();
|
||
let elementAnchorSelectionOnNode = false;
|
||
let elementFocusSelectionOnNode = false;
|
||
if (oldParent !== null) {
|
||
// TODO: this is O(n), can we improve?
|
||
const oldIndex = nodeToInsert.getIndexWithinParent();
|
||
removeFromParent(writableNodeToInsert);
|
||
if ($isRangeSelection(selection)) {
|
||
const oldParentKey = oldParent.__key;
|
||
const anchor = selection.anchor;
|
||
const focus = selection.focus;
|
||
elementAnchorSelectionOnNode = anchor.type === 'element' && anchor.key === oldParentKey && anchor.offset === oldIndex + 1;
|
||
elementFocusSelectionOnNode = focus.type === 'element' && focus.key === oldParentKey && focus.offset === oldIndex + 1;
|
||
}
|
||
}
|
||
const nextSibling = this.getNextSibling();
|
||
const writableParent = this.getParentOrThrow().getWritable();
|
||
const insertKey = writableNodeToInsert.__key;
|
||
const nextKey = writableSelf.__next;
|
||
if (nextSibling === null) {
|
||
writableParent.__last = insertKey;
|
||
} else {
|
||
const writableNextSibling = nextSibling.getWritable();
|
||
writableNextSibling.__prev = insertKey;
|
||
}
|
||
writableParent.__size++;
|
||
writableSelf.__next = insertKey;
|
||
writableNodeToInsert.__next = nextKey;
|
||
writableNodeToInsert.__prev = writableSelf.__key;
|
||
writableNodeToInsert.__parent = writableSelf.__parent;
|
||
if (restoreSelection && $isRangeSelection(selection)) {
|
||
const index = this.getIndexWithinParent();
|
||
$updateElementSelectionOnCreateDeleteNode(selection, writableParent, index + 1);
|
||
const writableParentKey = writableParent.__key;
|
||
if (elementAnchorSelectionOnNode) {
|
||
selection.anchor.set(writableParentKey, index + 2, 'element');
|
||
}
|
||
if (elementFocusSelectionOnNode) {
|
||
selection.focus.set(writableParentKey, index + 2, 'element');
|
||
}
|
||
}
|
||
return nodeToInsert;
|
||
}
|
||
|
||
/**
|
||
* Inserts a node before this LexicalNode (as the previous sibling).
|
||
*
|
||
* @param nodeToInsert - The node to insert before this one.
|
||
* @param restoreSelection - Whether or not to attempt to resolve the
|
||
* selection to the appropriate place after the operation is complete.
|
||
* */
|
||
insertBefore(nodeToInsert, restoreSelection = true) {
|
||
errorOnReadOnly();
|
||
errorOnInsertTextNodeOnRoot(this, nodeToInsert);
|
||
const writableSelf = this.getWritable();
|
||
const writableNodeToInsert = nodeToInsert.getWritable();
|
||
const insertKey = writableNodeToInsert.__key;
|
||
removeFromParent(writableNodeToInsert);
|
||
const prevSibling = this.getPreviousSibling();
|
||
const writableParent = this.getParentOrThrow().getWritable();
|
||
const prevKey = writableSelf.__prev;
|
||
// TODO: this is O(n), can we improve?
|
||
const index = this.getIndexWithinParent();
|
||
if (prevSibling === null) {
|
||
writableParent.__first = insertKey;
|
||
} else {
|
||
const writablePrevSibling = prevSibling.getWritable();
|
||
writablePrevSibling.__next = insertKey;
|
||
}
|
||
writableParent.__size++;
|
||
writableSelf.__prev = insertKey;
|
||
writableNodeToInsert.__prev = prevKey;
|
||
writableNodeToInsert.__next = writableSelf.__key;
|
||
writableNodeToInsert.__parent = writableSelf.__parent;
|
||
const selection = $getSelection();
|
||
if (restoreSelection && $isRangeSelection(selection)) {
|
||
const parent = this.getParentOrThrow();
|
||
$updateElementSelectionOnCreateDeleteNode(selection, parent, index);
|
||
}
|
||
return nodeToInsert;
|
||
}
|
||
|
||
/**
|
||
* Whether or not this node has a required parent. Used during copy + paste operations
|
||
* to normalize nodes that would otherwise be orphaned. For example, ListItemNodes without
|
||
* a ListNode parent or TextNodes with a ParagraphNode parent.
|
||
*
|
||
* */
|
||
isParentRequired() {
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* The creation logic for any required parent. Should be implemented if {@link isParentRequired} returns true.
|
||
*
|
||
* */
|
||
createParentElementNode() {
|
||
return $createParagraphNode();
|
||
}
|
||
selectStart() {
|
||
return this.selectPrevious();
|
||
}
|
||
selectEnd() {
|
||
return this.selectNext(0, 0);
|
||
}
|
||
|
||
/**
|
||
* Moves selection to the previous sibling of this node, at the specified offsets.
|
||
*
|
||
* @param anchorOffset - The anchor offset for selection.
|
||
* @param focusOffset - The focus offset for selection
|
||
* */
|
||
selectPrevious(anchorOffset, focusOffset) {
|
||
errorOnReadOnly();
|
||
const prevSibling = this.getPreviousSibling();
|
||
const parent = this.getParentOrThrow();
|
||
if (prevSibling === null) {
|
||
return parent.select(0, 0);
|
||
}
|
||
if ($isElementNode(prevSibling)) {
|
||
return prevSibling.select();
|
||
} else if (!$isTextNode(prevSibling)) {
|
||
const index = prevSibling.getIndexWithinParent() + 1;
|
||
return parent.select(index, index);
|
||
}
|
||
return prevSibling.select(anchorOffset, focusOffset);
|
||
}
|
||
|
||
/**
|
||
* Moves selection to the next sibling of this node, at the specified offsets.
|
||
*
|
||
* @param anchorOffset - The anchor offset for selection.
|
||
* @param focusOffset - The focus offset for selection
|
||
* */
|
||
selectNext(anchorOffset, focusOffset) {
|
||
errorOnReadOnly();
|
||
const nextSibling = this.getNextSibling();
|
||
const parent = this.getParentOrThrow();
|
||
if (nextSibling === null) {
|
||
return parent.select();
|
||
}
|
||
if ($isElementNode(nextSibling)) {
|
||
return nextSibling.select(0, 0);
|
||
} else if (!$isTextNode(nextSibling)) {
|
||
const index = nextSibling.getIndexWithinParent();
|
||
return parent.select(index, index);
|
||
}
|
||
return nextSibling.select(anchorOffset, focusOffset);
|
||
}
|
||
|
||
/**
|
||
* Marks a node dirty, triggering transforms and
|
||
* forcing it to be reconciled during the update cycle.
|
||
*
|
||
* */
|
||
markDirty() {
|
||
this.getWritable();
|
||
}
|
||
}
|
||
function errorOnTypeKlassMismatch(type, klass) {
|
||
const registeredNode = getActiveEditor()._nodes.get(type);
|
||
// Common error - split in its own invariant
|
||
if (registeredNode === undefined) {
|
||
{
|
||
throw Error(`Create node: Attempted to create node ${klass.name} that was not configured to be used on the editor.`);
|
||
}
|
||
}
|
||
const editorKlass = registeredNode.klass;
|
||
if (editorKlass !== klass) {
|
||
{
|
||
throw Error(`Create node: Type ${type} in node ${klass.name} does not match registered node ${editorKlass.name} with the same type`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Insert a series of nodes after this LexicalNode (as next siblings)
|
||
*
|
||
* @param firstToInsert - The first node to insert after this one.
|
||
* @param lastToInsert - The last node to insert after this one. Must be a
|
||
* later sibling of FirstNode. If not provided, it will be its last sibling.
|
||
*/
|
||
function insertRangeAfter(node, firstToInsert, lastToInsert) {
|
||
const lastToInsert2 = lastToInsert || firstToInsert.getParentOrThrow().getLastChild();
|
||
let current = firstToInsert;
|
||
const nodesToInsert = [firstToInsert];
|
||
while (current !== lastToInsert2) {
|
||
if (!current.getNextSibling()) {
|
||
{
|
||
throw Error(`insertRangeAfter: lastToInsert must be a later sibling of firstToInsert`);
|
||
}
|
||
}
|
||
current = current.getNextSibling();
|
||
nodesToInsert.push(current);
|
||
}
|
||
let currentNode = node;
|
||
for (const nodeToInsert of nodesToInsert) {
|
||
currentNode = currentNode.insertAfter(nodeToInsert);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
/** @noInheritDoc */
|
||
class LineBreakNode extends LexicalNode {
|
||
static getType() {
|
||
return 'linebreak';
|
||
}
|
||
static clone(node) {
|
||
return new LineBreakNode(node.__key);
|
||
}
|
||
constructor(key) {
|
||
super(key);
|
||
}
|
||
getTextContent() {
|
||
return '\n';
|
||
}
|
||
createDOM() {
|
||
return document.createElement('br');
|
||
}
|
||
updateDOM() {
|
||
return false;
|
||
}
|
||
static importDOM() {
|
||
return {
|
||
br: node => {
|
||
if (isOnlyChild(node)) {
|
||
return null;
|
||
}
|
||
return {
|
||
conversion: convertLineBreakElement,
|
||
priority: 0
|
||
};
|
||
}
|
||
};
|
||
}
|
||
static importJSON(serializedLineBreakNode) {
|
||
return $createLineBreakNode();
|
||
}
|
||
exportJSON() {
|
||
return {
|
||
type: 'linebreak',
|
||
version: 1
|
||
};
|
||
}
|
||
}
|
||
function convertLineBreakElement(node) {
|
||
return {
|
||
node: $createLineBreakNode()
|
||
};
|
||
}
|
||
function $createLineBreakNode() {
|
||
return $applyNodeReplacement(new LineBreakNode());
|
||
}
|
||
function $isLineBreakNode(node) {
|
||
return node instanceof LineBreakNode;
|
||
}
|
||
function isOnlyChild(node) {
|
||
const parentElement = node.parentElement;
|
||
if (parentElement !== null) {
|
||
const firstChild = parentElement.firstChild;
|
||
if (firstChild === node || firstChild.nextSibling === node && isWhitespaceDomTextNode(firstChild)) {
|
||
const lastChild = parentElement.lastChild;
|
||
if (lastChild === node || lastChild.previousSibling === node && isWhitespaceDomTextNode(lastChild)) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
function isWhitespaceDomTextNode(node) {
|
||
return node.nodeType === DOM_TEXT_TYPE && /^( |\t|\r?\n)+$/.test(node.textContent || '');
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
function getElementOuterTag(node, format) {
|
||
if (format & IS_CODE) {
|
||
return 'code';
|
||
}
|
||
if (format & IS_HIGHLIGHT) {
|
||
return 'mark';
|
||
}
|
||
if (format & IS_SUBSCRIPT) {
|
||
return 'sub';
|
||
}
|
||
if (format & IS_SUPERSCRIPT) {
|
||
return 'sup';
|
||
}
|
||
return null;
|
||
}
|
||
function getElementInnerTag(node, format) {
|
||
if (format & IS_BOLD) {
|
||
return 'strong';
|
||
}
|
||
if (format & IS_ITALIC) {
|
||
return 'em';
|
||
}
|
||
return 'span';
|
||
}
|
||
function setTextThemeClassNames(tag, prevFormat, nextFormat, dom, textClassNames) {
|
||
const domClassList = dom.classList;
|
||
// Firstly we handle the base theme.
|
||
let classNames = getCachedClassNameArray(textClassNames, 'base');
|
||
if (classNames !== undefined) {
|
||
domClassList.add(...classNames);
|
||
}
|
||
// Secondly we handle the special case: underline + strikethrough.
|
||
// We have to do this as we need a way to compose the fact that
|
||
// the same CSS property will need to be used: text-decoration.
|
||
// In an ideal world we shouldn't have to do this, but there's no
|
||
// easy workaround for many atomic CSS systems today.
|
||
classNames = getCachedClassNameArray(textClassNames, 'underlineStrikethrough');
|
||
let hasUnderlineStrikethrough = false;
|
||
const prevUnderlineStrikethrough = prevFormat & IS_UNDERLINE && prevFormat & IS_STRIKETHROUGH;
|
||
const nextUnderlineStrikethrough = nextFormat & IS_UNDERLINE && nextFormat & IS_STRIKETHROUGH;
|
||
if (classNames !== undefined) {
|
||
if (nextUnderlineStrikethrough) {
|
||
hasUnderlineStrikethrough = true;
|
||
if (!prevUnderlineStrikethrough) {
|
||
domClassList.add(...classNames);
|
||
}
|
||
} else if (prevUnderlineStrikethrough) {
|
||
domClassList.remove(...classNames);
|
||
}
|
||
}
|
||
for (const key in TEXT_TYPE_TO_FORMAT) {
|
||
const format = key;
|
||
const flag = TEXT_TYPE_TO_FORMAT[format];
|
||
classNames = getCachedClassNameArray(textClassNames, key);
|
||
if (classNames !== undefined) {
|
||
if (nextFormat & flag) {
|
||
if (hasUnderlineStrikethrough && (key === 'underline' || key === 'strikethrough')) {
|
||
if (prevFormat & flag) {
|
||
domClassList.remove(...classNames);
|
||
}
|
||
continue;
|
||
}
|
||
if ((prevFormat & flag) === 0 || prevUnderlineStrikethrough && key === 'underline' || key === 'strikethrough') {
|
||
domClassList.add(...classNames);
|
||
}
|
||
} else if (prevFormat & flag) {
|
||
domClassList.remove(...classNames);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function diffComposedText(a, b) {
|
||
const aLength = a.length;
|
||
const bLength = b.length;
|
||
let left = 0;
|
||
let right = 0;
|
||
while (left < aLength && left < bLength && a[left] === b[left]) {
|
||
left++;
|
||
}
|
||
while (right + left < aLength && right + left < bLength && a[aLength - right - 1] === b[bLength - right - 1]) {
|
||
right++;
|
||
}
|
||
return [left, aLength - left - right, b.slice(left, bLength - right)];
|
||
}
|
||
function setTextContent(nextText, dom, node) {
|
||
const firstChild = dom.firstChild;
|
||
const isComposing = node.isComposing();
|
||
// Always add a suffix if we're composing a node
|
||
const suffix = isComposing ? COMPOSITION_SUFFIX : '';
|
||
const text = nextText + suffix;
|
||
if (firstChild == null) {
|
||
dom.textContent = text;
|
||
} else {
|
||
const nodeValue = firstChild.nodeValue;
|
||
if (nodeValue !== text) {
|
||
if (isComposing || IS_FIREFOX) {
|
||
// We also use the diff composed text for general text in FF to avoid
|
||
// the spellcheck red line from flickering.
|
||
const [index, remove, insert] = diffComposedText(nodeValue, text);
|
||
if (remove !== 0) {
|
||
// @ts-expect-error
|
||
firstChild.deleteData(index, remove);
|
||
}
|
||
// @ts-expect-error
|
||
firstChild.insertData(index, insert);
|
||
} else {
|
||
firstChild.nodeValue = text;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function createTextInnerDOM(innerDOM, node, innerTag, format, text, config) {
|
||
setTextContent(text, innerDOM, node);
|
||
const theme = config.theme;
|
||
// Apply theme class names
|
||
const textClassNames = theme.text;
|
||
if (textClassNames !== undefined) {
|
||
setTextThemeClassNames(innerTag, 0, format, innerDOM, textClassNames);
|
||
}
|
||
}
|
||
function wrapElementWith(element, tag) {
|
||
const el = document.createElement(tag);
|
||
el.appendChild(element);
|
||
return el;
|
||
}
|
||
|
||
/** @noInheritDoc */
|
||
class TextNode extends LexicalNode {
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
static getType() {
|
||
return 'text';
|
||
}
|
||
static clone(node) {
|
||
return new TextNode(node.__text, node.__key);
|
||
}
|
||
constructor(text, key) {
|
||
super(key);
|
||
this.__text = text;
|
||
this.__format = 0;
|
||
this.__style = '';
|
||
this.__mode = 0;
|
||
this.__detail = 0;
|
||
}
|
||
|
||
/**
|
||
* Returns a 32-bit integer that represents the TextFormatTypes currently applied to the
|
||
* TextNode. You probably don't want to use this method directly - consider using TextNode.hasFormat instead.
|
||
*
|
||
* @returns a number representing the format of the text node.
|
||
*/
|
||
getFormat() {
|
||
const self = this.getLatest();
|
||
return self.__format;
|
||
}
|
||
|
||
/**
|
||
* Returns a 32-bit integer that represents the TextDetailTypes currently applied to the
|
||
* TextNode. You probably don't want to use this method directly - consider using TextNode.isDirectionless
|
||
* or TextNode.isUnmergeable instead.
|
||
*
|
||
* @returns a number representing the detail of the text node.
|
||
*/
|
||
getDetail() {
|
||
const self = this.getLatest();
|
||
return self.__detail;
|
||
}
|
||
|
||
/**
|
||
* Returns the mode (TextModeType) of the TextNode, which may be "normal", "token", or "segmented"
|
||
*
|
||
* @returns TextModeType.
|
||
*/
|
||
getMode() {
|
||
const self = this.getLatest();
|
||
return TEXT_TYPE_TO_MODE[self.__mode];
|
||
}
|
||
|
||
/**
|
||
* Returns the styles currently applied to the node. This is analogous to CSSText in the DOM.
|
||
*
|
||
* @returns CSSText-like string of styles applied to the underlying DOM node.
|
||
*/
|
||
getStyle() {
|
||
const self = this.getLatest();
|
||
return self.__style;
|
||
}
|
||
|
||
/**
|
||
* Returns whether or not the node is in "token" mode. TextNodes in token mode can be navigated through character-by-character
|
||
* with a RangeSelection, but are deleted as a single entity (not invdividually by character).
|
||
*
|
||
* @returns true if the node is in token mode, false otherwise.
|
||
*/
|
||
isToken() {
|
||
const self = this.getLatest();
|
||
return self.__mode === IS_TOKEN;
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @returns true if Lexical detects that an IME or other 3rd-party script is attempting to
|
||
* mutate the TextNode, false otherwise.
|
||
*/
|
||
isComposing() {
|
||
return this.__key === $getCompositionKey();
|
||
}
|
||
|
||
/**
|
||
* Returns whether or not the node is in "segemented" mode. TextNodes in segemented mode can be navigated through character-by-character
|
||
* with a RangeSelection, but are deleted in space-delimited "segments".
|
||
*
|
||
* @returns true if the node is in segmented mode, false otherwise.
|
||
*/
|
||
isSegmented() {
|
||
const self = this.getLatest();
|
||
return self.__mode === IS_SEGMENTED;
|
||
}
|
||
/**
|
||
* Returns whether or not the node is "directionless". Directionless nodes don't respect changes between RTL and LTR modes.
|
||
*
|
||
* @returns true if the node is directionless, false otherwise.
|
||
*/
|
||
isDirectionless() {
|
||
const self = this.getLatest();
|
||
return (self.__detail & IS_DIRECTIONLESS) !== 0;
|
||
}
|
||
/**
|
||
* Returns whether or not the node is unmergeable. In some scenarios, Lexical tries to merge
|
||
* adjacent TextNodes into a single TextNode. If a TextNode is unmergeable, this won't happen.
|
||
*
|
||
* @returns true if the node is unmergeable, false otherwise.
|
||
*/
|
||
isUnmergeable() {
|
||
const self = this.getLatest();
|
||
return (self.__detail & IS_UNMERGEABLE) !== 0;
|
||
}
|
||
|
||
/**
|
||
* Returns whether or not the node has the provided format applied. Use this with the human-readable TextFormatType
|
||
* string values to get the format of a TextNode.
|
||
*
|
||
* @param type - the TextFormatType to check for.
|
||
*
|
||
* @returns true if the node has the provided format, false otherwise.
|
||
*/
|
||
hasFormat(type) {
|
||
const formatFlag = TEXT_TYPE_TO_FORMAT[type];
|
||
return (this.getFormat() & formatFlag) !== 0;
|
||
}
|
||
|
||
/**
|
||
* Returns whether or not the node is simple text. Simple text is defined as a TextNode that has the string type "text"
|
||
* (i.e., not a subclass) and has no mode applied to it (i.e., not segmented or token).
|
||
*
|
||
* @returns true if the node is simple text, false otherwise.
|
||
*/
|
||
isSimpleText() {
|
||
return this.__type === 'text' && this.__mode === 0;
|
||
}
|
||
|
||
/**
|
||
* Returns the text content of the node as a string.
|
||
*
|
||
* @returns a string representing the text content of the node.
|
||
*/
|
||
getTextContent() {
|
||
const self = this.getLatest();
|
||
return self.__text;
|
||
}
|
||
|
||
/**
|
||
* Returns the format flags applied to the node as a 32-bit integer.
|
||
*
|
||
* @returns a number representing the TextFormatTypes applied to the node.
|
||
*/
|
||
getFormatFlags(type, alignWithFormat) {
|
||
const self = this.getLatest();
|
||
const format = self.__format;
|
||
return toggleTextFormatType(format, type, alignWithFormat);
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @returns true if the text node supports font styling, false otherwise.
|
||
*/
|
||
canHaveFormat() {
|
||
return true;
|
||
}
|
||
|
||
// View
|
||
|
||
createDOM(config, editor) {
|
||
const format = this.__format;
|
||
const outerTag = getElementOuterTag(this, format);
|
||
const innerTag = getElementInnerTag(this, format);
|
||
const tag = outerTag === null ? innerTag : outerTag;
|
||
const dom = document.createElement(tag);
|
||
let innerDOM = dom;
|
||
if (this.hasFormat('code')) {
|
||
dom.setAttribute('spellcheck', 'false');
|
||
}
|
||
if (outerTag !== null) {
|
||
innerDOM = document.createElement(innerTag);
|
||
dom.appendChild(innerDOM);
|
||
}
|
||
const text = this.__text;
|
||
createTextInnerDOM(innerDOM, this, innerTag, format, text, config);
|
||
const style = this.__style;
|
||
if (style !== '') {
|
||
dom.style.cssText = style;
|
||
}
|
||
return dom;
|
||
}
|
||
updateDOM(prevNode, dom, config) {
|
||
const nextText = this.__text;
|
||
const prevFormat = prevNode.__format;
|
||
const nextFormat = this.__format;
|
||
const prevOuterTag = getElementOuterTag(this, prevFormat);
|
||
const nextOuterTag = getElementOuterTag(this, nextFormat);
|
||
const prevInnerTag = getElementInnerTag(this, prevFormat);
|
||
const nextInnerTag = getElementInnerTag(this, nextFormat);
|
||
const prevTag = prevOuterTag === null ? prevInnerTag : prevOuterTag;
|
||
const nextTag = nextOuterTag === null ? nextInnerTag : nextOuterTag;
|
||
if (prevTag !== nextTag) {
|
||
return true;
|
||
}
|
||
if (prevOuterTag === nextOuterTag && prevInnerTag !== nextInnerTag) {
|
||
// should always be an element
|
||
const prevInnerDOM = dom.firstChild;
|
||
if (prevInnerDOM == null) {
|
||
{
|
||
throw Error(`updateDOM: prevInnerDOM is null or undefined`);
|
||
}
|
||
}
|
||
const nextInnerDOM = document.createElement(nextInnerTag);
|
||
createTextInnerDOM(nextInnerDOM, this, nextInnerTag, nextFormat, nextText, config);
|
||
dom.replaceChild(nextInnerDOM, prevInnerDOM);
|
||
return false;
|
||
}
|
||
let innerDOM = dom;
|
||
if (nextOuterTag !== null) {
|
||
if (prevOuterTag !== null) {
|
||
innerDOM = dom.firstChild;
|
||
if (innerDOM == null) {
|
||
{
|
||
throw Error(`updateDOM: innerDOM is null or undefined`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
setTextContent(nextText, innerDOM, this);
|
||
const theme = config.theme;
|
||
// Apply theme class names
|
||
const textClassNames = theme.text;
|
||
if (textClassNames !== undefined && prevFormat !== nextFormat) {
|
||
setTextThemeClassNames(nextInnerTag, prevFormat, nextFormat, innerDOM, textClassNames);
|
||
}
|
||
const prevStyle = prevNode.__style;
|
||
const nextStyle = this.__style;
|
||
if (prevStyle !== nextStyle) {
|
||
dom.style.cssText = nextStyle;
|
||
}
|
||
return false;
|
||
}
|
||
static importDOM() {
|
||
return {
|
||
'#text': () => ({
|
||
conversion: convertTextDOMNode,
|
||
priority: 0
|
||
}),
|
||
b: () => ({
|
||
conversion: convertBringAttentionToElement,
|
||
priority: 0
|
||
}),
|
||
code: () => ({
|
||
conversion: convertTextFormatElement,
|
||
priority: 0
|
||
}),
|
||
em: () => ({
|
||
conversion: convertTextFormatElement,
|
||
priority: 0
|
||
}),
|
||
i: () => ({
|
||
conversion: convertTextFormatElement,
|
||
priority: 0
|
||
}),
|
||
s: () => ({
|
||
conversion: convertTextFormatElement,
|
||
priority: 0
|
||
}),
|
||
span: () => ({
|
||
conversion: convertSpanElement,
|
||
priority: 0
|
||
}),
|
||
strong: () => ({
|
||
conversion: convertTextFormatElement,
|
||
priority: 0
|
||
}),
|
||
sub: () => ({
|
||
conversion: convertTextFormatElement,
|
||
priority: 0
|
||
}),
|
||
sup: () => ({
|
||
conversion: convertTextFormatElement,
|
||
priority: 0
|
||
}),
|
||
u: () => ({
|
||
conversion: convertTextFormatElement,
|
||
priority: 0
|
||
})
|
||
};
|
||
}
|
||
static importJSON(serializedNode) {
|
||
const node = $createTextNode(serializedNode.text);
|
||
node.setFormat(serializedNode.format);
|
||
node.setDetail(serializedNode.detail);
|
||
node.setMode(serializedNode.mode);
|
||
node.setStyle(serializedNode.style);
|
||
return node;
|
||
}
|
||
|
||
// This improves Lexical's basic text output in copy+paste plus
|
||
// for headless mode where people might use Lexical to generate
|
||
// HTML content and not have the ability to use CSS classes.
|
||
exportDOM(editor) {
|
||
let {
|
||
element
|
||
} = super.exportDOM(editor);
|
||
if (!(element !== null && isHTMLElement(element))) {
|
||
throw Error(`Expected TextNode createDOM to always return a HTMLElement`);
|
||
}
|
||
element.style.whiteSpace = 'pre-wrap';
|
||
// This is the only way to properly add support for most clients,
|
||
// even if it's semantically incorrect to have to resort to using
|
||
// <b>, <u>, <s>, <i> elements.
|
||
if (this.hasFormat('bold')) {
|
||
element = wrapElementWith(element, 'b');
|
||
}
|
||
if (this.hasFormat('italic')) {
|
||
element = wrapElementWith(element, 'i');
|
||
}
|
||
if (this.hasFormat('strikethrough')) {
|
||
element = wrapElementWith(element, 's');
|
||
}
|
||
if (this.hasFormat('underline')) {
|
||
element = wrapElementWith(element, 'u');
|
||
}
|
||
return {
|
||
element
|
||
};
|
||
}
|
||
exportJSON() {
|
||
return {
|
||
detail: this.getDetail(),
|
||
format: this.getFormat(),
|
||
mode: this.getMode(),
|
||
style: this.getStyle(),
|
||
text: this.getTextContent(),
|
||
type: 'text',
|
||
version: 1
|
||
};
|
||
}
|
||
|
||
// Mutators
|
||
selectionTransform(prevSelection, nextSelection) {
|
||
return;
|
||
}
|
||
|
||
/**
|
||
* Sets the node format to the provided TextFormatType or 32-bit integer. Note that the TextFormatType
|
||
* version of the argument can only specify one format and doing so will remove all other formats that
|
||
* may be applied to the node. For toggling behavior, consider using {@link TextNode.toggleFormat}
|
||
*
|
||
* @param format - TextFormatType or 32-bit integer representing the node format.
|
||
*
|
||
* @returns this TextNode.
|
||
* // TODO 0.12 This should just be a `string`.
|
||
*/
|
||
setFormat(format) {
|
||
const self = this.getWritable();
|
||
self.__format = typeof format === 'string' ? TEXT_TYPE_TO_FORMAT[format] : format;
|
||
return self;
|
||
}
|
||
|
||
/**
|
||
* Sets the node detail to the provided TextDetailType or 32-bit integer. Note that the TextDetailType
|
||
* version of the argument can only specify one detail value and doing so will remove all other detail values that
|
||
* may be applied to the node. For toggling behavior, consider using {@link TextNode.toggleDirectionless}
|
||
* or {@link TextNode.togglerUnmergeable}
|
||
*
|
||
* @param detail - TextDetailType or 32-bit integer representing the node detail.
|
||
*
|
||
* @returns this TextNode.
|
||
* // TODO 0.12 This should just be a `string`.
|
||
*/
|
||
setDetail(detail) {
|
||
const self = this.getWritable();
|
||
self.__detail = typeof detail === 'string' ? DETAIL_TYPE_TO_DETAIL[detail] : detail;
|
||
return self;
|
||
}
|
||
|
||
/**
|
||
* Sets the node style to the provided CSSText-like string. Set this property as you
|
||
* would an HTMLElement style attribute to apply inline styles to the underlying DOM Element.
|
||
*
|
||
* @param style - CSSText to be applied to the underlying HTMLElement.
|
||
*
|
||
* @returns this TextNode.
|
||
*/
|
||
setStyle(style) {
|
||
const self = this.getWritable();
|
||
self.__style = style;
|
||
return self;
|
||
}
|
||
|
||
/**
|
||
* Applies the provided format to this TextNode if it's not present. Removes it if it's present.
|
||
* The subscript and superscript formats are mutually exclusive.
|
||
* Prefer using this method to turn specific formats on and off.
|
||
*
|
||
* @param type - TextFormatType to toggle.
|
||
*
|
||
* @returns this TextNode.
|
||
*/
|
||
toggleFormat(type) {
|
||
const format = this.getFormat();
|
||
const newFormat = toggleTextFormatType(format, type, null);
|
||
return this.setFormat(newFormat);
|
||
}
|
||
|
||
/**
|
||
* Toggles the directionless detail value of the node. Prefer using this method over setDetail.
|
||
*
|
||
* @returns this TextNode.
|
||
*/
|
||
toggleDirectionless() {
|
||
const self = this.getWritable();
|
||
self.__detail ^= IS_DIRECTIONLESS;
|
||
return self;
|
||
}
|
||
|
||
/**
|
||
* Toggles the unmergeable detail value of the node. Prefer using this method over setDetail.
|
||
*
|
||
* @returns this TextNode.
|
||
*/
|
||
toggleUnmergeable() {
|
||
const self = this.getWritable();
|
||
self.__detail ^= IS_UNMERGEABLE;
|
||
return self;
|
||
}
|
||
|
||
/**
|
||
* Sets the mode of the node.
|
||
*
|
||
* @returns this TextNode.
|
||
*/
|
||
setMode(type) {
|
||
const mode = TEXT_MODE_TO_TYPE[type];
|
||
if (this.__mode === mode) {
|
||
return this;
|
||
}
|
||
const self = this.getWritable();
|
||
self.__mode = mode;
|
||
return self;
|
||
}
|
||
|
||
/**
|
||
* Sets the text content of the node.
|
||
*
|
||
* @param text - the string to set as the text value of the node.
|
||
*
|
||
* @returns this TextNode.
|
||
*/
|
||
setTextContent(text) {
|
||
if (this.__text === text) {
|
||
return this;
|
||
}
|
||
const self = this.getWritable();
|
||
self.__text = text;
|
||
return self;
|
||
}
|
||
|
||
/**
|
||
* Sets the current Lexical selection to be a RangeSelection with anchor and focus on this TextNode at the provided offsets.
|
||
*
|
||
* @param _anchorOffset - the offset at which the Selection anchor will be placed.
|
||
* @param _focusOffset - the offset at which the Selection focus will be placed.
|
||
*
|
||
* @returns the new RangeSelection.
|
||
*/
|
||
select(_anchorOffset, _focusOffset) {
|
||
errorOnReadOnly();
|
||
let anchorOffset = _anchorOffset;
|
||
let focusOffset = _focusOffset;
|
||
const selection = $getSelection();
|
||
const text = this.getTextContent();
|
||
const key = this.__key;
|
||
if (typeof text === 'string') {
|
||
const lastOffset = text.length;
|
||
if (anchorOffset === undefined) {
|
||
anchorOffset = lastOffset;
|
||
}
|
||
if (focusOffset === undefined) {
|
||
focusOffset = lastOffset;
|
||
}
|
||
} else {
|
||
anchorOffset = 0;
|
||
focusOffset = 0;
|
||
}
|
||
if (!$isRangeSelection(selection)) {
|
||
return internalMakeRangeSelection(key, anchorOffset, key, focusOffset, 'text', 'text');
|
||
} else {
|
||
const compositionKey = $getCompositionKey();
|
||
if (compositionKey === selection.anchor.key || compositionKey === selection.focus.key) {
|
||
$setCompositionKey(key);
|
||
}
|
||
selection.setTextNodeRange(this, anchorOffset, this, focusOffset);
|
||
}
|
||
return selection;
|
||
}
|
||
selectStart() {
|
||
return this.select(0, 0);
|
||
}
|
||
selectEnd() {
|
||
const size = this.getTextContentSize();
|
||
return this.select(size, size);
|
||
}
|
||
|
||
/**
|
||
* Inserts the provided text into this TextNode at the provided offset, deleting the number of characters
|
||
* specified. Can optionally calculate a new selection after the operation is complete.
|
||
*
|
||
* @param offset - the offset at which the splice operation should begin.
|
||
* @param delCount - the number of characters to delete, starting from the offset.
|
||
* @param newText - the text to insert into the TextNode at the offset.
|
||
* @param moveSelection - optional, whether or not to move selection to the end of the inserted substring.
|
||
*
|
||
* @returns this TextNode.
|
||
*/
|
||
spliceText(offset, delCount, newText, moveSelection) {
|
||
const writableSelf = this.getWritable();
|
||
const text = writableSelf.__text;
|
||
const handledTextLength = newText.length;
|
||
let index = offset;
|
||
if (index < 0) {
|
||
index = handledTextLength + index;
|
||
if (index < 0) {
|
||
index = 0;
|
||
}
|
||
}
|
||
const selection = $getSelection();
|
||
if (moveSelection && $isRangeSelection(selection)) {
|
||
const newOffset = offset + handledTextLength;
|
||
selection.setTextNodeRange(writableSelf, newOffset, writableSelf, newOffset);
|
||
}
|
||
const updatedText = text.slice(0, index) + newText + text.slice(index + delCount);
|
||
writableSelf.__text = updatedText;
|
||
return writableSelf;
|
||
}
|
||
|
||
/**
|
||
* This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
|
||
* when a user event would cause text to be inserted before them in the editor. If true, Lexical will attempt
|
||
* to insert text into this node. If false, it will insert the text in a new sibling node.
|
||
*
|
||
* @returns true if text can be inserted before the node, false otherwise.
|
||
*/
|
||
canInsertTextBefore() {
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
|
||
* when a user event would cause text to be inserted after them in the editor. If true, Lexical will attempt
|
||
* to insert text into this node. If false, it will insert the text in a new sibling node.
|
||
*
|
||
* @returns true if text can be inserted after the node, false otherwise.
|
||
*/
|
||
canInsertTextAfter() {
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Splits this TextNode at the provided character offsets, forming new TextNodes from the substrings
|
||
* formed by the split, and inserting those new TextNodes into the editor, replacing the one that was split.
|
||
*
|
||
* @param splitOffsets - rest param of the text content character offsets at which this node should be split.
|
||
*
|
||
* @returns an Array containing the newly-created TextNodes.
|
||
*/
|
||
splitText(...splitOffsets) {
|
||
errorOnReadOnly();
|
||
const self = this.getLatest();
|
||
const textContent = self.getTextContent();
|
||
const key = self.__key;
|
||
const compositionKey = $getCompositionKey();
|
||
const offsetsSet = new Set(splitOffsets);
|
||
const parts = [];
|
||
const textLength = textContent.length;
|
||
let string = '';
|
||
for (let i = 0; i < textLength; i++) {
|
||
if (string !== '' && offsetsSet.has(i)) {
|
||
parts.push(string);
|
||
string = '';
|
||
}
|
||
string += textContent[i];
|
||
}
|
||
if (string !== '') {
|
||
parts.push(string);
|
||
}
|
||
const partsLength = parts.length;
|
||
if (partsLength === 0) {
|
||
return [];
|
||
} else if (parts[0] === textContent) {
|
||
return [self];
|
||
}
|
||
const firstPart = parts[0];
|
||
const parent = self.getParentOrThrow();
|
||
let writableNode;
|
||
const format = self.getFormat();
|
||
const style = self.getStyle();
|
||
const detail = self.__detail;
|
||
let hasReplacedSelf = false;
|
||
if (self.isSegmented()) {
|
||
// Create a new TextNode
|
||
writableNode = $createTextNode(firstPart);
|
||
writableNode.__format = format;
|
||
writableNode.__style = style;
|
||
writableNode.__detail = detail;
|
||
hasReplacedSelf = true;
|
||
} else {
|
||
// For the first part, update the existing node
|
||
writableNode = self.getWritable();
|
||
writableNode.__text = firstPart;
|
||
}
|
||
|
||
// Handle selection
|
||
const selection = $getSelection();
|
||
|
||
// Then handle all other parts
|
||
const splitNodes = [writableNode];
|
||
let textSize = firstPart.length;
|
||
for (let i = 1; i < partsLength; i++) {
|
||
const part = parts[i];
|
||
const partSize = part.length;
|
||
const sibling = $createTextNode(part).getWritable();
|
||
sibling.__format = format;
|
||
sibling.__style = style;
|
||
sibling.__detail = detail;
|
||
const siblingKey = sibling.__key;
|
||
const nextTextSize = textSize + partSize;
|
||
if ($isRangeSelection(selection)) {
|
||
const anchor = selection.anchor;
|
||
const focus = selection.focus;
|
||
if (anchor.key === key && anchor.type === 'text' && anchor.offset > textSize && anchor.offset <= nextTextSize) {
|
||
anchor.key = siblingKey;
|
||
anchor.offset -= textSize;
|
||
selection.dirty = true;
|
||
}
|
||
if (focus.key === key && focus.type === 'text' && focus.offset > textSize && focus.offset <= nextTextSize) {
|
||
focus.key = siblingKey;
|
||
focus.offset -= textSize;
|
||
selection.dirty = true;
|
||
}
|
||
}
|
||
if (compositionKey === key) {
|
||
$setCompositionKey(siblingKey);
|
||
}
|
||
textSize = nextTextSize;
|
||
splitNodes.push(sibling);
|
||
}
|
||
|
||
// Insert the nodes into the parent's children
|
||
internalMarkSiblingsAsDirty(this);
|
||
const writableParent = parent.getWritable();
|
||
const insertionIndex = this.getIndexWithinParent();
|
||
if (hasReplacedSelf) {
|
||
writableParent.splice(insertionIndex, 0, splitNodes);
|
||
this.remove();
|
||
} else {
|
||
writableParent.splice(insertionIndex, 1, splitNodes);
|
||
}
|
||
if ($isRangeSelection(selection)) {
|
||
$updateElementSelectionOnCreateDeleteNode(selection, parent, insertionIndex, partsLength - 1);
|
||
}
|
||
return splitNodes;
|
||
}
|
||
|
||
/**
|
||
* Merges the target TextNode into this TextNode, removing the target node.
|
||
*
|
||
* @param target - the TextNode to merge into this one.
|
||
*
|
||
* @returns this TextNode.
|
||
*/
|
||
mergeWithSibling(target) {
|
||
const isBefore = target === this.getPreviousSibling();
|
||
if (!isBefore && target !== this.getNextSibling()) {
|
||
{
|
||
throw Error(`mergeWithSibling: sibling must be a previous or next sibling`);
|
||
}
|
||
}
|
||
const key = this.__key;
|
||
const targetKey = target.__key;
|
||
const text = this.__text;
|
||
const textLength = text.length;
|
||
const compositionKey = $getCompositionKey();
|
||
if (compositionKey === targetKey) {
|
||
$setCompositionKey(key);
|
||
}
|
||
const selection = $getSelection();
|
||
if ($isRangeSelection(selection)) {
|
||
const anchor = selection.anchor;
|
||
const focus = selection.focus;
|
||
if (anchor !== null && anchor.key === targetKey) {
|
||
adjustPointOffsetForMergedSibling(anchor, isBefore, key, target, textLength);
|
||
selection.dirty = true;
|
||
}
|
||
if (focus !== null && focus.key === targetKey) {
|
||
adjustPointOffsetForMergedSibling(focus, isBefore, key, target, textLength);
|
||
selection.dirty = true;
|
||
}
|
||
}
|
||
const targetText = target.__text;
|
||
const newText = isBefore ? targetText + text : text + targetText;
|
||
this.setTextContent(newText);
|
||
const writableSelf = this.getWritable();
|
||
target.remove();
|
||
return writableSelf;
|
||
}
|
||
|
||
/**
|
||
* This method is meant to be overriden by TextNode subclasses to control the behavior of those nodes
|
||
* when used with the registerLexicalTextEntity function. If you're using registerLexicalTextEntity, the
|
||
* node class that you create and replace matched text with should return true from this method.
|
||
*
|
||
* @returns true if the node is to be treated as a "text entity", false otherwise.
|
||
*/
|
||
isTextEntity() {
|
||
return false;
|
||
}
|
||
}
|
||
function convertSpanElement(domNode) {
|
||
// domNode is a <span> since we matched it by nodeName
|
||
const span = domNode;
|
||
// Google Docs uses span tags + font-weight for bold text
|
||
const hasBoldFontWeight = span.style.fontWeight === '700';
|
||
// Google Docs uses span tags + text-decoration: line-through for strikethrough text
|
||
const hasLinethroughTextDecoration = span.style.textDecoration === 'line-through';
|
||
// Google Docs uses span tags + font-style for italic text
|
||
const hasItalicFontStyle = span.style.fontStyle === 'italic';
|
||
// Google Docs uses span tags + text-decoration: underline for underline text
|
||
const hasUnderlineTextDecoration = span.style.textDecoration === 'underline';
|
||
// Google Docs uses span tags + vertical-align to specify subscript and superscript
|
||
const verticalAlign = span.style.verticalAlign;
|
||
return {
|
||
forChild: lexicalNode => {
|
||
if (!$isTextNode(lexicalNode)) {
|
||
return lexicalNode;
|
||
}
|
||
if (hasBoldFontWeight) {
|
||
lexicalNode.toggleFormat('bold');
|
||
}
|
||
if (hasLinethroughTextDecoration) {
|
||
lexicalNode.toggleFormat('strikethrough');
|
||
}
|
||
if (hasItalicFontStyle) {
|
||
lexicalNode.toggleFormat('italic');
|
||
}
|
||
if (hasUnderlineTextDecoration) {
|
||
lexicalNode.toggleFormat('underline');
|
||
}
|
||
if (verticalAlign === 'sub') {
|
||
lexicalNode.toggleFormat('subscript');
|
||
}
|
||
if (verticalAlign === 'super') {
|
||
lexicalNode.toggleFormat('superscript');
|
||
}
|
||
return lexicalNode;
|
||
},
|
||
node: null
|
||
};
|
||
}
|
||
function convertBringAttentionToElement(domNode) {
|
||
// domNode is a <b> since we matched it by nodeName
|
||
const b = domNode;
|
||
// Google Docs wraps all copied HTML in a <b> with font-weight normal
|
||
const hasNormalFontWeight = b.style.fontWeight === 'normal';
|
||
return {
|
||
forChild: lexicalNode => {
|
||
if ($isTextNode(lexicalNode) && !hasNormalFontWeight) {
|
||
lexicalNode.toggleFormat('bold');
|
||
}
|
||
return lexicalNode;
|
||
},
|
||
node: null
|
||
};
|
||
}
|
||
const preParentCache = new WeakMap();
|
||
function isNodePre(node) {
|
||
return node.nodeName === 'PRE' || node.nodeType === DOM_ELEMENT_TYPE && node.style !== undefined && node.style.whiteSpace !== undefined && node.style.whiteSpace.startsWith('pre');
|
||
}
|
||
function findParentPreDOMNode(node) {
|
||
let cached;
|
||
let parent = node.parentNode;
|
||
const visited = [node];
|
||
while (parent !== null && (cached = preParentCache.get(parent)) === undefined && !isNodePre(parent)) {
|
||
visited.push(parent);
|
||
parent = parent.parentNode;
|
||
}
|
||
const resultNode = cached === undefined ? parent : cached;
|
||
for (let i = 0; i < visited.length; i++) {
|
||
preParentCache.set(visited[i], resultNode);
|
||
}
|
||
return resultNode;
|
||
}
|
||
function convertTextDOMNode(domNode) {
|
||
const domNode_ = domNode;
|
||
const parentDom = domNode.parentElement;
|
||
if (!(parentDom !== null)) {
|
||
throw Error(`Expected parentElement of Text not to be null`);
|
||
}
|
||
let textContent = domNode_.textContent || '';
|
||
// No collapse and preserve segment break for pre, pre-wrap and pre-line
|
||
if (findParentPreDOMNode(domNode_) !== null) {
|
||
const parts = textContent.split(/(\r?\n|\t)/);
|
||
const nodes = [];
|
||
const length = parts.length;
|
||
for (let i = 0; i < length; i++) {
|
||
const part = parts[i];
|
||
if (part === '\n' || part === '\r\n') {
|
||
nodes.push($createLineBreakNode());
|
||
} else if (part === '\t') {
|
||
nodes.push($createTabNode());
|
||
} else if (part !== '') {
|
||
nodes.push($createTextNode(part));
|
||
}
|
||
}
|
||
return {
|
||
node: nodes
|
||
};
|
||
}
|
||
textContent = textContent.replace(/\r/g, '').replace(/[ \t\n]+/g, ' ');
|
||
if (textContent === '') {
|
||
return {
|
||
node: null
|
||
};
|
||
}
|
||
if (textContent[0] === ' ') {
|
||
// Traverse backward while in the same line. If content contains new line or tab -> pontential
|
||
// delete, other elements can borrow from this one. Deletion depends on whether it's also the
|
||
// last space (see next condition: textContent[textContent.length - 1] === ' '))
|
||
let previousText = domNode_;
|
||
let isStartOfLine = true;
|
||
while (previousText !== null && (previousText = findTextInLine(previousText, false)) !== null) {
|
||
const previousTextContent = previousText.textContent || '';
|
||
if (previousTextContent.length > 0) {
|
||
if (/[ \t\n]$/.test(previousTextContent)) {
|
||
textContent = textContent.slice(1);
|
||
}
|
||
isStartOfLine = false;
|
||
break;
|
||
}
|
||
}
|
||
if (isStartOfLine) {
|
||
textContent = textContent.slice(1);
|
||
}
|
||
}
|
||
if (textContent[textContent.length - 1] === ' ') {
|
||
// Traverse forward while in the same line, preserve if next inline will require a space
|
||
let nextText = domNode_;
|
||
let isEndOfLine = true;
|
||
while (nextText !== null && (nextText = findTextInLine(nextText, true)) !== null) {
|
||
const nextTextContent = (nextText.textContent || '').replace(/^( |\t|\r?\n)+/, '');
|
||
if (nextTextContent.length > 0) {
|
||
isEndOfLine = false;
|
||
break;
|
||
}
|
||
}
|
||
if (isEndOfLine) {
|
||
textContent = textContent.slice(0, textContent.length - 1);
|
||
}
|
||
}
|
||
if (textContent === '') {
|
||
return {
|
||
node: null
|
||
};
|
||
}
|
||
return {
|
||
node: $createTextNode(textContent)
|
||
};
|
||
}
|
||
const inlineParents = new RegExp(/^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/, 'i');
|
||
function findTextInLine(text, forward) {
|
||
let node = text;
|
||
// eslint-disable-next-line no-constant-condition
|
||
while (true) {
|
||
let sibling;
|
||
while ((sibling = forward ? node.nextSibling : node.previousSibling) === null) {
|
||
const parentElement = node.parentElement;
|
||
if (parentElement === null) {
|
||
return null;
|
||
}
|
||
node = parentElement;
|
||
}
|
||
node = sibling;
|
||
if (node.nodeType === DOM_ELEMENT_TYPE) {
|
||
const display = node.style.display;
|
||
if (display === '' && node.nodeName.match(inlineParents) === null || display !== '' && !display.startsWith('inline')) {
|
||
return null;
|
||
}
|
||
}
|
||
let descendant = node;
|
||
while ((descendant = forward ? node.firstChild : node.lastChild) !== null) {
|
||
node = descendant;
|
||
}
|
||
if (node.nodeType === DOM_TEXT_TYPE) {
|
||
return node;
|
||
} else if (node.nodeName === 'BR') {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
const nodeNameToTextFormat = {
|
||
code: 'code',
|
||
em: 'italic',
|
||
i: 'italic',
|
||
s: 'strikethrough',
|
||
strong: 'bold',
|
||
sub: 'subscript',
|
||
sup: 'superscript',
|
||
u: 'underline'
|
||
};
|
||
function convertTextFormatElement(domNode) {
|
||
const format = nodeNameToTextFormat[domNode.nodeName.toLowerCase()];
|
||
if (format === undefined) {
|
||
return {
|
||
node: null
|
||
};
|
||
}
|
||
return {
|
||
forChild: lexicalNode => {
|
||
if ($isTextNode(lexicalNode) && !lexicalNode.hasFormat(format)) {
|
||
lexicalNode.toggleFormat(format);
|
||
}
|
||
return lexicalNode;
|
||
},
|
||
node: null
|
||
};
|
||
}
|
||
function $createTextNode(text = '') {
|
||
return $applyNodeReplacement(new TextNode(text));
|
||
}
|
||
function $isTextNode(node) {
|
||
return node instanceof TextNode;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
/** @noInheritDoc */
|
||
class TabNode extends TextNode {
|
||
static getType() {
|
||
return 'tab';
|
||
}
|
||
static clone(node) {
|
||
const newNode = new TabNode(node.__key);
|
||
// TabNode __text can be either '\t' or ''. insertText will remove the empty Node
|
||
newNode.__text = node.__text;
|
||
newNode.__format = node.__format;
|
||
newNode.__style = node.__style;
|
||
return newNode;
|
||
}
|
||
constructor(key) {
|
||
super('\t', key);
|
||
this.__detail = IS_UNMERGEABLE;
|
||
}
|
||
static importDOM() {
|
||
return null;
|
||
}
|
||
static importJSON(serializedTabNode) {
|
||
const node = $createTabNode();
|
||
node.setFormat(serializedTabNode.format);
|
||
node.setStyle(serializedTabNode.style);
|
||
return node;
|
||
}
|
||
exportJSON() {
|
||
return {
|
||
...super.exportJSON(),
|
||
type: 'tab',
|
||
version: 1
|
||
};
|
||
}
|
||
setTextContent(_text) {
|
||
{
|
||
throw Error(`TabNode does not support setTextContent`);
|
||
}
|
||
}
|
||
setDetail(_detail) {
|
||
{
|
||
throw Error(`TabNode does not support setDetail`);
|
||
}
|
||
}
|
||
setMode(_type) {
|
||
{
|
||
throw Error(`TabNode does not support setMode`);
|
||
}
|
||
}
|
||
canInsertTextBefore() {
|
||
return false;
|
||
}
|
||
canInsertTextAfter() {
|
||
return false;
|
||
}
|
||
}
|
||
function $createTabNode() {
|
||
return $applyNodeReplacement(new TabNode());
|
||
}
|
||
function $isTabNode(node) {
|
||
return node instanceof TabNode;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
class Point {
|
||
constructor(key, offset, type) {
|
||
this._selection = null;
|
||
this.key = key;
|
||
this.offset = offset;
|
||
this.type = type;
|
||
}
|
||
is(point) {
|
||
return this.key === point.key && this.offset === point.offset && this.type === point.type;
|
||
}
|
||
isBefore(b) {
|
||
let aNode = this.getNode();
|
||
let bNode = b.getNode();
|
||
const aOffset = this.offset;
|
||
const bOffset = b.offset;
|
||
if ($isElementNode(aNode)) {
|
||
const aNodeDescendant = aNode.getDescendantByIndex(aOffset);
|
||
aNode = aNodeDescendant != null ? aNodeDescendant : aNode;
|
||
}
|
||
if ($isElementNode(bNode)) {
|
||
const bNodeDescendant = bNode.getDescendantByIndex(bOffset);
|
||
bNode = bNodeDescendant != null ? bNodeDescendant : bNode;
|
||
}
|
||
if (aNode === bNode) {
|
||
return aOffset < bOffset;
|
||
}
|
||
return aNode.isBefore(bNode);
|
||
}
|
||
getNode() {
|
||
const key = this.key;
|
||
const node = $getNodeByKey(key);
|
||
if (node === null) {
|
||
{
|
||
throw Error(`Point.getNode: node not found`);
|
||
}
|
||
}
|
||
return node;
|
||
}
|
||
set(key, offset, type) {
|
||
const selection = this._selection;
|
||
const oldKey = this.key;
|
||
this.key = key;
|
||
this.offset = offset;
|
||
this.type = type;
|
||
if (!isCurrentlyReadOnlyMode()) {
|
||
if ($getCompositionKey() === oldKey) {
|
||
$setCompositionKey(key);
|
||
}
|
||
if (selection !== null) {
|
||
selection.setCachedNodes(null);
|
||
selection.dirty = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function $createPoint(key, offset, type) {
|
||
// @ts-expect-error: intentionally cast as we use a class for perf reasons
|
||
return new Point(key, offset, type);
|
||
}
|
||
function selectPointOnNode(point, node) {
|
||
let key = node.__key;
|
||
let offset = point.offset;
|
||
let type = 'element';
|
||
if ($isTextNode(node)) {
|
||
type = 'text';
|
||
const textContentLength = node.getTextContentSize();
|
||
if (offset > textContentLength) {
|
||
offset = textContentLength;
|
||
}
|
||
} else if (!$isElementNode(node)) {
|
||
const nextSibling = node.getNextSibling();
|
||
if ($isTextNode(nextSibling)) {
|
||
key = nextSibling.__key;
|
||
offset = 0;
|
||
type = 'text';
|
||
} else {
|
||
const parentNode = node.getParent();
|
||
if (parentNode) {
|
||
key = parentNode.__key;
|
||
offset = node.getIndexWithinParent() + 1;
|
||
}
|
||
}
|
||
}
|
||
point.set(key, offset, type);
|
||
}
|
||
function $moveSelectionPointToEnd(point, node) {
|
||
if ($isElementNode(node)) {
|
||
const lastNode = node.getLastDescendant();
|
||
if ($isElementNode(lastNode) || $isTextNode(lastNode)) {
|
||
selectPointOnNode(point, lastNode);
|
||
} else {
|
||
selectPointOnNode(point, node);
|
||
}
|
||
} else {
|
||
selectPointOnNode(point, node);
|
||
}
|
||
}
|
||
function $transferStartingElementPointToTextPoint(start, end, format, style) {
|
||
const element = start.getNode();
|
||
const placementNode = element.getChildAtIndex(start.offset);
|
||
const textNode = $createTextNode();
|
||
const target = $isRootNode(element) ? $createParagraphNode().append(textNode) : textNode;
|
||
textNode.setFormat(format);
|
||
textNode.setStyle(style);
|
||
if (placementNode === null) {
|
||
element.append(target);
|
||
} else {
|
||
placementNode.insertBefore(target);
|
||
}
|
||
// Transfer the element point to a text point.
|
||
if (start.is(end)) {
|
||
end.set(textNode.__key, 0, 'text');
|
||
}
|
||
start.set(textNode.__key, 0, 'text');
|
||
}
|
||
function $setPointValues(point, key, offset, type) {
|
||
point.key = key;
|
||
point.offset = offset;
|
||
point.type = type;
|
||
}
|
||
class NodeSelection {
|
||
constructor(objects) {
|
||
this._cachedNodes = null;
|
||
this._nodes = objects;
|
||
this.dirty = false;
|
||
}
|
||
getCachedNodes() {
|
||
return this._cachedNodes;
|
||
}
|
||
setCachedNodes(nodes) {
|
||
this._cachedNodes = nodes;
|
||
}
|
||
is(selection) {
|
||
if (!$isNodeSelection(selection)) {
|
||
return false;
|
||
}
|
||
const a = this._nodes;
|
||
const b = selection._nodes;
|
||
return a.size === b.size && Array.from(a).every(key => b.has(key));
|
||
}
|
||
isCollapsed() {
|
||
return false;
|
||
}
|
||
isBackward() {
|
||
return false;
|
||
}
|
||
getStartEndPoints() {
|
||
return null;
|
||
}
|
||
add(key) {
|
||
this.dirty = true;
|
||
this._nodes.add(key);
|
||
this._cachedNodes = null;
|
||
}
|
||
delete(key) {
|
||
this.dirty = true;
|
||
this._nodes.delete(key);
|
||
this._cachedNodes = null;
|
||
}
|
||
clear() {
|
||
this.dirty = true;
|
||
this._nodes.clear();
|
||
this._cachedNodes = null;
|
||
}
|
||
has(key) {
|
||
return this._nodes.has(key);
|
||
}
|
||
clone() {
|
||
return new NodeSelection(new Set(this._nodes));
|
||
}
|
||
extract() {
|
||
return this.getNodes();
|
||
}
|
||
insertRawText(text) {
|
||
// Do nothing?
|
||
}
|
||
insertText() {
|
||
// Do nothing?
|
||
}
|
||
insertNodes(nodes) {
|
||
const selectedNodes = this.getNodes();
|
||
const selectedNodesLength = selectedNodes.length;
|
||
const lastSelectedNode = selectedNodes[selectedNodesLength - 1];
|
||
let selectionAtEnd;
|
||
// Insert nodes
|
||
if ($isTextNode(lastSelectedNode)) {
|
||
selectionAtEnd = lastSelectedNode.select();
|
||
} else {
|
||
const index = lastSelectedNode.getIndexWithinParent() + 1;
|
||
selectionAtEnd = lastSelectedNode.getParentOrThrow().select(index, index);
|
||
}
|
||
selectionAtEnd.insertNodes(nodes);
|
||
// Remove selected nodes
|
||
for (let i = 0; i < selectedNodesLength; i++) {
|
||
selectedNodes[i].remove();
|
||
}
|
||
}
|
||
getNodes() {
|
||
const cachedNodes = this._cachedNodes;
|
||
if (cachedNodes !== null) {
|
||
return cachedNodes;
|
||
}
|
||
const objects = this._nodes;
|
||
const nodes = [];
|
||
for (const object of objects) {
|
||
const node = $getNodeByKey(object);
|
||
if (node !== null) {
|
||
nodes.push(node);
|
||
}
|
||
}
|
||
if (!isCurrentlyReadOnlyMode()) {
|
||
this._cachedNodes = nodes;
|
||
}
|
||
return nodes;
|
||
}
|
||
getTextContent() {
|
||
const nodes = this.getNodes();
|
||
let textContent = '';
|
||
for (let i = 0; i < nodes.length; i++) {
|
||
textContent += nodes[i].getTextContent();
|
||
}
|
||
return textContent;
|
||
}
|
||
}
|
||
function $isRangeSelection(x) {
|
||
return x instanceof RangeSelection;
|
||
}
|
||
class RangeSelection {
|
||
constructor(anchor, focus, format, style) {
|
||
this.anchor = anchor;
|
||
this.focus = focus;
|
||
anchor._selection = this;
|
||
focus._selection = this;
|
||
this._cachedNodes = null;
|
||
this.format = format;
|
||
this.style = style;
|
||
this.dirty = false;
|
||
}
|
||
getCachedNodes() {
|
||
return this._cachedNodes;
|
||
}
|
||
setCachedNodes(nodes) {
|
||
this._cachedNodes = nodes;
|
||
}
|
||
|
||
/**
|
||
* Used to check if the provided selections is equal to this one by value,
|
||
* inluding anchor, focus, format, and style properties.
|
||
* @param selection - the Selection to compare this one to.
|
||
* @returns true if the Selections are equal, false otherwise.
|
||
*/
|
||
is(selection) {
|
||
if (!$isRangeSelection(selection)) {
|
||
return false;
|
||
}
|
||
return this.anchor.is(selection.anchor) && this.focus.is(selection.focus) && this.format === selection.format && this.style === selection.style;
|
||
}
|
||
|
||
/**
|
||
* Returns whether the Selection is "collapsed", meaning the anchor and focus are
|
||
* the same node and have the same offset.
|
||
*
|
||
* @returns true if the Selection is collapsed, false otherwise.
|
||
*/
|
||
isCollapsed() {
|
||
return this.anchor.is(this.focus);
|
||
}
|
||
|
||
/**
|
||
* Gets all the nodes in the Selection. Uses caching to make it generally suitable
|
||
* for use in hot paths.
|
||
*
|
||
* @returns an Array containing all the nodes in the Selection
|
||
*/
|
||
getNodes() {
|
||
const cachedNodes = this._cachedNodes;
|
||
if (cachedNodes !== null) {
|
||
return cachedNodes;
|
||
}
|
||
const anchor = this.anchor;
|
||
const focus = this.focus;
|
||
const isBefore = anchor.isBefore(focus);
|
||
const firstPoint = isBefore ? anchor : focus;
|
||
const lastPoint = isBefore ? focus : anchor;
|
||
let firstNode = firstPoint.getNode();
|
||
let lastNode = lastPoint.getNode();
|
||
const startOffset = firstPoint.offset;
|
||
const endOffset = lastPoint.offset;
|
||
if ($isElementNode(firstNode)) {
|
||
const firstNodeDescendant = firstNode.getDescendantByIndex(startOffset);
|
||
firstNode = firstNodeDescendant != null ? firstNodeDescendant : firstNode;
|
||
}
|
||
if ($isElementNode(lastNode)) {
|
||
let lastNodeDescendant = lastNode.getDescendantByIndex(endOffset);
|
||
// We don't want to over-select, as node selection infers the child before
|
||
// the last descendant, not including that descendant.
|
||
if (lastNodeDescendant !== null && lastNodeDescendant !== firstNode && lastNode.getChildAtIndex(endOffset) === lastNodeDescendant) {
|
||
lastNodeDescendant = lastNodeDescendant.getPreviousSibling();
|
||
}
|
||
lastNode = lastNodeDescendant != null ? lastNodeDescendant : lastNode;
|
||
}
|
||
let nodes;
|
||
if (firstNode.is(lastNode)) {
|
||
if ($isElementNode(firstNode) && firstNode.getChildrenSize() > 0) {
|
||
nodes = [];
|
||
} else {
|
||
nodes = [firstNode];
|
||
}
|
||
} else {
|
||
nodes = firstNode.getNodesBetween(lastNode);
|
||
}
|
||
if (!isCurrentlyReadOnlyMode()) {
|
||
this._cachedNodes = nodes;
|
||
}
|
||
return nodes;
|
||
}
|
||
|
||
/**
|
||
* Sets this Selection to be of type "text" at the provided anchor and focus values.
|
||
*
|
||
* @param anchorNode - the anchor node to set on the Selection
|
||
* @param anchorOffset - the offset to set on the Selection
|
||
* @param focusNode - the focus node to set on the Selection
|
||
* @param focusOffset - the focus offset to set on the Selection
|
||
*/
|
||
setTextNodeRange(anchorNode, anchorOffset, focusNode, focusOffset) {
|
||
$setPointValues(this.anchor, anchorNode.__key, anchorOffset, 'text');
|
||
$setPointValues(this.focus, focusNode.__key, focusOffset, 'text');
|
||
this._cachedNodes = null;
|
||
this.dirty = true;
|
||
}
|
||
|
||
/**
|
||
* Gets the (plain) text content of all the nodes in the selection.
|
||
*
|
||
* @returns a string representing the text content of all the nodes in the Selection
|
||
*/
|
||
getTextContent() {
|
||
const nodes = this.getNodes();
|
||
if (nodes.length === 0) {
|
||
return '';
|
||
}
|
||
const firstNode = nodes[0];
|
||
const lastNode = nodes[nodes.length - 1];
|
||
const anchor = this.anchor;
|
||
const focus = this.focus;
|
||
const isBefore = anchor.isBefore(focus);
|
||
const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
|
||
let textContent = '';
|
||
let prevWasElement = true;
|
||
for (let i = 0; i < nodes.length; i++) {
|
||
const node = nodes[i];
|
||
if ($isElementNode(node) && !node.isInline()) {
|
||
if (!prevWasElement) {
|
||
textContent += '\n';
|
||
}
|
||
if (node.isEmpty()) {
|
||
prevWasElement = false;
|
||
} else {
|
||
prevWasElement = true;
|
||
}
|
||
} else {
|
||
prevWasElement = false;
|
||
if ($isTextNode(node)) {
|
||
let text = node.getTextContent();
|
||
if (node === firstNode) {
|
||
if (node === lastNode) {
|
||
if (anchor.type !== 'element' || focus.type !== 'element' || focus.offset === anchor.offset) {
|
||
text = anchorOffset < focusOffset ? text.slice(anchorOffset, focusOffset) : text.slice(focusOffset, anchorOffset);
|
||
}
|
||
} else {
|
||
text = isBefore ? text.slice(anchorOffset) : text.slice(focusOffset);
|
||
}
|
||
} else if (node === lastNode) {
|
||
text = isBefore ? text.slice(0, focusOffset) : text.slice(0, anchorOffset);
|
||
}
|
||
textContent += text;
|
||
} else if (($isDecoratorNode(node) || $isLineBreakNode(node)) && (node !== lastNode || !this.isCollapsed())) {
|
||
textContent += node.getTextContent();
|
||
}
|
||
}
|
||
}
|
||
return textContent;
|
||
}
|
||
|
||
/**
|
||
* Attempts to map a DOM selection range onto this Lexical Selection,
|
||
* setting the anchor, focus, and type accordingly
|
||
*
|
||
* @param range a DOM Selection range conforming to the StaticRange interface.
|
||
*/
|
||
applyDOMRange(range) {
|
||
const editor = getActiveEditor();
|
||
const currentEditorState = editor.getEditorState();
|
||
const lastSelection = currentEditorState._selection;
|
||
const resolvedSelectionPoints = internalResolveSelectionPoints(range.startContainer, range.startOffset, range.endContainer, range.endOffset, editor, lastSelection);
|
||
if (resolvedSelectionPoints === null) {
|
||
return;
|
||
}
|
||
const [anchorPoint, focusPoint] = resolvedSelectionPoints;
|
||
$setPointValues(this.anchor, anchorPoint.key, anchorPoint.offset, anchorPoint.type);
|
||
$setPointValues(this.focus, focusPoint.key, focusPoint.offset, focusPoint.type);
|
||
this._cachedNodes = null;
|
||
}
|
||
|
||
/**
|
||
* Creates a new RangeSelection, copying over all the property values from this one.
|
||
*
|
||
* @returns a new RangeSelection with the same property values as this one.
|
||
*/
|
||
clone() {
|
||
const anchor = this.anchor;
|
||
const focus = this.focus;
|
||
const selection = new RangeSelection($createPoint(anchor.key, anchor.offset, anchor.type), $createPoint(focus.key, focus.offset, focus.type), this.format, this.style);
|
||
return selection;
|
||
}
|
||
|
||
/**
|
||
* Toggles the provided format on all the TextNodes in the Selection.
|
||
*
|
||
* @param format a string TextFormatType to toggle on the TextNodes in the selection
|
||
*/
|
||
toggleFormat(format) {
|
||
this.format = toggleTextFormatType(this.format, format, null);
|
||
this.dirty = true;
|
||
}
|
||
|
||
/**
|
||
* Sets the value of the style property on the Selection
|
||
*
|
||
* @param style - the style to set at the value of the style property.
|
||
*/
|
||
setStyle(style) {
|
||
this.style = style;
|
||
this.dirty = true;
|
||
}
|
||
|
||
/**
|
||
* Returns whether the provided TextFormatType is present on the Selection. This will be true if any node in the Selection
|
||
* has the specified format.
|
||
*
|
||
* @param type the TextFormatType to check for.
|
||
* @returns true if the provided format is currently toggled on on the Selection, false otherwise.
|
||
*/
|
||
hasFormat(type) {
|
||
const formatFlag = TEXT_TYPE_TO_FORMAT[type];
|
||
return (this.format & formatFlag) !== 0;
|
||
}
|
||
|
||
/**
|
||
* Attempts to insert the provided text into the EditorState at the current Selection.
|
||
* converts tabs, newlines, and carriage returns into LexicalNodes.
|
||
*
|
||
* @param text the text to insert into the Selection
|
||
*/
|
||
insertRawText(text) {
|
||
const parts = text.split(/(\r?\n|\t)/);
|
||
const nodes = [];
|
||
const length = parts.length;
|
||
for (let i = 0; i < length; i++) {
|
||
const part = parts[i];
|
||
if (part === '\n' || part === '\r\n') {
|
||
nodes.push($createLineBreakNode());
|
||
} else if (part === '\t') {
|
||
nodes.push($createTabNode());
|
||
} else {
|
||
nodes.push($createTextNode(part));
|
||
}
|
||
}
|
||
this.insertNodes(nodes);
|
||
}
|
||
|
||
/**
|
||
* Attempts to insert the provided text into the EditorState at the current Selection as a new
|
||
* Lexical TextNode, according to a series of insertion heuristics based on the selection type and position.
|
||
*
|
||
* @param text the text to insert into the Selection
|
||
*/
|
||
insertText(text) {
|
||
const anchor = this.anchor;
|
||
const focus = this.focus;
|
||
const isBefore = this.isCollapsed() || anchor.isBefore(focus);
|
||
const format = this.format;
|
||
const style = this.style;
|
||
if (isBefore && anchor.type === 'element') {
|
||
$transferStartingElementPointToTextPoint(anchor, focus, format, style);
|
||
} else if (!isBefore && focus.type === 'element') {
|
||
$transferStartingElementPointToTextPoint(focus, anchor, format, style);
|
||
}
|
||
const selectedNodes = this.getNodes();
|
||
const selectedNodesLength = selectedNodes.length;
|
||
const firstPoint = isBefore ? anchor : focus;
|
||
const endPoint = isBefore ? focus : anchor;
|
||
const startOffset = firstPoint.offset;
|
||
const endOffset = endPoint.offset;
|
||
let firstNode = selectedNodes[0];
|
||
if (!$isTextNode(firstNode)) {
|
||
{
|
||
throw Error(`insertText: first node is not a text node`);
|
||
}
|
||
}
|
||
const firstNodeText = firstNode.getTextContent();
|
||
const firstNodeTextLength = firstNodeText.length;
|
||
const firstNodeParent = firstNode.getParentOrThrow();
|
||
const lastIndex = selectedNodesLength - 1;
|
||
let lastNode = selectedNodes[lastIndex];
|
||
if (this.isCollapsed() && startOffset === firstNodeTextLength && (firstNode.isSegmented() || firstNode.isToken() || !firstNode.canInsertTextAfter() || !firstNodeParent.canInsertTextAfter() && firstNode.getNextSibling() === null)) {
|
||
let nextSibling = firstNode.getNextSibling();
|
||
if (!$isTextNode(nextSibling) || !nextSibling.canInsertTextBefore() || $isTokenOrSegmented(nextSibling)) {
|
||
nextSibling = $createTextNode();
|
||
nextSibling.setFormat(format);
|
||
if (!firstNodeParent.canInsertTextAfter()) {
|
||
firstNodeParent.insertAfter(nextSibling);
|
||
} else {
|
||
firstNode.insertAfter(nextSibling);
|
||
}
|
||
}
|
||
nextSibling.select(0, 0);
|
||
firstNode = nextSibling;
|
||
if (text !== '') {
|
||
this.insertText(text);
|
||
return;
|
||
}
|
||
} else if (this.isCollapsed() && startOffset === 0 && (firstNode.isSegmented() || firstNode.isToken() || !firstNode.canInsertTextBefore() || !firstNodeParent.canInsertTextBefore() && firstNode.getPreviousSibling() === null)) {
|
||
let prevSibling = firstNode.getPreviousSibling();
|
||
if (!$isTextNode(prevSibling) || $isTokenOrSegmented(prevSibling)) {
|
||
prevSibling = $createTextNode();
|
||
prevSibling.setFormat(format);
|
||
if (!firstNodeParent.canInsertTextBefore()) {
|
||
firstNodeParent.insertBefore(prevSibling);
|
||
} else {
|
||
firstNode.insertBefore(prevSibling);
|
||
}
|
||
}
|
||
prevSibling.select();
|
||
firstNode = prevSibling;
|
||
if (text !== '') {
|
||
this.insertText(text);
|
||
return;
|
||
}
|
||
} else if (firstNode.isSegmented() && startOffset !== firstNodeTextLength) {
|
||
const textNode = $createTextNode(firstNode.getTextContent());
|
||
textNode.setFormat(format);
|
||
firstNode.replace(textNode);
|
||
firstNode = textNode;
|
||
} else if (!this.isCollapsed() && text !== '') {
|
||
// When the firstNode or lastNode parents are elements that
|
||
// do not allow text to be inserted before or after, we first
|
||
// clear the content. Then we normalize selection, then insert
|
||
// the new content.
|
||
const lastNodeParent = lastNode.getParent();
|
||
if (!firstNodeParent.canInsertTextBefore() || !firstNodeParent.canInsertTextAfter() || $isElementNode(lastNodeParent) && (!lastNodeParent.canInsertTextBefore() || !lastNodeParent.canInsertTextAfter())) {
|
||
this.insertText('');
|
||
normalizeSelectionPointsForBoundaries(this.anchor, this.focus, null);
|
||
this.insertText(text);
|
||
return;
|
||
}
|
||
}
|
||
if (selectedNodesLength === 1) {
|
||
if (firstNode.isToken()) {
|
||
const textNode = $createTextNode(text);
|
||
textNode.select();
|
||
firstNode.replace(textNode);
|
||
return;
|
||
}
|
||
const firstNodeFormat = firstNode.getFormat();
|
||
const firstNodeStyle = firstNode.getStyle();
|
||
if (startOffset === endOffset && (firstNodeFormat !== format || firstNodeStyle !== style)) {
|
||
if (firstNode.getTextContent() === '') {
|
||
firstNode.setFormat(format);
|
||
firstNode.setStyle(style);
|
||
} else {
|
||
const textNode = $createTextNode(text);
|
||
textNode.setFormat(format);
|
||
textNode.setStyle(style);
|
||
textNode.select();
|
||
if (startOffset === 0) {
|
||
firstNode.insertBefore(textNode, false);
|
||
} else {
|
||
const [targetNode] = firstNode.splitText(startOffset);
|
||
targetNode.insertAfter(textNode, false);
|
||
}
|
||
// When composing, we need to adjust the anchor offset so that
|
||
// we correctly replace that right range.
|
||
if (textNode.isComposing() && this.anchor.type === 'text') {
|
||
this.anchor.offset -= text.length;
|
||
}
|
||
return;
|
||
}
|
||
} else if ($isTabNode(firstNode)) {
|
||
// We don't need to check for delCount because there is only the entire selected node case
|
||
// that can hit here for content size 1 and with canInsertTextBeforeAfter false
|
||
const textNode = $createTextNode(text);
|
||
textNode.setFormat(format);
|
||
textNode.setStyle(style);
|
||
textNode.select();
|
||
firstNode.replace(textNode);
|
||
return;
|
||
}
|
||
const delCount = endOffset - startOffset;
|
||
firstNode = firstNode.spliceText(startOffset, delCount, text, true);
|
||
if (firstNode.getTextContent() === '') {
|
||
firstNode.remove();
|
||
} else if (this.anchor.type === 'text') {
|
||
if (firstNode.isComposing()) {
|
||
// When composing, we need to adjust the anchor offset so that
|
||
// we correctly replace that right range.
|
||
this.anchor.offset -= text.length;
|
||
} else {
|
||
this.format = firstNodeFormat;
|
||
this.style = firstNodeStyle;
|
||
}
|
||
}
|
||
} else {
|
||
const markedNodeKeysForKeep = new Set([...firstNode.getParentKeys(), ...lastNode.getParentKeys()]);
|
||
|
||
// We have to get the parent elements before the next section,
|
||
// as in that section we might mutate the lastNode.
|
||
const firstElement = $isElementNode(firstNode) ? firstNode : firstNode.getParentOrThrow();
|
||
let lastElement = $isElementNode(lastNode) ? lastNode : lastNode.getParentOrThrow();
|
||
let lastElementChild = lastNode;
|
||
|
||
// If the last element is inline, we should instead look at getting
|
||
// the nodes of its parent, rather than itself. This behavior will
|
||
// then better match how text node insertions work. We will need to
|
||
// also update the last element's child accordingly as we do this.
|
||
if (!firstElement.is(lastElement) && lastElement.isInline()) {
|
||
// Keep traversing till we have a non-inline element parent.
|
||
do {
|
||
lastElementChild = lastElement;
|
||
lastElement = lastElement.getParentOrThrow();
|
||
} while (lastElement.isInline());
|
||
}
|
||
|
||
// Handle mutations to the last node.
|
||
if (endPoint.type === 'text' && (endOffset !== 0 || lastNode.getTextContent() === '') || endPoint.type === 'element' && lastNode.getIndexWithinParent() < endOffset) {
|
||
if ($isTextNode(lastNode) && !lastNode.isToken() && endOffset !== lastNode.getTextContentSize()) {
|
||
if (lastNode.isSegmented()) {
|
||
const textNode = $createTextNode(lastNode.getTextContent());
|
||
lastNode.replace(textNode);
|
||
lastNode = textNode;
|
||
}
|
||
// root node selections only select whole nodes, so no text splice is necessary
|
||
if (!$isRootNode(endPoint.getNode()) && endPoint.type === 'text') {
|
||
lastNode = lastNode.spliceText(0, endOffset, '');
|
||
}
|
||
markedNodeKeysForKeep.add(lastNode.__key);
|
||
} else {
|
||
const lastNodeParent = lastNode.getParentOrThrow();
|
||
if (!lastNodeParent.canBeEmpty() && lastNodeParent.getChildrenSize() === 1) {
|
||
lastNodeParent.remove();
|
||
} else {
|
||
lastNode.remove();
|
||
}
|
||
}
|
||
} else {
|
||
markedNodeKeysForKeep.add(lastNode.__key);
|
||
}
|
||
|
||
// Either move the remaining nodes of the last parent to after
|
||
// the first child, or remove them entirely. If the last parent
|
||
// is the same as the first parent, this logic also works.
|
||
const lastNodeChildren = lastElement.getChildren();
|
||
const selectedNodesSet = new Set(selectedNodes);
|
||
const firstAndLastElementsAreEqual = firstElement.is(lastElement);
|
||
|
||
// We choose a target to insert all nodes after. In the case of having
|
||
// and inline starting parent element with a starting node that has no
|
||
// siblings, we should insert after the starting parent element, otherwise
|
||
// we will incorrectly merge into the starting parent element.
|
||
// TODO: should we keep on traversing parents if we're inside another
|
||
// nested inline element?
|
||
const insertionTarget = firstElement.isInline() && firstNode.getNextSibling() === null ? firstElement : firstNode;
|
||
for (let i = lastNodeChildren.length - 1; i >= 0; i--) {
|
||
const lastNodeChild = lastNodeChildren[i];
|
||
if (lastNodeChild.is(firstNode) || $isElementNode(lastNodeChild) && lastNodeChild.isParentOf(firstNode)) {
|
||
break;
|
||
}
|
||
if (lastNodeChild.isAttached()) {
|
||
if (!selectedNodesSet.has(lastNodeChild) || lastNodeChild.is(lastElementChild)) {
|
||
if (!firstAndLastElementsAreEqual) {
|
||
insertionTarget.insertAfter(lastNodeChild, false);
|
||
}
|
||
} else {
|
||
lastNodeChild.remove();
|
||
}
|
||
}
|
||
}
|
||
if (!firstAndLastElementsAreEqual) {
|
||
// Check if we have already moved out all the nodes of the
|
||
// last parent, and if so, traverse the parent tree and mark
|
||
// them all as being able to deleted too.
|
||
let parent = lastElement;
|
||
let lastRemovedParent = null;
|
||
while (parent !== null) {
|
||
const children = parent.getChildren();
|
||
const childrenLength = children.length;
|
||
if (childrenLength === 0 || children[childrenLength - 1].is(lastRemovedParent)) {
|
||
markedNodeKeysForKeep.delete(parent.__key);
|
||
lastRemovedParent = parent;
|
||
}
|
||
parent = parent.getParent();
|
||
}
|
||
}
|
||
|
||
// Ensure we do splicing after moving of nodes, as splicing
|
||
// can have side-effects (in the case of hashtags).
|
||
if (!firstNode.isToken()) {
|
||
firstNode = firstNode.spliceText(startOffset, firstNodeTextLength - startOffset, text, true);
|
||
if (firstNode.getTextContent() === '') {
|
||
firstNode.remove();
|
||
} else if (firstNode.isComposing() && this.anchor.type === 'text') {
|
||
// When composing, we need to adjust the anchor offset so that
|
||
// we correctly replace that right range.
|
||
this.anchor.offset -= text.length;
|
||
}
|
||
} else if (startOffset === firstNodeTextLength) {
|
||
firstNode.select();
|
||
} else {
|
||
const textNode = $createTextNode(text);
|
||
textNode.select();
|
||
firstNode.replace(textNode);
|
||
}
|
||
|
||
// Remove all selected nodes that haven't already been removed.
|
||
for (let i = 1; i < selectedNodesLength; i++) {
|
||
const selectedNode = selectedNodes[i];
|
||
const key = selectedNode.__key;
|
||
if (!markedNodeKeysForKeep.has(key)) {
|
||
selectedNode.remove();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Removes the text in the Selection, adjusting the EditorState accordingly.
|
||
*/
|
||
removeText() {
|
||
this.insertText('');
|
||
}
|
||
|
||
/**
|
||
* Applies the provided format to the TextNodes in the Selection, splitting or
|
||
* merging nodes as necessary.
|
||
*
|
||
* @param formatType the format type to apply to the nodes in the Selection.
|
||
*/
|
||
formatText(formatType) {
|
||
if (this.isCollapsed()) {
|
||
this.toggleFormat(formatType);
|
||
// When changing format, we should stop composition
|
||
$setCompositionKey(null);
|
||
return;
|
||
}
|
||
const selectedNodes = this.getNodes();
|
||
const selectedTextNodes = [];
|
||
for (const selectedNode of selectedNodes) {
|
||
if ($isTextNode(selectedNode)) {
|
||
selectedTextNodes.push(selectedNode);
|
||
}
|
||
}
|
||
const selectedTextNodesLength = selectedTextNodes.length;
|
||
if (selectedTextNodesLength === 0) {
|
||
this.toggleFormat(formatType);
|
||
// When changing format, we should stop composition
|
||
$setCompositionKey(null);
|
||
return;
|
||
}
|
||
const anchor = this.anchor;
|
||
const focus = this.focus;
|
||
const isBackward = this.isBackward();
|
||
const startPoint = isBackward ? focus : anchor;
|
||
const endPoint = isBackward ? anchor : focus;
|
||
let firstIndex = 0;
|
||
let firstNode = selectedTextNodes[0];
|
||
let startOffset = startPoint.type === 'element' ? 0 : startPoint.offset;
|
||
|
||
// In case selection started at the end of text node use next text node
|
||
if (startPoint.type === 'text' && startOffset === firstNode.getTextContentSize()) {
|
||
firstIndex = 1;
|
||
firstNode = selectedTextNodes[1];
|
||
startOffset = 0;
|
||
}
|
||
if (firstNode == null) {
|
||
return;
|
||
}
|
||
const firstNextFormat = firstNode.getFormatFlags(formatType, null);
|
||
const lastIndex = selectedTextNodesLength - 1;
|
||
let lastNode = selectedTextNodes[lastIndex];
|
||
const endOffset = endPoint.type === 'text' ? endPoint.offset : lastNode.getTextContentSize();
|
||
|
||
// Single node selected
|
||
if (firstNode.is(lastNode)) {
|
||
// No actual text is selected, so do nothing.
|
||
if (startOffset === endOffset) {
|
||
return;
|
||
}
|
||
// The entire node is selected, so just format it
|
||
if (startOffset === 0 && endOffset === firstNode.getTextContentSize()) {
|
||
firstNode.setFormat(firstNextFormat);
|
||
} else {
|
||
// Node is partially selected, so split it into two nodes
|
||
// add style the selected one.
|
||
const splitNodes = firstNode.splitText(startOffset, endOffset);
|
||
const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
|
||
replacement.setFormat(firstNextFormat);
|
||
|
||
// Update selection only if starts/ends on text node
|
||
if (startPoint.type === 'text') {
|
||
startPoint.set(replacement.__key, 0, 'text');
|
||
}
|
||
if (endPoint.type === 'text') {
|
||
endPoint.set(replacement.__key, endOffset - startOffset, 'text');
|
||
}
|
||
}
|
||
this.format = firstNextFormat;
|
||
return;
|
||
}
|
||
// Multiple nodes selected
|
||
// The entire first node isn't selected, so split it
|
||
if (startOffset !== 0) {
|
||
[, firstNode] = firstNode.splitText(startOffset);
|
||
startOffset = 0;
|
||
}
|
||
firstNode.setFormat(firstNextFormat);
|
||
const lastNextFormat = lastNode.getFormatFlags(formatType, firstNextFormat);
|
||
// If the offset is 0, it means no actual characters are selected,
|
||
// so we skip formatting the last node altogether.
|
||
if (endOffset > 0) {
|
||
if (endOffset !== lastNode.getTextContentSize()) {
|
||
[lastNode] = lastNode.splitText(endOffset);
|
||
}
|
||
lastNode.setFormat(lastNextFormat);
|
||
}
|
||
|
||
// Process all text nodes in between
|
||
for (let i = firstIndex + 1; i < lastIndex; i++) {
|
||
const textNode = selectedTextNodes[i];
|
||
if (!textNode.isToken()) {
|
||
const nextFormat = textNode.getFormatFlags(formatType, lastNextFormat);
|
||
textNode.setFormat(nextFormat);
|
||
}
|
||
}
|
||
|
||
// Update selection only if starts/ends on text node
|
||
if (startPoint.type === 'text') {
|
||
startPoint.set(firstNode.__key, startOffset, 'text');
|
||
}
|
||
if (endPoint.type === 'text') {
|
||
endPoint.set(lastNode.__key, endOffset, 'text');
|
||
}
|
||
this.format = firstNextFormat | lastNextFormat;
|
||
}
|
||
|
||
/**
|
||
* Attempts to "intelligently" insert an arbitrary list of Lexical nodes into the EditorState at the
|
||
* current Selection according to a set of heuristics that determine how surrounding nodes
|
||
* should be changed, replaced, or moved to accomodate the incoming ones.
|
||
*
|
||
* @param nodes - the nodes to insert
|
||
*/
|
||
insertNodes(nodes) {
|
||
if (nodes.length === 0) {
|
||
return;
|
||
}
|
||
if (this.anchor.key === 'root') {
|
||
this.insertParagraph();
|
||
const selection = $getSelection();
|
||
if (!$isRangeSelection(selection)) {
|
||
throw Error(`Expected RangeSelection after insertParagraph`);
|
||
}
|
||
return selection.insertNodes(nodes);
|
||
}
|
||
const firstPoint = this.isBackward() ? this.focus : this.anchor;
|
||
const firstBlock = $getAncestor(firstPoint.getNode(), INTERNAL_$isBlock);
|
||
const last = nodes[nodes.length - 1];
|
||
|
||
// CASE 1: insert inside a code block
|
||
if ('__language' in firstBlock && $isElementNode(firstBlock)) {
|
||
if ('__language' in nodes[0]) {
|
||
this.insertText(nodes[0].getTextContent());
|
||
} else {
|
||
const index = removeTextAndSplitBlock(this);
|
||
firstBlock.splice(index, 0, nodes);
|
||
last.selectEnd();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// CASE 2: All elements of the array are inline
|
||
const notInline = node => ($isElementNode(node) || $isDecoratorNode(node)) && !node.isInline();
|
||
if (!nodes.some(notInline)) {
|
||
if (!$isElementNode(firstBlock)) {
|
||
throw Error(`Expected 'firstBlock' to be an ElementNode`);
|
||
}
|
||
const index = removeTextAndSplitBlock(this);
|
||
firstBlock.splice(index, 0, nodes);
|
||
last.selectEnd();
|
||
return;
|
||
}
|
||
|
||
// CASE 3: At least 1 element of the array is not inline
|
||
const blocksParent = $wrapInlineNodes(nodes);
|
||
const nodeToSelect = blocksParent.getLastDescendant();
|
||
const blocks = blocksParent.getChildren();
|
||
const isLI = node => '__value' in node && '__checked' in node;
|
||
const isMergeable = node => $isElementNode(node) && INTERNAL_$isBlock(node) && !node.isEmpty() && $isElementNode(firstBlock) && (!firstBlock.isEmpty() || isLI(firstBlock));
|
||
const shouldInsert = !$isElementNode(firstBlock) || !firstBlock.isEmpty();
|
||
const insertedParagraph = shouldInsert ? this.insertParagraph() : null;
|
||
const lastToInsert = blocks[blocks.length - 1];
|
||
let firstToInsert = blocks[0];
|
||
if (isMergeable(firstToInsert)) {
|
||
if (!$isElementNode(firstBlock)) {
|
||
throw Error(`Expected 'firstBlock' to be an ElementNode`);
|
||
}
|
||
firstBlock.append(...firstToInsert.getChildren());
|
||
firstToInsert = blocks[1];
|
||
}
|
||
if (firstToInsert) {
|
||
insertRangeAfter(firstBlock, firstToInsert);
|
||
}
|
||
const lastInsertedBlock = $getAncestor(nodeToSelect, INTERNAL_$isBlock);
|
||
if (insertedParagraph && $isElementNode(lastInsertedBlock) && (isLI(insertedParagraph) || INTERNAL_$isBlock(lastToInsert))) {
|
||
lastInsertedBlock.append(...insertedParagraph.getChildren());
|
||
insertedParagraph.remove();
|
||
}
|
||
if ($isElementNode(firstBlock) && firstBlock.isEmpty()) {
|
||
firstBlock.remove();
|
||
}
|
||
nodeToSelect.selectEnd();
|
||
|
||
// To understand this take a look at the test "can wrap post-linebreak nodes into new element"
|
||
const lastChild = $isElementNode(firstBlock) ? firstBlock.getLastChild() : null;
|
||
if ($isLineBreakNode(lastChild) && lastInsertedBlock !== firstBlock) {
|
||
lastChild.remove();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Inserts a new ParagraphNode into the EditorState at the current Selection
|
||
*
|
||
* @returns the newly inserted node.
|
||
*/
|
||
insertParagraph() {
|
||
if (this.anchor.key === 'root') {
|
||
const paragraph = $createParagraphNode();
|
||
$getRoot().splice(this.anchor.offset, 0, [paragraph]);
|
||
paragraph.select();
|
||
return paragraph;
|
||
}
|
||
const index = removeTextAndSplitBlock(this);
|
||
const block = $getAncestor(this.anchor.getNode(), INTERNAL_$isBlock);
|
||
if (!$isElementNode(block)) {
|
||
throw Error(`Expected ancestor to be an ElementNode`);
|
||
}
|
||
const firstToAppend = block.getChildAtIndex(index);
|
||
const nodesToInsert = firstToAppend ? [firstToAppend, ...firstToAppend.getNextSiblings()] : [];
|
||
const newBlock = block.insertNewAfter(this, false);
|
||
if (newBlock) {
|
||
newBlock.append(...nodesToInsert);
|
||
newBlock.selectStart();
|
||
return newBlock;
|
||
}
|
||
// if newBlock is null, it means that block is of type CodeNode.
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Inserts a logical linebreak, which may be a new LineBreakNode or a new ParagraphNode, into the EditorState at the
|
||
* current Selection.
|
||
*/
|
||
insertLineBreak(selectStart) {
|
||
const lineBreak = $createLineBreakNode();
|
||
this.insertNodes([lineBreak]);
|
||
// this is used in MacOS with the command 'ctrl-O' (openLineBreak)
|
||
if (selectStart) {
|
||
const parent = lineBreak.getParentOrThrow();
|
||
const index = lineBreak.getIndexWithinParent();
|
||
parent.select(index, index);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Extracts the nodes in the Selection, splitting nodes where necessary
|
||
* to get offset-level precision.
|
||
*
|
||
* @returns The nodes in the Selection
|
||
*/
|
||
extract() {
|
||
const selectedNodes = this.getNodes();
|
||
const selectedNodesLength = selectedNodes.length;
|
||
const lastIndex = selectedNodesLength - 1;
|
||
const anchor = this.anchor;
|
||
const focus = this.focus;
|
||
let firstNode = selectedNodes[0];
|
||
let lastNode = selectedNodes[lastIndex];
|
||
const [anchorOffset, focusOffset] = $getCharacterOffsets(this);
|
||
if (selectedNodesLength === 0) {
|
||
return [];
|
||
} else if (selectedNodesLength === 1) {
|
||
if ($isTextNode(firstNode) && !this.isCollapsed()) {
|
||
const startOffset = anchorOffset > focusOffset ? focusOffset : anchorOffset;
|
||
const endOffset = anchorOffset > focusOffset ? anchorOffset : focusOffset;
|
||
const splitNodes = firstNode.splitText(startOffset, endOffset);
|
||
const node = startOffset === 0 ? splitNodes[0] : splitNodes[1];
|
||
return node != null ? [node] : [];
|
||
}
|
||
return [firstNode];
|
||
}
|
||
const isBefore = anchor.isBefore(focus);
|
||
if ($isTextNode(firstNode)) {
|
||
const startOffset = isBefore ? anchorOffset : focusOffset;
|
||
if (startOffset === firstNode.getTextContentSize()) {
|
||
selectedNodes.shift();
|
||
} else if (startOffset !== 0) {
|
||
[, firstNode] = firstNode.splitText(startOffset);
|
||
selectedNodes[0] = firstNode;
|
||
}
|
||
}
|
||
if ($isTextNode(lastNode)) {
|
||
const lastNodeText = lastNode.getTextContent();
|
||
const lastNodeTextLength = lastNodeText.length;
|
||
const endOffset = isBefore ? focusOffset : anchorOffset;
|
||
if (endOffset === 0) {
|
||
selectedNodes.pop();
|
||
} else if (endOffset !== lastNodeTextLength) {
|
||
[lastNode] = lastNode.splitText(endOffset);
|
||
selectedNodes[lastIndex] = lastNode;
|
||
}
|
||
}
|
||
return selectedNodes;
|
||
}
|
||
|
||
/**
|
||
* Modifies the Selection according to the parameters and a set of heuristics that account for
|
||
* various node types. Can be used to safely move or extend selection by one logical "unit" without
|
||
* dealing explicitly with all the possible node types.
|
||
*
|
||
* @param alter the type of modification to perform
|
||
* @param isBackward whether or not selection is backwards
|
||
* @param granularity the granularity at which to apply the modification
|
||
*/
|
||
modify(alter, isBackward, granularity) {
|
||
const focus = this.focus;
|
||
const anchor = this.anchor;
|
||
const collapse = alter === 'move';
|
||
|
||
// Handle the selection movement around decorators.
|
||
const possibleNode = $getAdjacentNode(focus, isBackward);
|
||
if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) {
|
||
// Make it possible to move selection from range selection to
|
||
// node selection on the node.
|
||
if (collapse && possibleNode.isKeyboardSelectable()) {
|
||
const nodeSelection = $createNodeSelection();
|
||
nodeSelection.add(possibleNode.__key);
|
||
$setSelection(nodeSelection);
|
||
return;
|
||
}
|
||
const sibling = isBackward ? possibleNode.getPreviousSibling() : possibleNode.getNextSibling();
|
||
if (!$isTextNode(sibling)) {
|
||
const parent = possibleNode.getParentOrThrow();
|
||
let offset;
|
||
let elementKey;
|
||
if ($isElementNode(sibling)) {
|
||
elementKey = sibling.__key;
|
||
offset = isBackward ? sibling.getChildrenSize() : 0;
|
||
} else {
|
||
offset = possibleNode.getIndexWithinParent();
|
||
elementKey = parent.__key;
|
||
if (!isBackward) {
|
||
offset++;
|
||
}
|
||
}
|
||
focus.set(elementKey, offset, 'element');
|
||
if (collapse) {
|
||
anchor.set(elementKey, offset, 'element');
|
||
}
|
||
return;
|
||
} else {
|
||
const siblingKey = sibling.__key;
|
||
const offset = isBackward ? sibling.getTextContent().length : 0;
|
||
focus.set(siblingKey, offset, 'text');
|
||
if (collapse) {
|
||
anchor.set(siblingKey, offset, 'text');
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
const editor = getActiveEditor();
|
||
const domSelection = getDOMSelection(editor._window);
|
||
if (!domSelection) {
|
||
return;
|
||
}
|
||
const blockCursorElement = editor._blockCursorElement;
|
||
const rootElement = editor._rootElement;
|
||
// Remove the block cursor element if it exists. This will ensure selection
|
||
// works as intended. If we leave it in the DOM all sorts of strange bugs
|
||
// occur. :/
|
||
if (rootElement !== null && blockCursorElement !== null && $isElementNode(possibleNode) && !possibleNode.isInline() && !possibleNode.canBeEmpty()) {
|
||
removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
|
||
}
|
||
// We use the DOM selection.modify API here to "tell" us what the selection
|
||
// will be. We then use it to update the Lexical selection accordingly. This
|
||
// is much more reliable than waiting for a beforeinput and using the ranges
|
||
// from getTargetRanges(), and is also better than trying to do it ourselves
|
||
// using Intl.Segmenter or other workarounds that struggle with word segments
|
||
// and line segments (especially with word wrapping and non-Roman languages).
|
||
moveNativeSelection(domSelection, alter, isBackward ? 'backward' : 'forward', granularity);
|
||
// Guard against no ranges
|
||
if (domSelection.rangeCount > 0) {
|
||
const range = domSelection.getRangeAt(0);
|
||
// Apply the DOM selection to our Lexical selection.
|
||
const anchorNode = this.anchor.getNode();
|
||
const root = $isRootNode(anchorNode) ? anchorNode : $getNearestRootOrShadowRoot(anchorNode);
|
||
this.applyDOMRange(range);
|
||
this.dirty = true;
|
||
if (!collapse) {
|
||
// Validate selection; make sure that the new extended selection respects shadow roots
|
||
const nodes = this.getNodes();
|
||
const validNodes = [];
|
||
let shrinkSelection = false;
|
||
for (let i = 0; i < nodes.length; i++) {
|
||
const nextNode = nodes[i];
|
||
if ($hasAncestor(nextNode, root)) {
|
||
validNodes.push(nextNode);
|
||
} else {
|
||
shrinkSelection = true;
|
||
}
|
||
}
|
||
if (shrinkSelection && validNodes.length > 0) {
|
||
// validNodes length check is a safeguard against an invalid selection; as getNodes()
|
||
// will return an empty array in this case
|
||
if (isBackward) {
|
||
const firstValidNode = validNodes[0];
|
||
if ($isElementNode(firstValidNode)) {
|
||
firstValidNode.selectStart();
|
||
} else {
|
||
firstValidNode.getParentOrThrow().selectStart();
|
||
}
|
||
} else {
|
||
const lastValidNode = validNodes[validNodes.length - 1];
|
||
if ($isElementNode(lastValidNode)) {
|
||
lastValidNode.selectEnd();
|
||
} else {
|
||
lastValidNode.getParentOrThrow().selectEnd();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Because a range works on start and end, we might need to flip
|
||
// the anchor and focus points to match what the DOM has, not what
|
||
// the range has specifically.
|
||
if (domSelection.anchorNode !== range.startContainer || domSelection.anchorOffset !== range.startOffset) {
|
||
$swapPoints(this);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Performs one logical character deletion operation on the EditorState based on the current Selection.
|
||
* Handles different node types.
|
||
*
|
||
* @param isBackward whether or not the selection is backwards.
|
||
*/
|
||
deleteCharacter(isBackward) {
|
||
const wasCollapsed = this.isCollapsed();
|
||
if (this.isCollapsed()) {
|
||
const anchor = this.anchor;
|
||
const focus = this.focus;
|
||
let anchorNode = anchor.getNode();
|
||
if (!isBackward && (
|
||
// Delete forward handle case
|
||
anchor.type === 'element' && $isElementNode(anchorNode) && anchor.offset === anchorNode.getChildrenSize() || anchor.type === 'text' && anchor.offset === anchorNode.getTextContentSize())) {
|
||
const parent = anchorNode.getParent();
|
||
const nextSibling = anchorNode.getNextSibling() || (parent === null ? null : parent.getNextSibling());
|
||
if ($isElementNode(nextSibling) && nextSibling.isShadowRoot()) {
|
||
return;
|
||
}
|
||
}
|
||
// Handle the deletion around decorators.
|
||
const possibleNode = $getAdjacentNode(focus, isBackward);
|
||
if ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) {
|
||
// Make it possible to move selection from range selection to
|
||
// node selection on the node.
|
||
if (possibleNode.isKeyboardSelectable() && $isElementNode(anchorNode) && anchorNode.getChildrenSize() === 0) {
|
||
anchorNode.remove();
|
||
const nodeSelection = $createNodeSelection();
|
||
nodeSelection.add(possibleNode.__key);
|
||
$setSelection(nodeSelection);
|
||
} else {
|
||
possibleNode.remove();
|
||
const editor = getActiveEditor();
|
||
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
|
||
}
|
||
return;
|
||
} else if (!isBackward && $isElementNode(possibleNode) && $isElementNode(anchorNode) && anchorNode.isEmpty()) {
|
||
anchorNode.remove();
|
||
possibleNode.selectStart();
|
||
return;
|
||
}
|
||
this.modify('extend', isBackward, 'character');
|
||
if (!this.isCollapsed()) {
|
||
const focusNode = focus.type === 'text' ? focus.getNode() : null;
|
||
anchorNode = anchor.type === 'text' ? anchor.getNode() : null;
|
||
if (focusNode !== null && focusNode.isSegmented()) {
|
||
const offset = focus.offset;
|
||
const textContentSize = focusNode.getTextContentSize();
|
||
if (focusNode.is(anchorNode) || isBackward && offset !== textContentSize || !isBackward && offset !== 0) {
|
||
$removeSegment(focusNode, isBackward, offset);
|
||
return;
|
||
}
|
||
} else if (anchorNode !== null && anchorNode.isSegmented()) {
|
||
const offset = anchor.offset;
|
||
const textContentSize = anchorNode.getTextContentSize();
|
||
if (anchorNode.is(focusNode) || isBackward && offset !== 0 || !isBackward && offset !== textContentSize) {
|
||
$removeSegment(anchorNode, isBackward, offset);
|
||
return;
|
||
}
|
||
}
|
||
$updateCaretSelectionForUnicodeCharacter(this, isBackward);
|
||
} else if (isBackward && anchor.offset === 0) {
|
||
// Special handling around rich text nodes
|
||
const element = anchor.type === 'element' ? anchor.getNode() : anchor.getNode().getParentOrThrow();
|
||
if (element.collapseAtStart(this)) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
this.removeText();
|
||
if (isBackward && !wasCollapsed && this.isCollapsed() && this.anchor.type === 'element' && this.anchor.offset === 0) {
|
||
const anchorNode = this.anchor.getNode();
|
||
if (anchorNode.isEmpty() && $isRootNode(anchorNode.getParent()) && anchorNode.getIndexWithinParent() === 0) {
|
||
anchorNode.collapseAtStart(this);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Performs one logical line deletion operation on the EditorState based on the current Selection.
|
||
* Handles different node types.
|
||
*
|
||
* @param isBackward whether or not the selection is backwards.
|
||
*/
|
||
deleteLine(isBackward) {
|
||
if (this.isCollapsed()) {
|
||
if (this.anchor.type === 'text') {
|
||
this.modify('extend', isBackward, 'lineboundary');
|
||
}
|
||
|
||
// If selection is extended to cover text edge then extend it one character more
|
||
// to delete its parent element. Otherwise text content will be deleted but empty
|
||
// parent node will remain
|
||
const endPoint = isBackward ? this.focus : this.anchor;
|
||
if (endPoint.offset === 0) {
|
||
this.modify('extend', isBackward, 'character');
|
||
}
|
||
}
|
||
this.removeText();
|
||
}
|
||
|
||
/**
|
||
* Performs one logical word deletion operation on the EditorState based on the current Selection.
|
||
* Handles different node types.
|
||
*
|
||
* @param isBackward whether or not the selection is backwards.
|
||
*/
|
||
deleteWord(isBackward) {
|
||
if (this.isCollapsed()) {
|
||
this.modify('extend', isBackward, 'word');
|
||
}
|
||
this.removeText();
|
||
}
|
||
|
||
/**
|
||
* Returns whether the Selection is "backwards", meaning the focus
|
||
* logically precedes the anchor in the EditorState.
|
||
* @returns true if the Selection is backwards, false otherwise.
|
||
*/
|
||
isBackward() {
|
||
return this.focus.isBefore(this.anchor);
|
||
}
|
||
getStartEndPoints() {
|
||
return [this.anchor, this.focus];
|
||
}
|
||
}
|
||
function $isNodeSelection(x) {
|
||
return x instanceof NodeSelection;
|
||
}
|
||
function getCharacterOffset(point) {
|
||
const offset = point.offset;
|
||
if (point.type === 'text') {
|
||
return offset;
|
||
}
|
||
const parent = point.getNode();
|
||
return offset === parent.getChildrenSize() ? parent.getTextContent().length : 0;
|
||
}
|
||
function $getCharacterOffsets(selection) {
|
||
const anchorAndFocus = selection.getStartEndPoints();
|
||
if (anchorAndFocus === null) {
|
||
return [0, 0];
|
||
}
|
||
const [anchor, focus] = anchorAndFocus;
|
||
if (anchor.type === 'element' && focus.type === 'element' && anchor.key === focus.key && anchor.offset === focus.offset) {
|
||
return [0, 0];
|
||
}
|
||
return [getCharacterOffset(anchor), getCharacterOffset(focus)];
|
||
}
|
||
function $swapPoints(selection) {
|
||
const focus = selection.focus;
|
||
const anchor = selection.anchor;
|
||
const anchorKey = anchor.key;
|
||
const anchorOffset = anchor.offset;
|
||
const anchorType = anchor.type;
|
||
$setPointValues(anchor, focus.key, focus.offset, focus.type);
|
||
$setPointValues(focus, anchorKey, anchorOffset, anchorType);
|
||
selection._cachedNodes = null;
|
||
}
|
||
function moveNativeSelection(domSelection, alter, direction, granularity) {
|
||
// Selection.modify() method applies a change to the current selection or cursor position,
|
||
// but is still non-standard in some browsers.
|
||
domSelection.modify(alter, direction, granularity);
|
||
}
|
||
function $updateCaretSelectionForUnicodeCharacter(selection, isBackward) {
|
||
const anchor = selection.anchor;
|
||
const focus = selection.focus;
|
||
const anchorNode = anchor.getNode();
|
||
const focusNode = focus.getNode();
|
||
if (anchorNode === focusNode && anchor.type === 'text' && focus.type === 'text') {
|
||
// Handling of multibyte characters
|
||
const anchorOffset = anchor.offset;
|
||
const focusOffset = focus.offset;
|
||
const isBefore = anchorOffset < focusOffset;
|
||
const startOffset = isBefore ? anchorOffset : focusOffset;
|
||
const endOffset = isBefore ? focusOffset : anchorOffset;
|
||
const characterOffset = endOffset - 1;
|
||
if (startOffset !== characterOffset) {
|
||
const text = anchorNode.getTextContent().slice(startOffset, endOffset);
|
||
if (!doesContainGrapheme(text)) {
|
||
if (isBackward) {
|
||
focus.offset = characterOffset;
|
||
} else {
|
||
anchor.offset = characterOffset;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function $removeSegment(node, isBackward, offset) {
|
||
const textNode = node;
|
||
const textContent = textNode.getTextContent();
|
||
const split = textContent.split(/(?=\s)/g);
|
||
const splitLength = split.length;
|
||
let segmentOffset = 0;
|
||
let restoreOffset = 0;
|
||
for (let i = 0; i < splitLength; i++) {
|
||
const text = split[i];
|
||
const isLast = i === splitLength - 1;
|
||
restoreOffset = segmentOffset;
|
||
segmentOffset += text.length;
|
||
if (isBackward && segmentOffset === offset || segmentOffset > offset || isLast) {
|
||
split.splice(i, 1);
|
||
if (isLast) {
|
||
restoreOffset = undefined;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
const nextTextContent = split.join('').trim();
|
||
if (nextTextContent === '') {
|
||
textNode.remove();
|
||
} else {
|
||
textNode.setTextContent(nextTextContent);
|
||
textNode.select(restoreOffset, restoreOffset);
|
||
}
|
||
}
|
||
function shouldResolveAncestor(resolvedElement, resolvedOffset, lastPoint) {
|
||
const parent = resolvedElement.getParent();
|
||
return lastPoint === null || parent === null || !parent.canBeEmpty() || parent !== lastPoint.getNode();
|
||
}
|
||
function internalResolveSelectionPoint(dom, offset, lastPoint, editor) {
|
||
let resolvedOffset = offset;
|
||
let resolvedNode;
|
||
// If we have selection on an element, we will
|
||
// need to figure out (using the offset) what text
|
||
// node should be selected.
|
||
|
||
if (dom.nodeType === DOM_ELEMENT_TYPE) {
|
||
// Resolve element to a ElementNode, or TextNode, or null
|
||
let moveSelectionToEnd = false;
|
||
// Given we're moving selection to another node, selection is
|
||
// definitely dirty.
|
||
// We use the anchor to find which child node to select
|
||
const childNodes = dom.childNodes;
|
||
const childNodesLength = childNodes.length;
|
||
// If the anchor is the same as length, then this means we
|
||
// need to select the very last text node.
|
||
if (resolvedOffset === childNodesLength) {
|
||
moveSelectionToEnd = true;
|
||
resolvedOffset = childNodesLength - 1;
|
||
}
|
||
let childDOM = childNodes[resolvedOffset];
|
||
let hasBlockCursor = false;
|
||
if (childDOM === editor._blockCursorElement) {
|
||
childDOM = childNodes[resolvedOffset + 1];
|
||
hasBlockCursor = true;
|
||
} else if (editor._blockCursorElement !== null) {
|
||
resolvedOffset--;
|
||
}
|
||
resolvedNode = getNodeFromDOM(childDOM);
|
||
if ($isTextNode(resolvedNode)) {
|
||
resolvedOffset = getTextNodeOffset(resolvedNode, moveSelectionToEnd);
|
||
} else {
|
||
let resolvedElement = getNodeFromDOM(dom);
|
||
// Ensure resolvedElement is actually a element.
|
||
if (resolvedElement === null) {
|
||
return null;
|
||
}
|
||
if ($isElementNode(resolvedElement)) {
|
||
let child = resolvedElement.getChildAtIndex(resolvedOffset);
|
||
if ($isElementNode(child) && shouldResolveAncestor(child, resolvedOffset, lastPoint)) {
|
||
const descendant = moveSelectionToEnd ? child.getLastDescendant() : child.getFirstDescendant();
|
||
if (descendant === null) {
|
||
resolvedElement = child;
|
||
resolvedOffset = 0;
|
||
} else {
|
||
child = descendant;
|
||
resolvedElement = $isElementNode(child) ? child : child.getParentOrThrow();
|
||
}
|
||
}
|
||
if ($isTextNode(child)) {
|
||
resolvedNode = child;
|
||
resolvedElement = null;
|
||
resolvedOffset = getTextNodeOffset(child, moveSelectionToEnd);
|
||
} else if (child !== resolvedElement && moveSelectionToEnd && !hasBlockCursor) {
|
||
resolvedOffset++;
|
||
}
|
||
} else {
|
||
const index = resolvedElement.getIndexWithinParent();
|
||
// When selecting decorators, there can be some selection issues when using resolvedOffset,
|
||
// and instead we should be checking if we're using the offset
|
||
if (offset === 0 && $isDecoratorNode(resolvedElement) && getNodeFromDOM(dom) === resolvedElement) {
|
||
resolvedOffset = index;
|
||
} else {
|
||
resolvedOffset = index + 1;
|
||
}
|
||
resolvedElement = resolvedElement.getParentOrThrow();
|
||
}
|
||
if ($isElementNode(resolvedElement)) {
|
||
return $createPoint(resolvedElement.__key, resolvedOffset, 'element');
|
||
}
|
||
}
|
||
} else {
|
||
// TextNode or null
|
||
resolvedNode = getNodeFromDOM(dom);
|
||
}
|
||
if (!$isTextNode(resolvedNode)) {
|
||
return null;
|
||
}
|
||
return $createPoint(resolvedNode.__key, resolvedOffset, 'text');
|
||
}
|
||
function resolveSelectionPointOnBoundary(point, isBackward, isCollapsed) {
|
||
const offset = point.offset;
|
||
const node = point.getNode();
|
||
if (offset === 0) {
|
||
const prevSibling = node.getPreviousSibling();
|
||
const parent = node.getParent();
|
||
if (!isBackward) {
|
||
if ($isElementNode(prevSibling) && !isCollapsed && prevSibling.isInline()) {
|
||
point.key = prevSibling.__key;
|
||
point.offset = prevSibling.getChildrenSize();
|
||
// @ts-expect-error: intentional
|
||
point.type = 'element';
|
||
} else if ($isTextNode(prevSibling)) {
|
||
point.key = prevSibling.__key;
|
||
point.offset = prevSibling.getTextContent().length;
|
||
}
|
||
} else if ((isCollapsed || !isBackward) && prevSibling === null && $isElementNode(parent) && parent.isInline()) {
|
||
const parentSibling = parent.getPreviousSibling();
|
||
if ($isTextNode(parentSibling)) {
|
||
point.key = parentSibling.__key;
|
||
point.offset = parentSibling.getTextContent().length;
|
||
}
|
||
}
|
||
} else if (offset === node.getTextContent().length) {
|
||
const nextSibling = node.getNextSibling();
|
||
const parent = node.getParent();
|
||
if (isBackward && $isElementNode(nextSibling) && nextSibling.isInline()) {
|
||
point.key = nextSibling.__key;
|
||
point.offset = 0;
|
||
// @ts-expect-error: intentional
|
||
point.type = 'element';
|
||
} else if ((isCollapsed || isBackward) && nextSibling === null && $isElementNode(parent) && parent.isInline() && !parent.canInsertTextAfter()) {
|
||
const parentSibling = parent.getNextSibling();
|
||
if ($isTextNode(parentSibling)) {
|
||
point.key = parentSibling.__key;
|
||
point.offset = 0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function normalizeSelectionPointsForBoundaries(anchor, focus, lastSelection) {
|
||
if (anchor.type === 'text' && focus.type === 'text') {
|
||
const isBackward = anchor.isBefore(focus);
|
||
const isCollapsed = anchor.is(focus);
|
||
|
||
// Attempt to normalize the offset to the previous sibling if we're at the
|
||
// start of a text node and the sibling is a text node or inline element.
|
||
resolveSelectionPointOnBoundary(anchor, isBackward, isCollapsed);
|
||
resolveSelectionPointOnBoundary(focus, !isBackward, isCollapsed);
|
||
if (isCollapsed) {
|
||
focus.key = anchor.key;
|
||
focus.offset = anchor.offset;
|
||
focus.type = anchor.type;
|
||
}
|
||
const editor = getActiveEditor();
|
||
if (editor.isComposing() && editor._compositionKey !== anchor.key && $isRangeSelection(lastSelection)) {
|
||
const lastAnchor = lastSelection.anchor;
|
||
const lastFocus = lastSelection.focus;
|
||
$setPointValues(anchor, lastAnchor.key, lastAnchor.offset, lastAnchor.type);
|
||
$setPointValues(focus, lastFocus.key, lastFocus.offset, lastFocus.type);
|
||
}
|
||
}
|
||
}
|
||
function internalResolveSelectionPoints(anchorDOM, anchorOffset, focusDOM, focusOffset, editor, lastSelection) {
|
||
if (anchorDOM === null || focusDOM === null || !isSelectionWithinEditor(editor, anchorDOM, focusDOM)) {
|
||
return null;
|
||
}
|
||
const resolvedAnchorPoint = internalResolveSelectionPoint(anchorDOM, anchorOffset, $isRangeSelection(lastSelection) ? lastSelection.anchor : null, editor);
|
||
if (resolvedAnchorPoint === null) {
|
||
return null;
|
||
}
|
||
const resolvedFocusPoint = internalResolveSelectionPoint(focusDOM, focusOffset, $isRangeSelection(lastSelection) ? lastSelection.focus : null, editor);
|
||
if (resolvedFocusPoint === null) {
|
||
return null;
|
||
}
|
||
if (resolvedAnchorPoint.type === 'element' && resolvedFocusPoint.type === 'element') {
|
||
const anchorNode = getNodeFromDOM(anchorDOM);
|
||
const focusNode = getNodeFromDOM(focusDOM);
|
||
// Ensure if we're selecting the content of a decorator that we
|
||
// return null for this point, as it's not in the controlled scope
|
||
// of Lexical.
|
||
if ($isDecoratorNode(anchorNode) && $isDecoratorNode(focusNode)) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Handle normalization of selection when it is at the boundaries.
|
||
normalizeSelectionPointsForBoundaries(resolvedAnchorPoint, resolvedFocusPoint, lastSelection);
|
||
return [resolvedAnchorPoint, resolvedFocusPoint];
|
||
}
|
||
function $isBlockElementNode(node) {
|
||
return $isElementNode(node) && !node.isInline();
|
||
}
|
||
|
||
// This is used to make a selection when the existing
|
||
// selection is null, i.e. forcing selection on the editor
|
||
// when it current exists outside the editor.
|
||
|
||
function internalMakeRangeSelection(anchorKey, anchorOffset, focusKey, focusOffset, anchorType, focusType) {
|
||
const editorState = getActiveEditorState();
|
||
const selection = new RangeSelection($createPoint(anchorKey, anchorOffset, anchorType), $createPoint(focusKey, focusOffset, focusType), 0, '');
|
||
selection.dirty = true;
|
||
editorState._selection = selection;
|
||
return selection;
|
||
}
|
||
function $createRangeSelection() {
|
||
const anchor = $createPoint('root', 0, 'element');
|
||
const focus = $createPoint('root', 0, 'element');
|
||
return new RangeSelection(anchor, focus, 0, '');
|
||
}
|
||
function $createNodeSelection() {
|
||
return new NodeSelection(new Set());
|
||
}
|
||
function internalCreateSelection(editor) {
|
||
const currentEditorState = editor.getEditorState();
|
||
const lastSelection = currentEditorState._selection;
|
||
const domSelection = getDOMSelection(editor._window);
|
||
if ($isRangeSelection(lastSelection) || lastSelection == null) {
|
||
return internalCreateRangeSelection(lastSelection, domSelection, editor, null);
|
||
}
|
||
return lastSelection.clone();
|
||
}
|
||
function internalCreateRangeSelection(lastSelection, domSelection, editor, event) {
|
||
const windowObj = editor._window;
|
||
if (windowObj === null) {
|
||
return null;
|
||
}
|
||
// When we create a selection, we try to use the previous
|
||
// selection where possible, unless an actual user selection
|
||
// change has occurred. When we do need to create a new selection
|
||
// we validate we can have text nodes for both anchor and focus
|
||
// nodes. If that holds true, we then return that selection
|
||
// as a mutable object that we use for the editor state for this
|
||
// update cycle. If a selection gets changed, and requires a
|
||
// update to native DOM selection, it gets marked as "dirty".
|
||
// If the selection changes, but matches with the existing
|
||
// DOM selection, then we only need to sync it. Otherwise,
|
||
// we generally bail out of doing an update to selection during
|
||
// reconciliation unless there are dirty nodes that need
|
||
// reconciling.
|
||
|
||
const windowEvent = event || windowObj.event;
|
||
const eventType = windowEvent ? windowEvent.type : undefined;
|
||
const isSelectionChange = eventType === 'selectionchange';
|
||
const useDOMSelection = !getIsProcessingMutations() && (isSelectionChange || eventType === 'beforeinput' || eventType === 'compositionstart' || eventType === 'compositionend' || eventType === 'click' && windowEvent && windowEvent.detail === 3 || eventType === 'drop' || eventType === undefined);
|
||
let anchorDOM, focusDOM, anchorOffset, focusOffset;
|
||
if (!$isRangeSelection(lastSelection) || useDOMSelection) {
|
||
if (domSelection === null) {
|
||
return null;
|
||
}
|
||
anchorDOM = domSelection.anchorNode;
|
||
focusDOM = domSelection.focusNode;
|
||
anchorOffset = domSelection.anchorOffset;
|
||
focusOffset = domSelection.focusOffset;
|
||
if (isSelectionChange && $isRangeSelection(lastSelection) && !isSelectionWithinEditor(editor, anchorDOM, focusDOM)) {
|
||
return lastSelection.clone();
|
||
}
|
||
} else {
|
||
return lastSelection.clone();
|
||
}
|
||
// Let's resolve the text nodes from the offsets and DOM nodes we have from
|
||
// native selection.
|
||
const resolvedSelectionPoints = internalResolveSelectionPoints(anchorDOM, anchorOffset, focusDOM, focusOffset, editor, lastSelection);
|
||
if (resolvedSelectionPoints === null) {
|
||
return null;
|
||
}
|
||
const [resolvedAnchorPoint, resolvedFocusPoint] = resolvedSelectionPoints;
|
||
return new RangeSelection(resolvedAnchorPoint, resolvedFocusPoint, !$isRangeSelection(lastSelection) ? 0 : lastSelection.format, !$isRangeSelection(lastSelection) ? '' : lastSelection.style);
|
||
}
|
||
function $getSelection() {
|
||
const editorState = getActiveEditorState();
|
||
return editorState._selection;
|
||
}
|
||
function $getPreviousSelection() {
|
||
const editor = getActiveEditor();
|
||
return editor._editorState._selection;
|
||
}
|
||
function $updateElementSelectionOnCreateDeleteNode(selection, parentNode, nodeOffset, times = 1) {
|
||
const anchor = selection.anchor;
|
||
const focus = selection.focus;
|
||
const anchorNode = anchor.getNode();
|
||
const focusNode = focus.getNode();
|
||
if (!parentNode.is(anchorNode) && !parentNode.is(focusNode)) {
|
||
return;
|
||
}
|
||
const parentKey = parentNode.__key;
|
||
// Single node. We shift selection but never redimension it
|
||
if (selection.isCollapsed()) {
|
||
const selectionOffset = anchor.offset;
|
||
if (nodeOffset <= selectionOffset && times > 0 || nodeOffset < selectionOffset && times < 0) {
|
||
const newSelectionOffset = Math.max(0, selectionOffset + times);
|
||
anchor.set(parentKey, newSelectionOffset, 'element');
|
||
focus.set(parentKey, newSelectionOffset, 'element');
|
||
// The new selection might point to text nodes, try to resolve them
|
||
$updateSelectionResolveTextNodes(selection);
|
||
}
|
||
} else {
|
||
// Multiple nodes selected. We shift or redimension selection
|
||
const isBackward = selection.isBackward();
|
||
const firstPoint = isBackward ? focus : anchor;
|
||
const firstPointNode = firstPoint.getNode();
|
||
const lastPoint = isBackward ? anchor : focus;
|
||
const lastPointNode = lastPoint.getNode();
|
||
if (parentNode.is(firstPointNode)) {
|
||
const firstPointOffset = firstPoint.offset;
|
||
if (nodeOffset <= firstPointOffset && times > 0 || nodeOffset < firstPointOffset && times < 0) {
|
||
firstPoint.set(parentKey, Math.max(0, firstPointOffset + times), 'element');
|
||
}
|
||
}
|
||
if (parentNode.is(lastPointNode)) {
|
||
const lastPointOffset = lastPoint.offset;
|
||
if (nodeOffset <= lastPointOffset && times > 0 || nodeOffset < lastPointOffset && times < 0) {
|
||
lastPoint.set(parentKey, Math.max(0, lastPointOffset + times), 'element');
|
||
}
|
||
}
|
||
}
|
||
// The new selection might point to text nodes, try to resolve them
|
||
$updateSelectionResolveTextNodes(selection);
|
||
}
|
||
function $updateSelectionResolveTextNodes(selection) {
|
||
const anchor = selection.anchor;
|
||
const anchorOffset = anchor.offset;
|
||
const focus = selection.focus;
|
||
const focusOffset = focus.offset;
|
||
const anchorNode = anchor.getNode();
|
||
const focusNode = focus.getNode();
|
||
if (selection.isCollapsed()) {
|
||
if (!$isElementNode(anchorNode)) {
|
||
return;
|
||
}
|
||
const childSize = anchorNode.getChildrenSize();
|
||
const anchorOffsetAtEnd = anchorOffset >= childSize;
|
||
const child = anchorOffsetAtEnd ? anchorNode.getChildAtIndex(childSize - 1) : anchorNode.getChildAtIndex(anchorOffset);
|
||
if ($isTextNode(child)) {
|
||
let newOffset = 0;
|
||
if (anchorOffsetAtEnd) {
|
||
newOffset = child.getTextContentSize();
|
||
}
|
||
anchor.set(child.__key, newOffset, 'text');
|
||
focus.set(child.__key, newOffset, 'text');
|
||
}
|
||
return;
|
||
}
|
||
if ($isElementNode(anchorNode)) {
|
||
const childSize = anchorNode.getChildrenSize();
|
||
const anchorOffsetAtEnd = anchorOffset >= childSize;
|
||
const child = anchorOffsetAtEnd ? anchorNode.getChildAtIndex(childSize - 1) : anchorNode.getChildAtIndex(anchorOffset);
|
||
if ($isTextNode(child)) {
|
||
let newOffset = 0;
|
||
if (anchorOffsetAtEnd) {
|
||
newOffset = child.getTextContentSize();
|
||
}
|
||
anchor.set(child.__key, newOffset, 'text');
|
||
}
|
||
}
|
||
if ($isElementNode(focusNode)) {
|
||
const childSize = focusNode.getChildrenSize();
|
||
const focusOffsetAtEnd = focusOffset >= childSize;
|
||
const child = focusOffsetAtEnd ? focusNode.getChildAtIndex(childSize - 1) : focusNode.getChildAtIndex(focusOffset);
|
||
if ($isTextNode(child)) {
|
||
let newOffset = 0;
|
||
if (focusOffsetAtEnd) {
|
||
newOffset = child.getTextContentSize();
|
||
}
|
||
focus.set(child.__key, newOffset, 'text');
|
||
}
|
||
}
|
||
}
|
||
function applySelectionTransforms(nextEditorState, editor) {
|
||
const prevEditorState = editor.getEditorState();
|
||
const prevSelection = prevEditorState._selection;
|
||
const nextSelection = nextEditorState._selection;
|
||
if ($isRangeSelection(nextSelection)) {
|
||
const anchor = nextSelection.anchor;
|
||
const focus = nextSelection.focus;
|
||
let anchorNode;
|
||
if (anchor.type === 'text') {
|
||
anchorNode = anchor.getNode();
|
||
anchorNode.selectionTransform(prevSelection, nextSelection);
|
||
}
|
||
if (focus.type === 'text') {
|
||
const focusNode = focus.getNode();
|
||
if (anchorNode !== focusNode) {
|
||
focusNode.selectionTransform(prevSelection, nextSelection);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function moveSelectionPointToSibling(point, node, parent, prevSibling, nextSibling) {
|
||
let siblingKey = null;
|
||
let offset = 0;
|
||
let type = null;
|
||
if (prevSibling !== null) {
|
||
siblingKey = prevSibling.__key;
|
||
if ($isTextNode(prevSibling)) {
|
||
offset = prevSibling.getTextContentSize();
|
||
type = 'text';
|
||
} else if ($isElementNode(prevSibling)) {
|
||
offset = prevSibling.getChildrenSize();
|
||
type = 'element';
|
||
}
|
||
} else {
|
||
if (nextSibling !== null) {
|
||
siblingKey = nextSibling.__key;
|
||
if ($isTextNode(nextSibling)) {
|
||
type = 'text';
|
||
} else if ($isElementNode(nextSibling)) {
|
||
type = 'element';
|
||
}
|
||
}
|
||
}
|
||
if (siblingKey !== null && type !== null) {
|
||
point.set(siblingKey, offset, type);
|
||
} else {
|
||
offset = node.getIndexWithinParent();
|
||
if (offset === -1) {
|
||
// Move selection to end of parent
|
||
offset = parent.getChildrenSize();
|
||
}
|
||
point.set(parent.__key, offset, 'element');
|
||
}
|
||
}
|
||
function adjustPointOffsetForMergedSibling(point, isBefore, key, target, textLength) {
|
||
if (point.type === 'text') {
|
||
point.key = key;
|
||
if (!isBefore) {
|
||
point.offset += textLength;
|
||
}
|
||
} else if (point.offset > target.getIndexWithinParent()) {
|
||
point.offset -= 1;
|
||
}
|
||
}
|
||
function updateDOMSelection(prevSelection, nextSelection, editor, domSelection, tags, rootElement, nodeCount) {
|
||
const anchorDOMNode = domSelection.anchorNode;
|
||
const focusDOMNode = domSelection.focusNode;
|
||
const anchorOffset = domSelection.anchorOffset;
|
||
const focusOffset = domSelection.focusOffset;
|
||
const activeElement = document.activeElement;
|
||
|
||
// TODO: make this not hard-coded, and add another config option
|
||
// that makes this configurable.
|
||
if (tags.has('collaboration') && activeElement !== rootElement || activeElement !== null && isSelectionCapturedInDecoratorInput(activeElement)) {
|
||
return;
|
||
}
|
||
if (!$isRangeSelection(nextSelection)) {
|
||
// We don't remove selection if the prevSelection is null because
|
||
// of editor.setRootElement(). If this occurs on init when the
|
||
// editor is already focused, then this can cause the editor to
|
||
// lose focus.
|
||
if (prevSelection !== null && isSelectionWithinEditor(editor, anchorDOMNode, focusDOMNode)) {
|
||
domSelection.removeAllRanges();
|
||
}
|
||
return;
|
||
}
|
||
const anchor = nextSelection.anchor;
|
||
const focus = nextSelection.focus;
|
||
const anchorKey = anchor.key;
|
||
const focusKey = focus.key;
|
||
const anchorDOM = getElementByKeyOrThrow(editor, anchorKey);
|
||
const focusDOM = getElementByKeyOrThrow(editor, focusKey);
|
||
const nextAnchorOffset = anchor.offset;
|
||
const nextFocusOffset = focus.offset;
|
||
const nextFormat = nextSelection.format;
|
||
const nextStyle = nextSelection.style;
|
||
const isCollapsed = nextSelection.isCollapsed();
|
||
let nextAnchorNode = anchorDOM;
|
||
let nextFocusNode = focusDOM;
|
||
let anchorFormatOrStyleChanged = false;
|
||
if (anchor.type === 'text') {
|
||
nextAnchorNode = getDOMTextNode(anchorDOM);
|
||
const anchorNode = anchor.getNode();
|
||
anchorFormatOrStyleChanged = anchorNode.getFormat() !== nextFormat || anchorNode.getStyle() !== nextStyle;
|
||
} else if ($isRangeSelection(prevSelection) && prevSelection.anchor.type === 'text') {
|
||
anchorFormatOrStyleChanged = true;
|
||
}
|
||
if (focus.type === 'text') {
|
||
nextFocusNode = getDOMTextNode(focusDOM);
|
||
}
|
||
|
||
// If we can't get an underlying text node for selection, then
|
||
// we should avoid setting selection to something incorrect.
|
||
if (nextAnchorNode === null || nextFocusNode === null) {
|
||
return;
|
||
}
|
||
if (isCollapsed && (prevSelection === null || anchorFormatOrStyleChanged || $isRangeSelection(prevSelection) && (prevSelection.format !== nextFormat || prevSelection.style !== nextStyle))) {
|
||
markCollapsedSelectionFormat(nextFormat, nextStyle, nextAnchorOffset, anchorKey, performance.now());
|
||
}
|
||
|
||
// Diff against the native DOM selection to ensure we don't do
|
||
// an unnecessary selection update. We also skip this check if
|
||
// we're moving selection to within an element, as this can
|
||
// sometimes be problematic around scrolling.
|
||
if (anchorOffset === nextAnchorOffset && focusOffset === nextFocusOffset && anchorDOMNode === nextAnchorNode && focusDOMNode === nextFocusNode &&
|
||
// Badly interpreted range selection when collapsed - #1482
|
||
!(domSelection.type === 'Range' && isCollapsed)) {
|
||
// If the root element does not have focus, ensure it has focus
|
||
if (activeElement === null || !rootElement.contains(activeElement)) {
|
||
rootElement.focus({
|
||
preventScroll: true
|
||
});
|
||
}
|
||
if (anchor.type !== 'element') {
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Apply the updated selection to the DOM. Note: this will trigger
|
||
// a "selectionchange" event, although it will be asynchronous.
|
||
try {
|
||
domSelection.setBaseAndExtent(nextAnchorNode, nextAnchorOffset, nextFocusNode, nextFocusOffset);
|
||
} catch (error) {
|
||
// If we encounter an error, continue. This can sometimes
|
||
// occur with FF and there's no good reason as to why it
|
||
// should happen.
|
||
}
|
||
if (!tags.has('skip-scroll-into-view') && nextSelection.isCollapsed() && rootElement !== null && rootElement === document.activeElement) {
|
||
const selectionTarget = nextSelection instanceof RangeSelection && nextSelection.anchor.type === 'element' ? nextAnchorNode.childNodes[nextAnchorOffset] || null : domSelection.rangeCount > 0 ? domSelection.getRangeAt(0) : null;
|
||
if (selectionTarget !== null) {
|
||
let selectionRect;
|
||
if (selectionTarget instanceof Text) {
|
||
const range = document.createRange();
|
||
range.selectNode(selectionTarget);
|
||
selectionRect = range.getBoundingClientRect();
|
||
} else {
|
||
selectionRect = selectionTarget.getBoundingClientRect();
|
||
}
|
||
scrollIntoViewIfNeeded(editor, selectionRect, rootElement);
|
||
}
|
||
}
|
||
markSelectionChangeFromDOMUpdate();
|
||
}
|
||
function $insertNodes(nodes) {
|
||
let selection = $getSelection() || $getPreviousSelection();
|
||
if (selection === null) {
|
||
selection = $getRoot().selectEnd();
|
||
}
|
||
selection.insertNodes(nodes);
|
||
}
|
||
function $getTextContent() {
|
||
const selection = $getSelection();
|
||
if (selection === null) {
|
||
return '';
|
||
}
|
||
return selection.getTextContent();
|
||
}
|
||
function removeTextAndSplitBlock(selection) {
|
||
if (!selection.isCollapsed()) {
|
||
selection.removeText();
|
||
}
|
||
const anchor = selection.anchor;
|
||
let node = anchor.getNode();
|
||
let offset = anchor.offset;
|
||
while (!INTERNAL_$isBlock(node)) {
|
||
[node, offset] = splitNodeAtPoint(node, offset);
|
||
}
|
||
return offset;
|
||
}
|
||
function splitNodeAtPoint(node, offset) {
|
||
const parent = node.getParent();
|
||
if (!parent) {
|
||
const paragraph = $createParagraphNode();
|
||
$getRoot().append(paragraph);
|
||
paragraph.select();
|
||
return [$getRoot(), 0];
|
||
}
|
||
if ($isTextNode(node)) {
|
||
const split = node.splitText(offset);
|
||
if (split.length === 0) {
|
||
return [parent, node.getIndexWithinParent()];
|
||
}
|
||
const x = offset === 0 ? 0 : 1;
|
||
const index = split[0].getIndexWithinParent() + x;
|
||
return [parent, index];
|
||
}
|
||
if (!$isElementNode(node) || offset === 0) {
|
||
return [parent, node.getIndexWithinParent()];
|
||
}
|
||
const firstToAppend = node.getChildAtIndex(offset);
|
||
if (firstToAppend) {
|
||
const insertPoint = new RangeSelection($createPoint(node.__key, offset, 'element'), $createPoint(node.__key, offset, 'element'), 0, '');
|
||
const newElement = node.insertNewAfter(insertPoint);
|
||
if (newElement) {
|
||
newElement.append(firstToAppend, ...firstToAppend.getNextSiblings());
|
||
}
|
||
}
|
||
return [parent, node.getIndexWithinParent() + 1];
|
||
}
|
||
function $wrapInlineNodes(nodes) {
|
||
// We temporarily insert the topLevelNodes into an arbitrary ElementNode,
|
||
// since insertAfter does not work on nodes that have no parent (TO-DO: fix that).
|
||
const virtualRoot = $createParagraphNode();
|
||
let currentBlock = null;
|
||
for (let i = 0; i < nodes.length; i++) {
|
||
const node = nodes[i];
|
||
const isLineBreakNode = $isLineBreakNode(node);
|
||
if (isLineBreakNode || $isDecoratorNode(node) && node.isInline() || $isElementNode(node) && node.isInline() || $isTextNode(node) || node.isParentRequired()) {
|
||
if (currentBlock === null) {
|
||
currentBlock = node.createParentElementNode();
|
||
virtualRoot.append(currentBlock);
|
||
// In the case of LineBreakNode, we just need to
|
||
// add an empty ParagraphNode to the topLevelBlocks.
|
||
if (isLineBreakNode) {
|
||
continue;
|
||
}
|
||
}
|
||
if (currentBlock !== null) {
|
||
currentBlock.append(node);
|
||
}
|
||
} else {
|
||
virtualRoot.append(node);
|
||
currentBlock = null;
|
||
}
|
||
}
|
||
return virtualRoot;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
let activeEditorState = null;
|
||
let activeEditor = null;
|
||
let isReadOnlyMode = false;
|
||
let isAttemptingToRecoverFromReconcilerError = false;
|
||
let infiniteTransformCount = 0;
|
||
const observerOptions = {
|
||
characterData: true,
|
||
childList: true,
|
||
subtree: true
|
||
};
|
||
function isCurrentlyReadOnlyMode() {
|
||
return isReadOnlyMode || activeEditorState !== null && activeEditorState._readOnly;
|
||
}
|
||
function errorOnReadOnly() {
|
||
if (isReadOnlyMode) {
|
||
{
|
||
throw Error(`Cannot use method in read-only mode.`);
|
||
}
|
||
}
|
||
}
|
||
function errorOnInfiniteTransforms() {
|
||
if (infiniteTransformCount > 99) {
|
||
{
|
||
throw Error(`One or more transforms are endlessly triggering additional transforms. May have encountered infinite recursion caused by transforms that have their preconditions too lose and/or conflict with each other.`);
|
||
}
|
||
}
|
||
}
|
||
function getActiveEditorState() {
|
||
if (activeEditorState === null) {
|
||
{
|
||
throw Error(`Unable to find an active editor state. State helpers or node methods can only be used synchronously during the callback of editor.update() or editorState.read().`);
|
||
}
|
||
}
|
||
return activeEditorState;
|
||
}
|
||
function getActiveEditor() {
|
||
if (activeEditor === null) {
|
||
{
|
||
throw Error(`Unable to find an active editor. This method can only be used synchronously during the callback of editor.update().`);
|
||
}
|
||
}
|
||
return activeEditor;
|
||
}
|
||
function internalGetActiveEditor() {
|
||
return activeEditor;
|
||
}
|
||
function $applyTransforms(editor, node, transformsCache) {
|
||
const type = node.__type;
|
||
const registeredNode = getRegisteredNodeOrThrow(editor, type);
|
||
let transformsArr = transformsCache.get(type);
|
||
if (transformsArr === undefined) {
|
||
transformsArr = Array.from(registeredNode.transforms);
|
||
transformsCache.set(type, transformsArr);
|
||
}
|
||
const transformsArrLength = transformsArr.length;
|
||
for (let i = 0; i < transformsArrLength; i++) {
|
||
transformsArr[i](node);
|
||
if (!node.isAttached()) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
function $isNodeValidForTransform(node, compositionKey) {
|
||
return node !== undefined &&
|
||
// We don't want to transform nodes being composed
|
||
node.__key !== compositionKey && node.isAttached();
|
||
}
|
||
function $normalizeAllDirtyTextNodes(editorState, editor) {
|
||
const dirtyLeaves = editor._dirtyLeaves;
|
||
const nodeMap = editorState._nodeMap;
|
||
for (const nodeKey of dirtyLeaves) {
|
||
const node = nodeMap.get(nodeKey);
|
||
if ($isTextNode(node) && node.isAttached() && node.isSimpleText() && !node.isUnmergeable()) {
|
||
$normalizeTextNode(node);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Transform heuristic:
|
||
* 1. We transform leaves first. If transforms generate additional dirty nodes we repeat step 1.
|
||
* The reasoning behind this is that marking a leaf as dirty marks all its parent elements as dirty too.
|
||
* 2. We transform elements. If element transforms generate additional dirty nodes we repeat step 1.
|
||
* If element transforms only generate additional dirty elements we only repeat step 2.
|
||
*
|
||
* Note that to keep track of newly dirty nodes and subtrees we leverage the editor._dirtyNodes and
|
||
* editor._subtrees which we reset in every loop.
|
||
*/
|
||
function $applyAllTransforms(editorState, editor) {
|
||
const dirtyLeaves = editor._dirtyLeaves;
|
||
const dirtyElements = editor._dirtyElements;
|
||
const nodeMap = editorState._nodeMap;
|
||
const compositionKey = $getCompositionKey();
|
||
const transformsCache = new Map();
|
||
let untransformedDirtyLeaves = dirtyLeaves;
|
||
let untransformedDirtyLeavesLength = untransformedDirtyLeaves.size;
|
||
let untransformedDirtyElements = dirtyElements;
|
||
let untransformedDirtyElementsLength = untransformedDirtyElements.size;
|
||
while (untransformedDirtyLeavesLength > 0 || untransformedDirtyElementsLength > 0) {
|
||
if (untransformedDirtyLeavesLength > 0) {
|
||
// We leverage editor._dirtyLeaves to track the new dirty leaves after the transforms
|
||
editor._dirtyLeaves = new Set();
|
||
for (const nodeKey of untransformedDirtyLeaves) {
|
||
const node = nodeMap.get(nodeKey);
|
||
if ($isTextNode(node) && node.isAttached() && node.isSimpleText() && !node.isUnmergeable()) {
|
||
$normalizeTextNode(node);
|
||
}
|
||
if (node !== undefined && $isNodeValidForTransform(node, compositionKey)) {
|
||
$applyTransforms(editor, node, transformsCache);
|
||
}
|
||
dirtyLeaves.add(nodeKey);
|
||
}
|
||
untransformedDirtyLeaves = editor._dirtyLeaves;
|
||
untransformedDirtyLeavesLength = untransformedDirtyLeaves.size;
|
||
|
||
// We want to prioritize node transforms over element transforms
|
||
if (untransformedDirtyLeavesLength > 0) {
|
||
infiniteTransformCount++;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// All dirty leaves have been processed. Let's do elements!
|
||
// We have previously processed dirty leaves, so let's restart the editor leaves Set to track
|
||
// new ones caused by element transforms
|
||
editor._dirtyLeaves = new Set();
|
||
editor._dirtyElements = new Map();
|
||
for (const currentUntransformedDirtyElement of untransformedDirtyElements) {
|
||
const nodeKey = currentUntransformedDirtyElement[0];
|
||
const intentionallyMarkedAsDirty = currentUntransformedDirtyElement[1];
|
||
if (nodeKey !== 'root' && !intentionallyMarkedAsDirty) {
|
||
continue;
|
||
}
|
||
const node = nodeMap.get(nodeKey);
|
||
if (node !== undefined && $isNodeValidForTransform(node, compositionKey)) {
|
||
$applyTransforms(editor, node, transformsCache);
|
||
}
|
||
dirtyElements.set(nodeKey, intentionallyMarkedAsDirty);
|
||
}
|
||
untransformedDirtyLeaves = editor._dirtyLeaves;
|
||
untransformedDirtyLeavesLength = untransformedDirtyLeaves.size;
|
||
untransformedDirtyElements = editor._dirtyElements;
|
||
untransformedDirtyElementsLength = untransformedDirtyElements.size;
|
||
infiniteTransformCount++;
|
||
}
|
||
editor._dirtyLeaves = dirtyLeaves;
|
||
editor._dirtyElements = dirtyElements;
|
||
}
|
||
function $parseSerializedNode(serializedNode) {
|
||
const internalSerializedNode = serializedNode;
|
||
return $parseSerializedNodeImpl(internalSerializedNode, getActiveEditor()._nodes);
|
||
}
|
||
function $parseSerializedNodeImpl(serializedNode, registeredNodes) {
|
||
const type = serializedNode.type;
|
||
const registeredNode = registeredNodes.get(type);
|
||
if (registeredNode === undefined) {
|
||
{
|
||
throw Error(`parseEditorState: type "${type}" + not found`);
|
||
}
|
||
}
|
||
const nodeClass = registeredNode.klass;
|
||
if (serializedNode.type !== nodeClass.getType()) {
|
||
{
|
||
throw Error(`LexicalNode: Node ${nodeClass.name} does not implement .importJSON().`);
|
||
}
|
||
}
|
||
const node = nodeClass.importJSON(serializedNode);
|
||
const children = serializedNode.children;
|
||
if ($isElementNode(node) && Array.isArray(children)) {
|
||
for (let i = 0; i < children.length; i++) {
|
||
const serializedJSONChildNode = children[i];
|
||
const childNode = $parseSerializedNodeImpl(serializedJSONChildNode, registeredNodes);
|
||
node.append(childNode);
|
||
}
|
||
}
|
||
return node;
|
||
}
|
||
function parseEditorState(serializedEditorState, editor, updateFn) {
|
||
const editorState = createEmptyEditorState();
|
||
const previousActiveEditorState = activeEditorState;
|
||
const previousReadOnlyMode = isReadOnlyMode;
|
||
const previousActiveEditor = activeEditor;
|
||
const previousDirtyElements = editor._dirtyElements;
|
||
const previousDirtyLeaves = editor._dirtyLeaves;
|
||
const previousCloneNotNeeded = editor._cloneNotNeeded;
|
||
const previousDirtyType = editor._dirtyType;
|
||
editor._dirtyElements = new Map();
|
||
editor._dirtyLeaves = new Set();
|
||
editor._cloneNotNeeded = new Set();
|
||
editor._dirtyType = 0;
|
||
activeEditorState = editorState;
|
||
isReadOnlyMode = false;
|
||
activeEditor = editor;
|
||
try {
|
||
const registeredNodes = editor._nodes;
|
||
const serializedNode = serializedEditorState.root;
|
||
$parseSerializedNodeImpl(serializedNode, registeredNodes);
|
||
if (updateFn) {
|
||
updateFn();
|
||
}
|
||
|
||
// Make the editorState immutable
|
||
editorState._readOnly = true;
|
||
{
|
||
handleDEVOnlyPendingUpdateGuarantees(editorState);
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof Error) {
|
||
editor._onError(error);
|
||
}
|
||
} finally {
|
||
editor._dirtyElements = previousDirtyElements;
|
||
editor._dirtyLeaves = previousDirtyLeaves;
|
||
editor._cloneNotNeeded = previousCloneNotNeeded;
|
||
editor._dirtyType = previousDirtyType;
|
||
activeEditorState = previousActiveEditorState;
|
||
isReadOnlyMode = previousReadOnlyMode;
|
||
activeEditor = previousActiveEditor;
|
||
}
|
||
return editorState;
|
||
}
|
||
|
||
// This technically isn't an update but given we need
|
||
// exposure to the module's active bindings, we have this
|
||
// function here
|
||
|
||
function readEditorState(editorState, callbackFn) {
|
||
const previousActiveEditorState = activeEditorState;
|
||
const previousReadOnlyMode = isReadOnlyMode;
|
||
const previousActiveEditor = activeEditor;
|
||
activeEditorState = editorState;
|
||
isReadOnlyMode = true;
|
||
activeEditor = null;
|
||
try {
|
||
return callbackFn();
|
||
} finally {
|
||
activeEditorState = previousActiveEditorState;
|
||
isReadOnlyMode = previousReadOnlyMode;
|
||
activeEditor = previousActiveEditor;
|
||
}
|
||
}
|
||
function handleDEVOnlyPendingUpdateGuarantees(pendingEditorState) {
|
||
// Given we can't Object.freeze the nodeMap as it's a Map,
|
||
// we instead replace its set, clear and delete methods.
|
||
const nodeMap = pendingEditorState._nodeMap;
|
||
nodeMap.set = () => {
|
||
throw new Error('Cannot call set() on a frozen Lexical node map');
|
||
};
|
||
nodeMap.clear = () => {
|
||
throw new Error('Cannot call clear() on a frozen Lexical node map');
|
||
};
|
||
nodeMap.delete = () => {
|
||
throw new Error('Cannot call delete() on a frozen Lexical node map');
|
||
};
|
||
}
|
||
function commitPendingUpdates(editor, recoveryEditorState) {
|
||
const pendingEditorState = editor._pendingEditorState;
|
||
const rootElement = editor._rootElement;
|
||
const shouldSkipDOM = editor._headless || rootElement === null;
|
||
if (pendingEditorState === null) {
|
||
return;
|
||
}
|
||
|
||
// ======
|
||
// Reconciliation has started.
|
||
// ======
|
||
|
||
const currentEditorState = editor._editorState;
|
||
const currentSelection = currentEditorState._selection;
|
||
const pendingSelection = pendingEditorState._selection;
|
||
const needsUpdate = editor._dirtyType !== NO_DIRTY_NODES;
|
||
const previousActiveEditorState = activeEditorState;
|
||
const previousReadOnlyMode = isReadOnlyMode;
|
||
const previousActiveEditor = activeEditor;
|
||
const previouslyUpdating = editor._updating;
|
||
const observer = editor._observer;
|
||
let mutatedNodes = null;
|
||
editor._pendingEditorState = null;
|
||
editor._editorState = pendingEditorState;
|
||
if (!shouldSkipDOM && needsUpdate && observer !== null) {
|
||
activeEditor = editor;
|
||
activeEditorState = pendingEditorState;
|
||
isReadOnlyMode = false;
|
||
// We don't want updates to sync block the reconciliation.
|
||
editor._updating = true;
|
||
try {
|
||
const dirtyType = editor._dirtyType;
|
||
const dirtyElements = editor._dirtyElements;
|
||
const dirtyLeaves = editor._dirtyLeaves;
|
||
observer.disconnect();
|
||
mutatedNodes = reconcileRoot(currentEditorState, pendingEditorState, editor, dirtyType, dirtyElements, dirtyLeaves);
|
||
} catch (error) {
|
||
// Report errors
|
||
if (error instanceof Error) {
|
||
editor._onError(error);
|
||
}
|
||
|
||
// Reset editor and restore incoming editor state to the DOM
|
||
if (!isAttemptingToRecoverFromReconcilerError) {
|
||
resetEditor(editor, null, rootElement, pendingEditorState);
|
||
initMutationObserver(editor);
|
||
editor._dirtyType = FULL_RECONCILE;
|
||
isAttemptingToRecoverFromReconcilerError = true;
|
||
commitPendingUpdates(editor, currentEditorState);
|
||
isAttemptingToRecoverFromReconcilerError = false;
|
||
} else {
|
||
// To avoid a possible situation of infinite loops, lets throw
|
||
throw error;
|
||
}
|
||
return;
|
||
} finally {
|
||
observer.observe(rootElement, observerOptions);
|
||
editor._updating = previouslyUpdating;
|
||
activeEditorState = previousActiveEditorState;
|
||
isReadOnlyMode = previousReadOnlyMode;
|
||
activeEditor = previousActiveEditor;
|
||
}
|
||
}
|
||
if (!pendingEditorState._readOnly) {
|
||
pendingEditorState._readOnly = true;
|
||
{
|
||
handleDEVOnlyPendingUpdateGuarantees(pendingEditorState);
|
||
if ($isRangeSelection(pendingSelection)) {
|
||
Object.freeze(pendingSelection.anchor);
|
||
Object.freeze(pendingSelection.focus);
|
||
}
|
||
Object.freeze(pendingSelection);
|
||
}
|
||
}
|
||
const dirtyLeaves = editor._dirtyLeaves;
|
||
const dirtyElements = editor._dirtyElements;
|
||
const normalizedNodes = editor._normalizedNodes;
|
||
const tags = editor._updateTags;
|
||
const deferred = editor._deferred;
|
||
if (needsUpdate) {
|
||
editor._dirtyType = NO_DIRTY_NODES;
|
||
editor._cloneNotNeeded.clear();
|
||
editor._dirtyLeaves = new Set();
|
||
editor._dirtyElements = new Map();
|
||
editor._normalizedNodes = new Set();
|
||
editor._updateTags = new Set();
|
||
}
|
||
$garbageCollectDetachedDecorators(editor, pendingEditorState);
|
||
|
||
// ======
|
||
// Reconciliation has finished. Now update selection and trigger listeners.
|
||
// ======
|
||
|
||
const domSelection = shouldSkipDOM ? null : getDOMSelection(editor._window);
|
||
|
||
// Attempt to update the DOM selection, including focusing of the root element,
|
||
// and scroll into view if needed.
|
||
if (editor._editable &&
|
||
// domSelection will be null in headless
|
||
domSelection !== null && (needsUpdate || pendingSelection === null || pendingSelection.dirty)) {
|
||
activeEditor = editor;
|
||
activeEditorState = pendingEditorState;
|
||
try {
|
||
if (observer !== null) {
|
||
observer.disconnect();
|
||
}
|
||
if (needsUpdate || pendingSelection === null || pendingSelection.dirty) {
|
||
const blockCursorElement = editor._blockCursorElement;
|
||
if (blockCursorElement !== null) {
|
||
removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
|
||
}
|
||
updateDOMSelection(currentSelection, pendingSelection, editor, domSelection, tags, rootElement);
|
||
}
|
||
updateDOMBlockCursorElement(editor, rootElement, pendingSelection);
|
||
if (observer !== null) {
|
||
observer.observe(rootElement, observerOptions);
|
||
}
|
||
} finally {
|
||
activeEditor = previousActiveEditor;
|
||
activeEditorState = previousActiveEditorState;
|
||
}
|
||
}
|
||
if (mutatedNodes !== null) {
|
||
triggerMutationListeners(editor, mutatedNodes, tags, dirtyLeaves, currentEditorState);
|
||
}
|
||
if (!$isRangeSelection(pendingSelection) && pendingSelection !== null && (currentSelection === null || !currentSelection.is(pendingSelection))) {
|
||
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
|
||
}
|
||
/**
|
||
* Capture pendingDecorators after garbage collecting detached decorators
|
||
*/
|
||
const pendingDecorators = editor._pendingDecorators;
|
||
if (pendingDecorators !== null) {
|
||
editor._decorators = pendingDecorators;
|
||
editor._pendingDecorators = null;
|
||
triggerListeners('decorator', editor, true, pendingDecorators);
|
||
}
|
||
|
||
// If reconciler fails, we reset whole editor (so current editor state becomes empty)
|
||
// and attempt to re-render pendingEditorState. If that goes through we trigger
|
||
// listeners, but instead use recoverEditorState which is current editor state before reset
|
||
// This specifically important for collab that relies on prevEditorState from update
|
||
// listener to calculate delta of changed nodes/properties
|
||
triggerTextContentListeners(editor, recoveryEditorState || currentEditorState, pendingEditorState);
|
||
triggerListeners('update', editor, true, {
|
||
dirtyElements,
|
||
dirtyLeaves,
|
||
editorState: pendingEditorState,
|
||
normalizedNodes,
|
||
prevEditorState: recoveryEditorState || currentEditorState,
|
||
tags
|
||
});
|
||
triggerDeferredUpdateCallbacks(editor, deferred);
|
||
triggerEnqueuedUpdates(editor);
|
||
}
|
||
function triggerTextContentListeners(editor, currentEditorState, pendingEditorState) {
|
||
const currentTextContent = getEditorStateTextContent(currentEditorState);
|
||
const latestTextContent = getEditorStateTextContent(pendingEditorState);
|
||
if (currentTextContent !== latestTextContent) {
|
||
triggerListeners('textcontent', editor, true, latestTextContent);
|
||
}
|
||
}
|
||
function triggerMutationListeners(editor, mutatedNodes, updateTags, dirtyLeaves, prevEditorState) {
|
||
const listeners = Array.from(editor._listeners.mutation);
|
||
const listenersLength = listeners.length;
|
||
for (let i = 0; i < listenersLength; i++) {
|
||
const [listener, klass] = listeners[i];
|
||
const mutatedNodesByType = mutatedNodes.get(klass);
|
||
if (mutatedNodesByType !== undefined) {
|
||
listener(mutatedNodesByType, {
|
||
dirtyLeaves,
|
||
prevEditorState,
|
||
updateTags
|
||
});
|
||
}
|
||
}
|
||
}
|
||
function triggerListeners(type, editor, isCurrentlyEnqueuingUpdates, ...payload) {
|
||
const previouslyUpdating = editor._updating;
|
||
editor._updating = isCurrentlyEnqueuingUpdates;
|
||
try {
|
||
const listeners = Array.from(editor._listeners[type]);
|
||
for (let i = 0; i < listeners.length; i++) {
|
||
// @ts-ignore
|
||
listeners[i].apply(null, payload);
|
||
}
|
||
} finally {
|
||
editor._updating = previouslyUpdating;
|
||
}
|
||
}
|
||
function triggerCommandListeners(editor, type, payload) {
|
||
if (editor._updating === false || activeEditor !== editor) {
|
||
let returnVal = false;
|
||
editor.update(() => {
|
||
returnVal = triggerCommandListeners(editor, type, payload);
|
||
});
|
||
return returnVal;
|
||
}
|
||
const editors = getEditorsToPropagate(editor);
|
||
for (let i = 4; i >= 0; i--) {
|
||
for (let e = 0; e < editors.length; e++) {
|
||
const currentEditor = editors[e];
|
||
const commandListeners = currentEditor._commands;
|
||
const listenerInPriorityOrder = commandListeners.get(type);
|
||
if (listenerInPriorityOrder !== undefined) {
|
||
const listenersSet = listenerInPriorityOrder[i];
|
||
if (listenersSet !== undefined) {
|
||
const listeners = Array.from(listenersSet);
|
||
const listenersLength = listeners.length;
|
||
for (let j = 0; j < listenersLength; j++) {
|
||
if (listeners[j](payload, editor) === true) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
function triggerEnqueuedUpdates(editor) {
|
||
const queuedUpdates = editor._updates;
|
||
if (queuedUpdates.length !== 0) {
|
||
const queuedUpdate = queuedUpdates.shift();
|
||
if (queuedUpdate) {
|
||
const [updateFn, options] = queuedUpdate;
|
||
beginUpdate(editor, updateFn, options);
|
||
}
|
||
}
|
||
}
|
||
function triggerDeferredUpdateCallbacks(editor, deferred) {
|
||
editor._deferred = [];
|
||
if (deferred.length !== 0) {
|
||
const previouslyUpdating = editor._updating;
|
||
editor._updating = true;
|
||
try {
|
||
for (let i = 0; i < deferred.length; i++) {
|
||
deferred[i]();
|
||
}
|
||
} finally {
|
||
editor._updating = previouslyUpdating;
|
||
}
|
||
}
|
||
}
|
||
function processNestedUpdates(editor, initialSkipTransforms) {
|
||
const queuedUpdates = editor._updates;
|
||
let skipTransforms = initialSkipTransforms || false;
|
||
|
||
// Updates might grow as we process them, we so we'll need
|
||
// to handle each update as we go until the updates array is
|
||
// empty.
|
||
while (queuedUpdates.length !== 0) {
|
||
const queuedUpdate = queuedUpdates.shift();
|
||
if (queuedUpdate) {
|
||
const [nextUpdateFn, options] = queuedUpdate;
|
||
let onUpdate;
|
||
let tag;
|
||
if (options !== undefined) {
|
||
onUpdate = options.onUpdate;
|
||
tag = options.tag;
|
||
if (options.skipTransforms) {
|
||
skipTransforms = true;
|
||
}
|
||
if (onUpdate) {
|
||
editor._deferred.push(onUpdate);
|
||
}
|
||
if (tag) {
|
||
editor._updateTags.add(tag);
|
||
}
|
||
}
|
||
nextUpdateFn();
|
||
}
|
||
}
|
||
return skipTransforms;
|
||
}
|
||
function beginUpdate(editor, updateFn, options) {
|
||
const updateTags = editor._updateTags;
|
||
let onUpdate;
|
||
let tag;
|
||
let skipTransforms = false;
|
||
let discrete = false;
|
||
if (options !== undefined) {
|
||
onUpdate = options.onUpdate;
|
||
tag = options.tag;
|
||
if (tag != null) {
|
||
updateTags.add(tag);
|
||
}
|
||
skipTransforms = options.skipTransforms || false;
|
||
discrete = options.discrete || false;
|
||
}
|
||
if (onUpdate) {
|
||
editor._deferred.push(onUpdate);
|
||
}
|
||
const currentEditorState = editor._editorState;
|
||
let pendingEditorState = editor._pendingEditorState;
|
||
let editorStateWasCloned = false;
|
||
if (pendingEditorState === null || pendingEditorState._readOnly) {
|
||
pendingEditorState = editor._pendingEditorState = cloneEditorState(pendingEditorState || currentEditorState);
|
||
editorStateWasCloned = true;
|
||
}
|
||
pendingEditorState._flushSync = discrete;
|
||
const previousActiveEditorState = activeEditorState;
|
||
const previousReadOnlyMode = isReadOnlyMode;
|
||
const previousActiveEditor = activeEditor;
|
||
const previouslyUpdating = editor._updating;
|
||
activeEditorState = pendingEditorState;
|
||
isReadOnlyMode = false;
|
||
editor._updating = true;
|
||
activeEditor = editor;
|
||
try {
|
||
if (editorStateWasCloned) {
|
||
if (editor._headless) {
|
||
if (currentEditorState._selection !== null) {
|
||
pendingEditorState._selection = currentEditorState._selection.clone();
|
||
}
|
||
} else {
|
||
pendingEditorState._selection = internalCreateSelection(editor);
|
||
}
|
||
}
|
||
const startingCompositionKey = editor._compositionKey;
|
||
updateFn();
|
||
skipTransforms = processNestedUpdates(editor, skipTransforms);
|
||
applySelectionTransforms(pendingEditorState, editor);
|
||
if (editor._dirtyType !== NO_DIRTY_NODES) {
|
||
if (skipTransforms) {
|
||
$normalizeAllDirtyTextNodes(pendingEditorState, editor);
|
||
} else {
|
||
$applyAllTransforms(pendingEditorState, editor);
|
||
}
|
||
processNestedUpdates(editor);
|
||
$garbageCollectDetachedNodes(currentEditorState, pendingEditorState, editor._dirtyLeaves, editor._dirtyElements);
|
||
}
|
||
const endingCompositionKey = editor._compositionKey;
|
||
if (startingCompositionKey !== endingCompositionKey) {
|
||
pendingEditorState._flushSync = true;
|
||
}
|
||
const pendingSelection = pendingEditorState._selection;
|
||
if ($isRangeSelection(pendingSelection)) {
|
||
const pendingNodeMap = pendingEditorState._nodeMap;
|
||
const anchorKey = pendingSelection.anchor.key;
|
||
const focusKey = pendingSelection.focus.key;
|
||
if (pendingNodeMap.get(anchorKey) === undefined || pendingNodeMap.get(focusKey) === undefined) {
|
||
{
|
||
throw Error(`updateEditor: selection has been lost because the previously selected nodes have been removed and selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node.`);
|
||
}
|
||
}
|
||
} else if ($isNodeSelection(pendingSelection)) {
|
||
// TODO: we should also validate node selection?
|
||
if (pendingSelection._nodes.size === 0) {
|
||
pendingEditorState._selection = null;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// Report errors
|
||
if (error instanceof Error) {
|
||
editor._onError(error);
|
||
}
|
||
|
||
// Restore existing editor state to the DOM
|
||
editor._pendingEditorState = currentEditorState;
|
||
editor._dirtyType = FULL_RECONCILE;
|
||
editor._cloneNotNeeded.clear();
|
||
editor._dirtyLeaves = new Set();
|
||
editor._dirtyElements.clear();
|
||
commitPendingUpdates(editor);
|
||
return;
|
||
} finally {
|
||
activeEditorState = previousActiveEditorState;
|
||
isReadOnlyMode = previousReadOnlyMode;
|
||
activeEditor = previousActiveEditor;
|
||
editor._updating = previouslyUpdating;
|
||
infiniteTransformCount = 0;
|
||
}
|
||
const shouldUpdate = editor._dirtyType !== NO_DIRTY_NODES || editorStateHasDirtySelection(pendingEditorState, editor);
|
||
if (shouldUpdate) {
|
||
if (pendingEditorState._flushSync) {
|
||
pendingEditorState._flushSync = false;
|
||
commitPendingUpdates(editor);
|
||
} else if (editorStateWasCloned) {
|
||
scheduleMicroTask(() => {
|
||
commitPendingUpdates(editor);
|
||
});
|
||
}
|
||
} else {
|
||
pendingEditorState._flushSync = false;
|
||
if (editorStateWasCloned) {
|
||
updateTags.clear();
|
||
editor._deferred = [];
|
||
editor._pendingEditorState = null;
|
||
}
|
||
}
|
||
}
|
||
function updateEditor(editor, updateFn, options) {
|
||
if (editor._updating) {
|
||
editor._updates.push([updateFn, options]);
|
||
} else {
|
||
beginUpdate(editor, updateFn, options);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
|
||
/** @noInheritDoc */
|
||
class DecoratorNode extends LexicalNode {
|
||
constructor(key) {
|
||
super(key);
|
||
}
|
||
|
||
/**
|
||
* The returned value is added to the LexicalEditor._decorators
|
||
*/
|
||
decorate(editor, config) {
|
||
{
|
||
throw Error(`decorate: base method not extended`);
|
||
}
|
||
}
|
||
isIsolated() {
|
||
return false;
|
||
}
|
||
isInline() {
|
||
return true;
|
||
}
|
||
isKeyboardSelectable() {
|
||
return true;
|
||
}
|
||
}
|
||
function $isDecoratorNode(node) {
|
||
return node instanceof DecoratorNode;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
/** @noInheritDoc */
|
||
class ElementNode extends LexicalNode {
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
constructor(key) {
|
||
super(key);
|
||
this.__first = null;
|
||
this.__last = null;
|
||
this.__size = 0;
|
||
this.__format = 0;
|
||
this.__indent = 0;
|
||
this.__dir = null;
|
||
}
|
||
getFormat() {
|
||
const self = this.getLatest();
|
||
return self.__format;
|
||
}
|
||
getFormatType() {
|
||
const format = this.getFormat();
|
||
return ELEMENT_FORMAT_TO_TYPE[format] || '';
|
||
}
|
||
getIndent() {
|
||
const self = this.getLatest();
|
||
return self.__indent;
|
||
}
|
||
getChildren() {
|
||
const children = [];
|
||
let child = this.getFirstChild();
|
||
while (child !== null) {
|
||
children.push(child);
|
||
child = child.getNextSibling();
|
||
}
|
||
return children;
|
||
}
|
||
getChildrenKeys() {
|
||
const children = [];
|
||
let child = this.getFirstChild();
|
||
while (child !== null) {
|
||
children.push(child.__key);
|
||
child = child.getNextSibling();
|
||
}
|
||
return children;
|
||
}
|
||
getChildrenSize() {
|
||
const self = this.getLatest();
|
||
return self.__size;
|
||
}
|
||
isEmpty() {
|
||
return this.getChildrenSize() === 0;
|
||
}
|
||
isDirty() {
|
||
const editor = getActiveEditor();
|
||
const dirtyElements = editor._dirtyElements;
|
||
return dirtyElements !== null && dirtyElements.has(this.__key);
|
||
}
|
||
isLastChild() {
|
||
const self = this.getLatest();
|
||
const parentLastChild = this.getParentOrThrow().getLastChild();
|
||
return parentLastChild !== null && parentLastChild.is(self);
|
||
}
|
||
getAllTextNodes() {
|
||
const textNodes = [];
|
||
let child = this.getFirstChild();
|
||
while (child !== null) {
|
||
if ($isTextNode(child)) {
|
||
textNodes.push(child);
|
||
}
|
||
if ($isElementNode(child)) {
|
||
const subChildrenNodes = child.getAllTextNodes();
|
||
textNodes.push(...subChildrenNodes);
|
||
}
|
||
child = child.getNextSibling();
|
||
}
|
||
return textNodes;
|
||
}
|
||
getFirstDescendant() {
|
||
let node = this.getFirstChild();
|
||
while (node !== null) {
|
||
if ($isElementNode(node)) {
|
||
const child = node.getFirstChild();
|
||
if (child !== null) {
|
||
node = child;
|
||
continue;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
return node;
|
||
}
|
||
getLastDescendant() {
|
||
let node = this.getLastChild();
|
||
while (node !== null) {
|
||
if ($isElementNode(node)) {
|
||
const child = node.getLastChild();
|
||
if (child !== null) {
|
||
node = child;
|
||
continue;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
return node;
|
||
}
|
||
getDescendantByIndex(index) {
|
||
const children = this.getChildren();
|
||
const childrenLength = children.length;
|
||
// For non-empty element nodes, we resolve its descendant
|
||
// (either a leaf node or the bottom-most element)
|
||
if (index >= childrenLength) {
|
||
const resolvedNode = children[childrenLength - 1];
|
||
return $isElementNode(resolvedNode) && resolvedNode.getLastDescendant() || resolvedNode || null;
|
||
}
|
||
const resolvedNode = children[index];
|
||
return $isElementNode(resolvedNode) && resolvedNode.getFirstDescendant() || resolvedNode || null;
|
||
}
|
||
getFirstChild() {
|
||
const self = this.getLatest();
|
||
const firstKey = self.__first;
|
||
return firstKey === null ? null : $getNodeByKey(firstKey);
|
||
}
|
||
getFirstChildOrThrow() {
|
||
const firstChild = this.getFirstChild();
|
||
if (firstChild === null) {
|
||
{
|
||
throw Error(`Expected node ${this.__key} to have a first child.`);
|
||
}
|
||
}
|
||
return firstChild;
|
||
}
|
||
getLastChild() {
|
||
const self = this.getLatest();
|
||
const lastKey = self.__last;
|
||
return lastKey === null ? null : $getNodeByKey(lastKey);
|
||
}
|
||
getLastChildOrThrow() {
|
||
const lastChild = this.getLastChild();
|
||
if (lastChild === null) {
|
||
{
|
||
throw Error(`Expected node ${this.__key} to have a last child.`);
|
||
}
|
||
}
|
||
return lastChild;
|
||
}
|
||
getChildAtIndex(index) {
|
||
const size = this.getChildrenSize();
|
||
let node;
|
||
let i;
|
||
if (index < size / 2) {
|
||
node = this.getFirstChild();
|
||
i = 0;
|
||
while (node !== null && i <= index) {
|
||
if (i === index) {
|
||
return node;
|
||
}
|
||
node = node.getNextSibling();
|
||
i++;
|
||
}
|
||
return null;
|
||
}
|
||
node = this.getLastChild();
|
||
i = size - 1;
|
||
while (node !== null && i >= index) {
|
||
if (i === index) {
|
||
return node;
|
||
}
|
||
node = node.getPreviousSibling();
|
||
i--;
|
||
}
|
||
return null;
|
||
}
|
||
getTextContent() {
|
||
let textContent = '';
|
||
const children = this.getChildren();
|
||
const childrenLength = children.length;
|
||
for (let i = 0; i < childrenLength; i++) {
|
||
const child = children[i];
|
||
textContent += child.getTextContent();
|
||
if ($isElementNode(child) && i !== childrenLength - 1 && !child.isInline()) {
|
||
textContent += DOUBLE_LINE_BREAK;
|
||
}
|
||
}
|
||
return textContent;
|
||
}
|
||
getTextContentSize() {
|
||
let textContentSize = 0;
|
||
const children = this.getChildren();
|
||
const childrenLength = children.length;
|
||
for (let i = 0; i < childrenLength; i++) {
|
||
const child = children[i];
|
||
textContentSize += child.getTextContentSize();
|
||
if ($isElementNode(child) && i !== childrenLength - 1 && !child.isInline()) {
|
||
textContentSize += DOUBLE_LINE_BREAK.length;
|
||
}
|
||
}
|
||
return textContentSize;
|
||
}
|
||
getDirection() {
|
||
const self = this.getLatest();
|
||
return self.__dir;
|
||
}
|
||
hasFormat(type) {
|
||
if (type !== '') {
|
||
const formatFlag = ELEMENT_TYPE_TO_FORMAT[type];
|
||
return (this.getFormat() & formatFlag) !== 0;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Mutators
|
||
|
||
select(_anchorOffset, _focusOffset) {
|
||
errorOnReadOnly();
|
||
const selection = $getSelection();
|
||
let anchorOffset = _anchorOffset;
|
||
let focusOffset = _focusOffset;
|
||
const childrenCount = this.getChildrenSize();
|
||
if (!this.canBeEmpty()) {
|
||
if (_anchorOffset === 0 && _focusOffset === 0) {
|
||
const firstChild = this.getFirstChild();
|
||
if ($isTextNode(firstChild) || $isElementNode(firstChild)) {
|
||
return firstChild.select(0, 0);
|
||
}
|
||
} else if ((_anchorOffset === undefined || _anchorOffset === childrenCount) && (_focusOffset === undefined || _focusOffset === childrenCount)) {
|
||
const lastChild = this.getLastChild();
|
||
if ($isTextNode(lastChild) || $isElementNode(lastChild)) {
|
||
return lastChild.select();
|
||
}
|
||
}
|
||
}
|
||
if (anchorOffset === undefined) {
|
||
anchorOffset = childrenCount;
|
||
}
|
||
if (focusOffset === undefined) {
|
||
focusOffset = childrenCount;
|
||
}
|
||
const key = this.__key;
|
||
if (!$isRangeSelection(selection)) {
|
||
return internalMakeRangeSelection(key, anchorOffset, key, focusOffset, 'element', 'element');
|
||
} else {
|
||
selection.anchor.set(key, anchorOffset, 'element');
|
||
selection.focus.set(key, focusOffset, 'element');
|
||
selection.dirty = true;
|
||
}
|
||
return selection;
|
||
}
|
||
selectStart() {
|
||
const firstNode = this.getFirstDescendant();
|
||
return firstNode ? firstNode.selectStart() : this.select();
|
||
}
|
||
selectEnd() {
|
||
const lastNode = this.getLastDescendant();
|
||
return lastNode ? lastNode.selectEnd() : this.select();
|
||
}
|
||
clear() {
|
||
const writableSelf = this.getWritable();
|
||
const children = this.getChildren();
|
||
children.forEach(child => child.remove());
|
||
return writableSelf;
|
||
}
|
||
append(...nodesToAppend) {
|
||
return this.splice(this.getChildrenSize(), 0, nodesToAppend);
|
||
}
|
||
setDirection(direction) {
|
||
const self = this.getWritable();
|
||
self.__dir = direction;
|
||
return self;
|
||
}
|
||
setFormat(type) {
|
||
const self = this.getWritable();
|
||
self.__format = type !== '' ? ELEMENT_TYPE_TO_FORMAT[type] : 0;
|
||
return this;
|
||
}
|
||
setIndent(indentLevel) {
|
||
const self = this.getWritable();
|
||
self.__indent = indentLevel;
|
||
return this;
|
||
}
|
||
splice(start, deleteCount, nodesToInsert) {
|
||
const nodesToInsertLength = nodesToInsert.length;
|
||
const oldSize = this.getChildrenSize();
|
||
const writableSelf = this.getWritable();
|
||
const writableSelfKey = writableSelf.__key;
|
||
const nodesToInsertKeys = [];
|
||
const nodesToRemoveKeys = [];
|
||
const nodeAfterRange = this.getChildAtIndex(start + deleteCount);
|
||
let nodeBeforeRange = null;
|
||
let newSize = oldSize - deleteCount + nodesToInsertLength;
|
||
if (start !== 0) {
|
||
if (start === oldSize) {
|
||
nodeBeforeRange = this.getLastChild();
|
||
} else {
|
||
const node = this.getChildAtIndex(start);
|
||
if (node !== null) {
|
||
nodeBeforeRange = node.getPreviousSibling();
|
||
}
|
||
}
|
||
}
|
||
if (deleteCount > 0) {
|
||
let nodeToDelete = nodeBeforeRange === null ? this.getFirstChild() : nodeBeforeRange.getNextSibling();
|
||
for (let i = 0; i < deleteCount; i++) {
|
||
if (nodeToDelete === null) {
|
||
{
|
||
throw Error(`splice: sibling not found`);
|
||
}
|
||
}
|
||
const nextSibling = nodeToDelete.getNextSibling();
|
||
const nodeKeyToDelete = nodeToDelete.__key;
|
||
const writableNodeToDelete = nodeToDelete.getWritable();
|
||
removeFromParent(writableNodeToDelete);
|
||
nodesToRemoveKeys.push(nodeKeyToDelete);
|
||
nodeToDelete = nextSibling;
|
||
}
|
||
}
|
||
let prevNode = nodeBeforeRange;
|
||
for (let i = 0; i < nodesToInsertLength; i++) {
|
||
const nodeToInsert = nodesToInsert[i];
|
||
if (prevNode !== null && nodeToInsert.is(prevNode)) {
|
||
nodeBeforeRange = prevNode = prevNode.getPreviousSibling();
|
||
}
|
||
const writableNodeToInsert = nodeToInsert.getWritable();
|
||
if (writableNodeToInsert.__parent === writableSelfKey) {
|
||
newSize--;
|
||
}
|
||
removeFromParent(writableNodeToInsert);
|
||
const nodeKeyToInsert = nodeToInsert.__key;
|
||
if (prevNode === null) {
|
||
writableSelf.__first = nodeKeyToInsert;
|
||
writableNodeToInsert.__prev = null;
|
||
} else {
|
||
const writablePrevNode = prevNode.getWritable();
|
||
writablePrevNode.__next = nodeKeyToInsert;
|
||
writableNodeToInsert.__prev = writablePrevNode.__key;
|
||
}
|
||
if (nodeToInsert.__key === writableSelfKey) {
|
||
{
|
||
throw Error(`append: attempting to append self`);
|
||
}
|
||
}
|
||
// Set child parent to self
|
||
writableNodeToInsert.__parent = writableSelfKey;
|
||
nodesToInsertKeys.push(nodeKeyToInsert);
|
||
prevNode = nodeToInsert;
|
||
}
|
||
if (start + deleteCount === oldSize) {
|
||
if (prevNode !== null) {
|
||
const writablePrevNode = prevNode.getWritable();
|
||
writablePrevNode.__next = null;
|
||
writableSelf.__last = prevNode.__key;
|
||
}
|
||
} else if (nodeAfterRange !== null) {
|
||
const writableNodeAfterRange = nodeAfterRange.getWritable();
|
||
if (prevNode !== null) {
|
||
const writablePrevNode = prevNode.getWritable();
|
||
writableNodeAfterRange.__prev = prevNode.__key;
|
||
writablePrevNode.__next = nodeAfterRange.__key;
|
||
} else {
|
||
writableNodeAfterRange.__prev = null;
|
||
}
|
||
}
|
||
writableSelf.__size = newSize;
|
||
|
||
// In case of deletion we need to adjust selection, unlink removed nodes
|
||
// and clean up node itself if it becomes empty. None of these needed
|
||
// for insertion-only cases
|
||
if (nodesToRemoveKeys.length) {
|
||
// Adjusting selection, in case node that was anchor/focus will be deleted
|
||
const selection = $getSelection();
|
||
if ($isRangeSelection(selection)) {
|
||
const nodesToRemoveKeySet = new Set(nodesToRemoveKeys);
|
||
const nodesToInsertKeySet = new Set(nodesToInsertKeys);
|
||
const {
|
||
anchor,
|
||
focus
|
||
} = selection;
|
||
if (isPointRemoved(anchor, nodesToRemoveKeySet, nodesToInsertKeySet)) {
|
||
moveSelectionPointToSibling(anchor, anchor.getNode(), this, nodeBeforeRange, nodeAfterRange);
|
||
}
|
||
if (isPointRemoved(focus, nodesToRemoveKeySet, nodesToInsertKeySet)) {
|
||
moveSelectionPointToSibling(focus, focus.getNode(), this, nodeBeforeRange, nodeAfterRange);
|
||
}
|
||
// Cleanup if node can't be empty
|
||
if (newSize === 0 && !this.canBeEmpty() && !$isRootOrShadowRoot(this)) {
|
||
this.remove();
|
||
}
|
||
}
|
||
}
|
||
return writableSelf;
|
||
}
|
||
// JSON serialization
|
||
exportJSON() {
|
||
return {
|
||
children: [],
|
||
direction: this.getDirection(),
|
||
format: this.getFormatType(),
|
||
indent: this.getIndent(),
|
||
type: 'element',
|
||
version: 1
|
||
};
|
||
}
|
||
// These are intended to be extends for specific element heuristics.
|
||
insertNewAfter(selection, restoreSelection) {
|
||
return null;
|
||
}
|
||
canIndent() {
|
||
return true;
|
||
}
|
||
/*
|
||
* This method controls the behavior of a the node during backwards
|
||
* deletion (i.e., backspace) when selection is at the beginning of
|
||
* the node (offset 0)
|
||
*/
|
||
collapseAtStart(selection) {
|
||
return false;
|
||
}
|
||
excludeFromCopy(destination) {
|
||
return false;
|
||
}
|
||
// TODO 0.10 deprecate
|
||
canExtractContents() {
|
||
return true;
|
||
}
|
||
canReplaceWith(replacement) {
|
||
return true;
|
||
}
|
||
canInsertAfter(node) {
|
||
return true;
|
||
}
|
||
canBeEmpty() {
|
||
return true;
|
||
}
|
||
canInsertTextBefore() {
|
||
return true;
|
||
}
|
||
canInsertTextAfter() {
|
||
return true;
|
||
}
|
||
isInline() {
|
||
return false;
|
||
}
|
||
// A shadow root is a Node that behaves like RootNode. The shadow root (and RootNode) mark the
|
||
// end of the hiercharchy, most implementations should treat it as there's nothing (upwards)
|
||
// beyond this point. For example, node.getTopLevelElement(), when performed inside a TableCellNode
|
||
// will return the immediate first child underneath TableCellNode instead of RootNode.
|
||
isShadowRoot() {
|
||
return false;
|
||
}
|
||
canMergeWith(node) {
|
||
return false;
|
||
}
|
||
extractWithChild(child, selection, destination) {
|
||
return false;
|
||
}
|
||
}
|
||
function $isElementNode(node) {
|
||
return node instanceof ElementNode;
|
||
}
|
||
function isPointRemoved(point, nodesToRemoveKeySet, nodesToInsertKeySet) {
|
||
let node = point.getNode();
|
||
while (node) {
|
||
const nodeKey = node.__key;
|
||
if (nodesToRemoveKeySet.has(nodeKey) && !nodesToInsertKeySet.has(nodeKey)) {
|
||
return true;
|
||
}
|
||
node = node.getParent();
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
/** @noInheritDoc */
|
||
class RootNode extends ElementNode {
|
||
/** @internal */
|
||
|
||
static getType() {
|
||
return 'root';
|
||
}
|
||
static clone() {
|
||
return new RootNode();
|
||
}
|
||
constructor() {
|
||
super('root');
|
||
this.__cachedText = null;
|
||
}
|
||
getTopLevelElementOrThrow() {
|
||
{
|
||
throw Error(`getTopLevelElementOrThrow: root nodes are not top level elements`);
|
||
}
|
||
}
|
||
getTextContent() {
|
||
const cachedText = this.__cachedText;
|
||
if (isCurrentlyReadOnlyMode() || getActiveEditor()._dirtyType === NO_DIRTY_NODES) {
|
||
if (cachedText !== null) {
|
||
return cachedText;
|
||
}
|
||
}
|
||
return super.getTextContent();
|
||
}
|
||
remove() {
|
||
{
|
||
throw Error(`remove: cannot be called on root nodes`);
|
||
}
|
||
}
|
||
replace(node) {
|
||
{
|
||
throw Error(`replace: cannot be called on root nodes`);
|
||
}
|
||
}
|
||
insertBefore(nodeToInsert) {
|
||
{
|
||
throw Error(`insertBefore: cannot be called on root nodes`);
|
||
}
|
||
}
|
||
insertAfter(nodeToInsert) {
|
||
{
|
||
throw Error(`insertAfter: cannot be called on root nodes`);
|
||
}
|
||
}
|
||
|
||
// View
|
||
|
||
updateDOM(prevNode, dom) {
|
||
return false;
|
||
}
|
||
|
||
// Mutate
|
||
|
||
append(...nodesToAppend) {
|
||
for (let i = 0; i < nodesToAppend.length; i++) {
|
||
const node = nodesToAppend[i];
|
||
if (!$isElementNode(node) && !$isDecoratorNode(node)) {
|
||
{
|
||
throw Error(`rootNode.append: Only element or decorator nodes can be appended to the root node`);
|
||
}
|
||
}
|
||
}
|
||
return super.append(...nodesToAppend);
|
||
}
|
||
static importJSON(serializedNode) {
|
||
// We don't create a root, and instead use the existing root.
|
||
const node = $getRoot();
|
||
node.setFormat(serializedNode.format);
|
||
node.setIndent(serializedNode.indent);
|
||
node.setDirection(serializedNode.direction);
|
||
return node;
|
||
}
|
||
exportJSON() {
|
||
return {
|
||
children: [],
|
||
direction: this.getDirection(),
|
||
format: this.getFormatType(),
|
||
indent: this.getIndent(),
|
||
type: 'root',
|
||
version: 1
|
||
};
|
||
}
|
||
collapseAtStart() {
|
||
return true;
|
||
}
|
||
}
|
||
function $createRootNode() {
|
||
return new RootNode();
|
||
}
|
||
function $isRootNode(node) {
|
||
return node instanceof RootNode;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
function editorStateHasDirtySelection(editorState, editor) {
|
||
const currentSelection = editor.getEditorState()._selection;
|
||
const pendingSelection = editorState._selection;
|
||
|
||
// Check if we need to update because of changes in selection
|
||
if (pendingSelection !== null) {
|
||
if (pendingSelection.dirty || !pendingSelection.is(currentSelection)) {
|
||
return true;
|
||
}
|
||
} else if (currentSelection !== null) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
function cloneEditorState(current) {
|
||
return new EditorState(new Map(current._nodeMap));
|
||
}
|
||
function createEmptyEditorState() {
|
||
return new EditorState(new Map([['root', $createRootNode()]]));
|
||
}
|
||
function exportNodeToJSON(node) {
|
||
const serializedNode = node.exportJSON();
|
||
const nodeClass = node.constructor;
|
||
if (serializedNode.type !== nodeClass.getType()) {
|
||
{
|
||
throw Error(`LexicalNode: Node ${nodeClass.name} does not match the serialized type. Check if .exportJSON() is implemented and it is returning the correct type.`);
|
||
}
|
||
}
|
||
if ($isElementNode(node)) {
|
||
const serializedChildren = serializedNode.children;
|
||
if (!Array.isArray(serializedChildren)) {
|
||
{
|
||
throw Error(`LexicalNode: Node ${nodeClass.name} is an element but .exportJSON() does not have a children array.`);
|
||
}
|
||
}
|
||
const children = node.getChildren();
|
||
for (let i = 0; i < children.length; i++) {
|
||
const child = children[i];
|
||
const serializedChildNode = exportNodeToJSON(child);
|
||
serializedChildren.push(serializedChildNode);
|
||
}
|
||
}
|
||
|
||
// @ts-expect-error
|
||
return serializedNode;
|
||
}
|
||
class EditorState {
|
||
constructor(nodeMap, selection) {
|
||
this._nodeMap = nodeMap;
|
||
this._selection = selection || null;
|
||
this._flushSync = false;
|
||
this._readOnly = false;
|
||
}
|
||
isEmpty() {
|
||
return this._nodeMap.size === 1 && this._selection === null;
|
||
}
|
||
read(callbackFn) {
|
||
return readEditorState(this, callbackFn);
|
||
}
|
||
clone(selection) {
|
||
const editorState = new EditorState(this._nodeMap, selection === undefined ? this._selection : selection);
|
||
editorState._readOnly = true;
|
||
return editorState;
|
||
}
|
||
toJSON() {
|
||
return readEditorState(this, () => ({
|
||
root: exportNodeToJSON($getRoot())
|
||
}));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
/** @noInheritDoc */
|
||
class ParagraphNode extends ElementNode {
|
||
static getType() {
|
||
return 'paragraph';
|
||
}
|
||
static clone(node) {
|
||
return new ParagraphNode(node.__key);
|
||
}
|
||
|
||
// View
|
||
|
||
createDOM(config) {
|
||
const dom = document.createElement('p');
|
||
const classNames = getCachedClassNameArray(config.theme, 'paragraph');
|
||
if (classNames !== undefined) {
|
||
const domClassList = dom.classList;
|
||
domClassList.add(...classNames);
|
||
}
|
||
return dom;
|
||
}
|
||
updateDOM(prevNode, dom, config) {
|
||
return false;
|
||
}
|
||
static importDOM() {
|
||
return {
|
||
p: node => ({
|
||
conversion: convertParagraphElement,
|
||
priority: 0
|
||
})
|
||
};
|
||
}
|
||
exportDOM(editor) {
|
||
const {
|
||
element
|
||
} = super.exportDOM(editor);
|
||
if (element && isHTMLElement(element)) {
|
||
if (this.isEmpty()) element.append(document.createElement('br'));
|
||
const formatType = this.getFormatType();
|
||
element.style.textAlign = formatType;
|
||
const direction = this.getDirection();
|
||
if (direction) {
|
||
element.dir = direction;
|
||
}
|
||
const indent = this.getIndent();
|
||
if (indent > 0) {
|
||
// padding-inline-start is not widely supported in email HTML, but
|
||
// Lexical Reconciler uses padding-inline-start. Using text-indent instead.
|
||
element.style.textIndent = `${indent * 20}px`;
|
||
}
|
||
}
|
||
return {
|
||
element
|
||
};
|
||
}
|
||
static importJSON(serializedNode) {
|
||
const node = $createParagraphNode();
|
||
node.setFormat(serializedNode.format);
|
||
node.setIndent(serializedNode.indent);
|
||
node.setDirection(serializedNode.direction);
|
||
return node;
|
||
}
|
||
exportJSON() {
|
||
return {
|
||
...super.exportJSON(),
|
||
type: 'paragraph',
|
||
version: 1
|
||
};
|
||
}
|
||
|
||
// Mutation
|
||
|
||
insertNewAfter(_, restoreSelection) {
|
||
const newElement = $createParagraphNode();
|
||
const direction = this.getDirection();
|
||
newElement.setDirection(direction);
|
||
this.insertAfter(newElement, restoreSelection);
|
||
return newElement;
|
||
}
|
||
collapseAtStart() {
|
||
const children = this.getChildren();
|
||
// If we have an empty (trimmed) first paragraph and try and remove it,
|
||
// delete the paragraph as long as we have another sibling to go to
|
||
if (children.length === 0 || $isTextNode(children[0]) && children[0].getTextContent().trim() === '') {
|
||
const nextSibling = this.getNextSibling();
|
||
if (nextSibling !== null) {
|
||
this.selectNext();
|
||
this.remove();
|
||
return true;
|
||
}
|
||
const prevSibling = this.getPreviousSibling();
|
||
if (prevSibling !== null) {
|
||
this.selectPrevious();
|
||
this.remove();
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
function convertParagraphElement(element) {
|
||
const node = $createParagraphNode();
|
||
if (element.style) {
|
||
node.setFormat(element.style.textAlign);
|
||
const indent = parseInt(element.style.textIndent, 10) / 20;
|
||
if (indent > 0) {
|
||
node.setIndent(indent);
|
||
}
|
||
}
|
||
return {
|
||
node
|
||
};
|
||
}
|
||
function $createParagraphNode() {
|
||
return $applyNodeReplacement(new ParagraphNode());
|
||
}
|
||
function $isParagraphNode(node) {
|
||
return node instanceof ParagraphNode;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
*/
|
||
|
||
// https://github.com/microsoft/TypeScript/issues/3841
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const COMMAND_PRIORITY_EDITOR = 0;
|
||
const COMMAND_PRIORITY_LOW = 1;
|
||
const COMMAND_PRIORITY_NORMAL = 2;
|
||
const COMMAND_PRIORITY_HIGH = 3;
|
||
const COMMAND_PRIORITY_CRITICAL = 4;
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
|
||
/**
|
||
* Type helper for extracting the payload type from a command.
|
||
*
|
||
* @example
|
||
* ```ts
|
||
* const MY_COMMAND = createCommand<SomeType>();
|
||
*
|
||
* // ...
|
||
*
|
||
* editor.registerCommand(MY_COMMAND, payload => {
|
||
* // Type of `payload` is inferred here. But lets say we want to extract a function to delegate to
|
||
* handleMyCommand(editor, payload);
|
||
* return true;
|
||
* });
|
||
*
|
||
* function handleMyCommand(editor: LexicalEditor, payload: CommandPayloadType<typeof MY_COMMAND>) {
|
||
* // `payload` is of type `SomeType`, extracted from the command.
|
||
* }
|
||
* ```
|
||
*/
|
||
|
||
function resetEditor(editor, prevRootElement, nextRootElement, pendingEditorState) {
|
||
const keyNodeMap = editor._keyToDOMMap;
|
||
keyNodeMap.clear();
|
||
editor._editorState = createEmptyEditorState();
|
||
editor._pendingEditorState = pendingEditorState;
|
||
editor._compositionKey = null;
|
||
editor._dirtyType = NO_DIRTY_NODES;
|
||
editor._cloneNotNeeded.clear();
|
||
editor._dirtyLeaves = new Set();
|
||
editor._dirtyElements.clear();
|
||
editor._normalizedNodes = new Set();
|
||
editor._updateTags = new Set();
|
||
editor._updates = [];
|
||
editor._blockCursorElement = null;
|
||
const observer = editor._observer;
|
||
if (observer !== null) {
|
||
observer.disconnect();
|
||
editor._observer = null;
|
||
}
|
||
|
||
// Remove all the DOM nodes from the root element
|
||
if (prevRootElement !== null) {
|
||
prevRootElement.textContent = '';
|
||
}
|
||
if (nextRootElement !== null) {
|
||
nextRootElement.textContent = '';
|
||
keyNodeMap.set('root', nextRootElement);
|
||
}
|
||
}
|
||
function initializeConversionCache(nodes, additionalConversions) {
|
||
const conversionCache = new Map();
|
||
const handledConversions = new Set();
|
||
const addConversionsToCache = map => {
|
||
Object.keys(map).forEach(key => {
|
||
let currentCache = conversionCache.get(key);
|
||
if (currentCache === undefined) {
|
||
currentCache = [];
|
||
conversionCache.set(key, currentCache);
|
||
}
|
||
currentCache.push(map[key]);
|
||
});
|
||
};
|
||
nodes.forEach(node => {
|
||
const importDOM = node.klass.importDOM != null ? node.klass.importDOM.bind(node.klass) : null;
|
||
if (importDOM == null || handledConversions.has(importDOM)) {
|
||
return;
|
||
}
|
||
handledConversions.add(importDOM);
|
||
const map = importDOM();
|
||
if (map !== null) {
|
||
addConversionsToCache(map);
|
||
}
|
||
});
|
||
if (additionalConversions) {
|
||
addConversionsToCache(additionalConversions);
|
||
}
|
||
return conversionCache;
|
||
}
|
||
|
||
/**
|
||
* Creates a new LexicalEditor attached to a single contentEditable (provided in the config). This is
|
||
* the lowest-level initialization API for a LexicalEditor. If you're using React or another framework,
|
||
* consider using the appropriate abstractions, such as LexicalComposer
|
||
* @param editorConfig - the editor configuration.
|
||
* @returns a LexicalEditor instance
|
||
*/
|
||
function createEditor(editorConfig) {
|
||
const config = editorConfig || {};
|
||
const activeEditor = internalGetActiveEditor();
|
||
const theme = config.theme || {};
|
||
const parentEditor = editorConfig === undefined ? activeEditor : config.parentEditor || null;
|
||
const disableEvents = config.disableEvents || false;
|
||
const editorState = createEmptyEditorState();
|
||
const namespace = config.namespace || (parentEditor !== null ? parentEditor._config.namespace : createUID());
|
||
const initialEditorState = config.editorState;
|
||
const nodes = [RootNode, TextNode, LineBreakNode, TabNode, ParagraphNode, ...(config.nodes || [])];
|
||
const {
|
||
onError,
|
||
html
|
||
} = config;
|
||
const isEditable = config.editable !== undefined ? config.editable : true;
|
||
let registeredNodes;
|
||
if (editorConfig === undefined && activeEditor !== null) {
|
||
registeredNodes = activeEditor._nodes;
|
||
} else {
|
||
registeredNodes = new Map();
|
||
for (let i = 0; i < nodes.length; i++) {
|
||
let klass = nodes[i];
|
||
let replace = null;
|
||
let replaceWithKlass = null;
|
||
if (typeof klass !== 'function') {
|
||
const options = klass;
|
||
klass = options.replace;
|
||
replace = options.with;
|
||
replaceWithKlass = options.withKlass || null;
|
||
}
|
||
// Ensure custom nodes implement required methods.
|
||
{
|
||
const name = klass.name;
|
||
if (name !== 'RootNode') {
|
||
const proto = klass.prototype;
|
||
['getType', 'clone'].forEach(method => {
|
||
// eslint-disable-next-line no-prototype-builtins
|
||
if (!klass.hasOwnProperty(method)) {
|
||
console.warn(`${name} must implement static "${method}" method`);
|
||
}
|
||
});
|
||
if (
|
||
// eslint-disable-next-line no-prototype-builtins
|
||
!klass.hasOwnProperty('importDOM') &&
|
||
// eslint-disable-next-line no-prototype-builtins
|
||
klass.hasOwnProperty('exportDOM')) {
|
||
console.warn(`${name} should implement "importDOM" if using a custom "exportDOM" method to ensure HTML serialization (important for copy & paste) works as expected`);
|
||
}
|
||
if (proto instanceof DecoratorNode) {
|
||
// eslint-disable-next-line no-prototype-builtins
|
||
if (!proto.hasOwnProperty('decorate')) {
|
||
console.warn(`${proto.constructor.name} must implement "decorate" method`);
|
||
}
|
||
}
|
||
if (
|
||
// eslint-disable-next-line no-prototype-builtins
|
||
!klass.hasOwnProperty('importJSON')) {
|
||
console.warn(`${name} should implement "importJSON" method to ensure JSON and default HTML serialization works as expected`);
|
||
}
|
||
if (
|
||
// eslint-disable-next-line no-prototype-builtins
|
||
!proto.hasOwnProperty('exportJSON')) {
|
||
console.warn(`${name} should implement "exportJSON" method to ensure JSON and default HTML serialization works as expected`);
|
||
}
|
||
}
|
||
}
|
||
const type = klass.getType();
|
||
const transform = klass.transform();
|
||
const transforms = new Set();
|
||
if (transform !== null) {
|
||
transforms.add(transform);
|
||
}
|
||
registeredNodes.set(type, {
|
||
exportDOM: html && html.export ? html.export.get(klass) : undefined,
|
||
klass,
|
||
replace,
|
||
replaceWithKlass,
|
||
transforms
|
||
});
|
||
}
|
||
}
|
||
const editor = new LexicalEditor(editorState, parentEditor, registeredNodes, {
|
||
disableEvents,
|
||
namespace,
|
||
theme
|
||
}, onError ? onError : console.error, initializeConversionCache(registeredNodes, html ? html.import : undefined), isEditable);
|
||
if (initialEditorState !== undefined) {
|
||
editor._pendingEditorState = initialEditorState;
|
||
editor._dirtyType = FULL_RECONCILE;
|
||
}
|
||
return editor;
|
||
}
|
||
class LexicalEditor {
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
|
||
/** @internal */
|
||
constructor(editorState, parentEditor, nodes, config, onError, htmlConversions, editable) {
|
||
this._parentEditor = parentEditor;
|
||
// The root element associated with this editor
|
||
this._rootElement = null;
|
||
// The current editor state
|
||
this._editorState = editorState;
|
||
// Handling of drafts and updates
|
||
this._pendingEditorState = null;
|
||
// Used to help co-ordinate selection and events
|
||
this._compositionKey = null;
|
||
this._deferred = [];
|
||
// Used during reconciliation
|
||
this._keyToDOMMap = new Map();
|
||
this._updates = [];
|
||
this._updating = false;
|
||
// Listeners
|
||
this._listeners = {
|
||
decorator: new Set(),
|
||
editable: new Set(),
|
||
mutation: new Map(),
|
||
root: new Set(),
|
||
textcontent: new Set(),
|
||
update: new Set()
|
||
};
|
||
// Commands
|
||
this._commands = new Map();
|
||
// Editor configuration for theme/context.
|
||
this._config = config;
|
||
// Mapping of types to their nodes
|
||
this._nodes = nodes;
|
||
// React node decorators for portals
|
||
this._decorators = {};
|
||
this._pendingDecorators = null;
|
||
// Used to optimize reconciliation
|
||
this._dirtyType = NO_DIRTY_NODES;
|
||
this._cloneNotNeeded = new Set();
|
||
this._dirtyLeaves = new Set();
|
||
this._dirtyElements = new Map();
|
||
this._normalizedNodes = new Set();
|
||
this._updateTags = new Set();
|
||
// Handling of DOM mutations
|
||
this._observer = null;
|
||
// Used for identifying owning editors
|
||
this._key = createUID();
|
||
this._onError = onError;
|
||
this._htmlConversions = htmlConversions;
|
||
this._editable = editable;
|
||
this._headless = parentEditor !== null && parentEditor._headless;
|
||
this._window = null;
|
||
this._blockCursorElement = null;
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @returns true if the editor is currently in "composition" mode due to receiving input
|
||
* through an IME, or 3P extension, for example. Returns false otherwise.
|
||
*/
|
||
isComposing() {
|
||
return this._compositionKey != null;
|
||
}
|
||
/**
|
||
* Registers a listener for Editor update event. Will trigger the provided callback
|
||
* each time the editor goes through an update (via {@link LexicalEditor.update}) until the
|
||
* teardown function is called.
|
||
*
|
||
* @returns a teardown function that can be used to cleanup the listener.
|
||
*/
|
||
registerUpdateListener(listener) {
|
||
const listenerSetOrMap = this._listeners.update;
|
||
listenerSetOrMap.add(listener);
|
||
return () => {
|
||
listenerSetOrMap.delete(listener);
|
||
};
|
||
}
|
||
/**
|
||
* Registers a listener for for when the editor changes between editable and non-editable states.
|
||
* Will trigger the provided callback each time the editor transitions between these states until the
|
||
* teardown function is called.
|
||
*
|
||
* @returns a teardown function that can be used to cleanup the listener.
|
||
*/
|
||
registerEditableListener(listener) {
|
||
const listenerSetOrMap = this._listeners.editable;
|
||
listenerSetOrMap.add(listener);
|
||
return () => {
|
||
listenerSetOrMap.delete(listener);
|
||
};
|
||
}
|
||
/**
|
||
* Registers a listener for when the editor's decorator object changes. The decorator object contains
|
||
* all DecoratorNode keys -> their decorated value. This is primarily used with external UI frameworks.
|
||
*
|
||
* Will trigger the provided callback each time the editor transitions between these states until the
|
||
* teardown function is called.
|
||
*
|
||
* @returns a teardown function that can be used to cleanup the listener.
|
||
*/
|
||
registerDecoratorListener(listener) {
|
||
const listenerSetOrMap = this._listeners.decorator;
|
||
listenerSetOrMap.add(listener);
|
||
return () => {
|
||
listenerSetOrMap.delete(listener);
|
||
};
|
||
}
|
||
/**
|
||
* Registers a listener for when Lexical commits an update to the DOM and the text content of
|
||
* the editor changes from the previous state of the editor. If the text content is the
|
||
* same between updates, no notifications to the listeners will happen.
|
||
*
|
||
* Will trigger the provided callback each time the editor transitions between these states until the
|
||
* teardown function is called.
|
||
*
|
||
* @returns a teardown function that can be used to cleanup the listener.
|
||
*/
|
||
registerTextContentListener(listener) {
|
||
const listenerSetOrMap = this._listeners.textcontent;
|
||
listenerSetOrMap.add(listener);
|
||
return () => {
|
||
listenerSetOrMap.delete(listener);
|
||
};
|
||
}
|
||
/**
|
||
* Registers a listener for when the editor's root DOM element (the content editable
|
||
* Lexical attaches to) changes. This is primarily used to attach event listeners to the root
|
||
* element. The root listener function is executed directly upon registration and then on
|
||
* any subsequent update.
|
||
*
|
||
* Will trigger the provided callback each time the editor transitions between these states until the
|
||
* teardown function is called.
|
||
*
|
||
* @returns a teardown function that can be used to cleanup the listener.
|
||
*/
|
||
registerRootListener(listener) {
|
||
const listenerSetOrMap = this._listeners.root;
|
||
listener(this._rootElement, null);
|
||
listenerSetOrMap.add(listener);
|
||
return () => {
|
||
listener(null, this._rootElement);
|
||
listenerSetOrMap.delete(listener);
|
||
};
|
||
}
|
||
/**
|
||
* Registers a listener that will trigger anytime the provided command
|
||
* is dispatched, subject to priority. Listeners that run at a higher priority can "intercept"
|
||
* commands and prevent them from propagating to other handlers by returning true.
|
||
*
|
||
* Listeners registered at the same priority level will run deterministically in the order of registration.
|
||
*
|
||
* @param command - the command that will trigger the callback.
|
||
* @param listener - the function that will execute when the command is dispatched.
|
||
* @param priority - the relative priority of the listener. 0 | 1 | 2 | 3 | 4
|
||
* @returns a teardown function that can be used to cleanup the listener.
|
||
*/
|
||
registerCommand(command, listener, priority) {
|
||
if (priority === undefined) {
|
||
{
|
||
throw Error(`Listener for type "command" requires a "priority".`);
|
||
}
|
||
}
|
||
const commandsMap = this._commands;
|
||
if (!commandsMap.has(command)) {
|
||
commandsMap.set(command, [new Set(), new Set(), new Set(), new Set(), new Set()]);
|
||
}
|
||
const listenersInPriorityOrder = commandsMap.get(command);
|
||
if (listenersInPriorityOrder === undefined) {
|
||
{
|
||
throw Error(`registerCommand: Command ${String(command)} not found in command map`);
|
||
}
|
||
}
|
||
const listeners = listenersInPriorityOrder[priority];
|
||
listeners.add(listener);
|
||
return () => {
|
||
listeners.delete(listener);
|
||
if (listenersInPriorityOrder.every(listenersSet => listenersSet.size === 0)) {
|
||
commandsMap.delete(command);
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Registers a listener that will run when a Lexical node of the provided class is
|
||
* mutated. The listener will receive a list of nodes along with the type of mutation
|
||
* that was performed on each: created, destroyed, or updated.
|
||
*
|
||
* One common use case for this is to attach DOM event listeners to the underlying DOM nodes as Lexical nodes are created.
|
||
* {@link LexicalEditor.getElementByKey} can be used for this.
|
||
*
|
||
* @param klass - The class of the node that you want to listen to mutations on.
|
||
* @param listener - The logic you want to run when the node is mutated.
|
||
* @returns a teardown function that can be used to cleanup the listener.
|
||
*/
|
||
registerMutationListener(klass, listener) {
|
||
const registeredNode = this._nodes.get(klass.getType());
|
||
if (registeredNode === undefined) {
|
||
{
|
||
throw Error(`Node ${klass.name} has not been registered. Ensure node has been passed to createEditor.`);
|
||
}
|
||
}
|
||
const mutations = this._listeners.mutation;
|
||
mutations.set(listener, klass);
|
||
return () => {
|
||
mutations.delete(listener);
|
||
};
|
||
}
|
||
|
||
/** @internal */
|
||
registerNodeTransformToKlass(klass, listener) {
|
||
const type = klass.getType();
|
||
const registeredNode = this._nodes.get(type);
|
||
if (registeredNode === undefined) {
|
||
{
|
||
throw Error(`Node ${klass.name} has not been registered. Ensure node has been passed to createEditor.`);
|
||
}
|
||
}
|
||
const transforms = registeredNode.transforms;
|
||
transforms.add(listener);
|
||
return registeredNode;
|
||
}
|
||
|
||
/**
|
||
* Registers a listener that will run when a Lexical node of the provided class is
|
||
* marked dirty during an update. The listener will continue to run as long as the node
|
||
* is marked dirty. There are no guarantees around the order of transform execution!
|
||
*
|
||
* Watch out for infinite loops. See [Node Transforms](https://lexical.dev/docs/concepts/transforms)
|
||
* @param klass - The class of the node that you want to run transforms on.
|
||
* @param listener - The logic you want to run when the node is updated.
|
||
* @returns a teardown function that can be used to cleanup the listener.
|
||
*/
|
||
registerNodeTransform(klass, listener) {
|
||
const registeredNode = this.registerNodeTransformToKlass(klass, listener);
|
||
const registeredNodes = [registeredNode];
|
||
const replaceWithKlass = registeredNode.replaceWithKlass;
|
||
if (replaceWithKlass != null) {
|
||
const registeredReplaceWithNode = this.registerNodeTransformToKlass(replaceWithKlass, listener);
|
||
registeredNodes.push(registeredReplaceWithNode);
|
||
}
|
||
markAllNodesAsDirty(this, klass.getType());
|
||
return () => {
|
||
registeredNodes.forEach(node => node.transforms.delete(listener));
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Used to assert that a certain node is registered, usually by plugins to ensure nodes that they
|
||
* depend on have been registered.
|
||
* @returns True if the editor has registered the provided node type, false otherwise.
|
||
*/
|
||
hasNode(node) {
|
||
return this._nodes.has(node.getType());
|
||
}
|
||
|
||
/**
|
||
* Used to assert that certain nodes are registered, usually by plugins to ensure nodes that they
|
||
* depend on have been registered.
|
||
* @returns True if the editor has registered all of the provided node types, false otherwise.
|
||
*/
|
||
hasNodes(nodes) {
|
||
return nodes.every(this.hasNode.bind(this));
|
||
}
|
||
|
||
/**
|
||
* Dispatches a command of the specified type with the specified payload.
|
||
* This triggers all command listeners (set by {@link LexicalEditor.registerCommand})
|
||
* for this type, passing them the provided payload.
|
||
* @param type - the type of command listeners to trigger.
|
||
* @param payload - the data to pass as an argument to the command listeners.
|
||
*/
|
||
dispatchCommand(type, payload) {
|
||
return dispatchCommand(this, type, payload);
|
||
}
|
||
|
||
/**
|
||
* Gets a map of all decorators in the editor.
|
||
* @returns A mapping of call decorator keys to their decorated content
|
||
*/
|
||
getDecorators() {
|
||
return this._decorators;
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @returns the current root element of the editor. If you want to register
|
||
* an event listener, do it via {@link LexicalEditor.registerRootListener}, since
|
||
* this reference may not be stable.
|
||
*/
|
||
getRootElement() {
|
||
return this._rootElement;
|
||
}
|
||
|
||
/**
|
||
* Gets the key of the editor
|
||
* @returns The editor key
|
||
*/
|
||
getKey() {
|
||
return this._key;
|
||
}
|
||
|
||
/**
|
||
* Imperatively set the root contenteditable element that Lexical listens
|
||
* for events on.
|
||
*/
|
||
setRootElement(nextRootElement) {
|
||
const prevRootElement = this._rootElement;
|
||
if (nextRootElement !== prevRootElement) {
|
||
const classNames = getCachedClassNameArray(this._config.theme, 'root');
|
||
const pendingEditorState = this._pendingEditorState || this._editorState;
|
||
this._rootElement = nextRootElement;
|
||
resetEditor(this, prevRootElement, nextRootElement, pendingEditorState);
|
||
if (prevRootElement !== null) {
|
||
// TODO: remove this flag once we no longer use UEv2 internally
|
||
if (!this._config.disableEvents) {
|
||
removeRootElementEvents(prevRootElement);
|
||
}
|
||
if (classNames != null) {
|
||
prevRootElement.classList.remove(...classNames);
|
||
}
|
||
}
|
||
if (nextRootElement !== null) {
|
||
const windowObj = getDefaultView(nextRootElement);
|
||
const style = nextRootElement.style;
|
||
style.userSelect = 'text';
|
||
style.whiteSpace = 'pre-wrap';
|
||
style.wordBreak = 'break-word';
|
||
nextRootElement.setAttribute('data-lexical-editor', 'true');
|
||
this._window = windowObj;
|
||
this._dirtyType = FULL_RECONCILE;
|
||
initMutationObserver(this);
|
||
this._updateTags.add('history-merge');
|
||
commitPendingUpdates(this);
|
||
|
||
// TODO: remove this flag once we no longer use UEv2 internally
|
||
if (!this._config.disableEvents) {
|
||
addRootElementEvents(nextRootElement, this);
|
||
}
|
||
if (classNames != null) {
|
||
nextRootElement.classList.add(...classNames);
|
||
}
|
||
} else {
|
||
// If content editable is unmounted we'll reset editor state back to original
|
||
// (or pending) editor state since there will be no reconciliation
|
||
this._editorState = pendingEditorState;
|
||
this._pendingEditorState = null;
|
||
this._window = null;
|
||
}
|
||
triggerListeners('root', this, false, nextRootElement, prevRootElement);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Gets the underlying HTMLElement associated with the LexicalNode for the given key.
|
||
* @returns the HTMLElement rendered by the LexicalNode associated with the key.
|
||
* @param key - the key of the LexicalNode.
|
||
*/
|
||
getElementByKey(key) {
|
||
return this._keyToDOMMap.get(key) || null;
|
||
}
|
||
|
||
/**
|
||
* Gets the active editor state.
|
||
* @returns The editor state
|
||
*/
|
||
getEditorState() {
|
||
return this._editorState;
|
||
}
|
||
|
||
/**
|
||
* Imperatively set the EditorState. Triggers reconciliation like an update.
|
||
* @param editorState - the state to set the editor
|
||
* @param options - options for the update.
|
||
*/
|
||
setEditorState(editorState, options) {
|
||
if (editorState.isEmpty()) {
|
||
{
|
||
throw Error(`setEditorState: the editor state is empty. Ensure the editor state's root node never becomes empty.`);
|
||
}
|
||
}
|
||
flushRootMutations(this);
|
||
const pendingEditorState = this._pendingEditorState;
|
||
const tags = this._updateTags;
|
||
const tag = options !== undefined ? options.tag : null;
|
||
if (pendingEditorState !== null && !pendingEditorState.isEmpty()) {
|
||
if (tag != null) {
|
||
tags.add(tag);
|
||
}
|
||
commitPendingUpdates(this);
|
||
}
|
||
this._pendingEditorState = editorState;
|
||
this._dirtyType = FULL_RECONCILE;
|
||
this._dirtyElements.set('root', false);
|
||
this._compositionKey = null;
|
||
if (tag != null) {
|
||
tags.add(tag);
|
||
}
|
||
commitPendingUpdates(this);
|
||
}
|
||
|
||
/**
|
||
* Parses a SerializedEditorState (usually produced by {@link EditorState.toJSON}) and returns
|
||
* and EditorState object that can be, for example, passed to {@link LexicalEditor.setEditorState}. Typically,
|
||
* deserliazation from JSON stored in a database uses this method.
|
||
* @param maybeStringifiedEditorState
|
||
* @param updateFn
|
||
* @returns
|
||
*/
|
||
parseEditorState(maybeStringifiedEditorState, updateFn) {
|
||
const serializedEditorState = typeof maybeStringifiedEditorState === 'string' ? JSON.parse(maybeStringifiedEditorState) : maybeStringifiedEditorState;
|
||
return parseEditorState(serializedEditorState, this, updateFn);
|
||
}
|
||
|
||
/**
|
||
* Executes an update to the editor state. The updateFn callback is the ONLY place
|
||
* where Lexical editor state can be safely mutated.
|
||
* @param updateFn - A function that has access to writable editor state.
|
||
* @param options - A bag of options to control the behavior of the update.
|
||
* @param options.onUpdate - A function to run once the update is complete.
|
||
* Useful for synchronizing updates in some cases.
|
||
* @param options.skipTransforms - Setting this to true will suppress all node
|
||
* transforms for this update cycle.
|
||
* @param options.tag - A tag to identify this update, in an update listener, for instance.
|
||
* Some tags are reserved by the core and control update behavior in different ways.
|
||
* @param options.discrete - If true, prevents this update from being batched, forcing it to
|
||
* run synchronously.
|
||
*/
|
||
update(updateFn, options) {
|
||
updateEditor(this, updateFn, options);
|
||
}
|
||
|
||
/**
|
||
* Focuses the editor
|
||
* @param callbackFn - A function to run after the editor is focused.
|
||
* @param options - A bag of options
|
||
* @param options.defaultSelection - Where to move selection when the editor is
|
||
* focused. Can be rootStart, rootEnd, or undefined. Defaults to rootEnd.
|
||
*/
|
||
focus(callbackFn, options = {}) {
|
||
const rootElement = this._rootElement;
|
||
if (rootElement !== null) {
|
||
// This ensures that iOS does not trigger caps lock upon focus
|
||
rootElement.setAttribute('autocapitalize', 'off');
|
||
updateEditor(this, () => {
|
||
const selection = $getSelection();
|
||
const root = $getRoot();
|
||
if (selection !== null) {
|
||
// Marking the selection dirty will force the selection back to it
|
||
selection.dirty = true;
|
||
} else if (root.getChildrenSize() !== 0) {
|
||
if (options.defaultSelection === 'rootStart') {
|
||
root.selectStart();
|
||
} else {
|
||
root.selectEnd();
|
||
}
|
||
}
|
||
}, {
|
||
onUpdate: () => {
|
||
rootElement.removeAttribute('autocapitalize');
|
||
if (callbackFn) {
|
||
callbackFn();
|
||
}
|
||
},
|
||
tag: 'focus'
|
||
});
|
||
// In the case where onUpdate doesn't fire (due to the focus update not
|
||
// occuring).
|
||
if (this._pendingEditorState === null) {
|
||
rootElement.removeAttribute('autocapitalize');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Removes focus from the editor.
|
||
*/
|
||
blur() {
|
||
const rootElement = this._rootElement;
|
||
if (rootElement !== null) {
|
||
rootElement.blur();
|
||
}
|
||
const domSelection = getDOMSelection(this._window);
|
||
if (domSelection !== null) {
|
||
domSelection.removeAllRanges();
|
||
}
|
||
}
|
||
/**
|
||
* Returns true if the editor is editable, false otherwise.
|
||
* @returns True if the editor is editable, false otherwise.
|
||
*/
|
||
isEditable() {
|
||
return this._editable;
|
||
}
|
||
/**
|
||
* Sets the editable property of the editor. When false, the
|
||
* editor will not listen for user events on the underling contenteditable.
|
||
* @param editable - the value to set the editable mode to.
|
||
*/
|
||
setEditable(editable) {
|
||
if (this._editable !== editable) {
|
||
this._editable = editable;
|
||
triggerListeners('editable', this, true, editable);
|
||
}
|
||
}
|
||
/**
|
||
* Returns a JSON-serializable javascript object NOT a JSON string.
|
||
* You still must call JSON.stringify (or something else) to turn the
|
||
* state into a string you can transfer over the wire and store in a database.
|
||
*
|
||
* See {@link LexicalNode.exportJSON}
|
||
*
|
||
* @returns A JSON-serializable javascript object
|
||
*/
|
||
toJSON() {
|
||
return {
|
||
editorState: this._editorState.toJSON()
|
||
};
|
||
}
|
||
}
|
||
|
||
exports.$addUpdateTag = $addUpdateTag;
|
||
exports.$applyNodeReplacement = $applyNodeReplacement;
|
||
exports.$copyNode = $copyNode;
|
||
exports.$createLineBreakNode = $createLineBreakNode;
|
||
exports.$createNodeSelection = $createNodeSelection;
|
||
exports.$createParagraphNode = $createParagraphNode;
|
||
exports.$createPoint = $createPoint;
|
||
exports.$createRangeSelection = $createRangeSelection;
|
||
exports.$createTabNode = $createTabNode;
|
||
exports.$createTextNode = $createTextNode;
|
||
exports.$getAdjacentNode = $getAdjacentNode;
|
||
exports.$getCharacterOffsets = $getCharacterOffsets;
|
||
exports.$getEditor = $getEditor;
|
||
exports.$getNearestNodeFromDOMNode = $getNearestNodeFromDOMNode;
|
||
exports.$getNearestRootOrShadowRoot = $getNearestRootOrShadowRoot;
|
||
exports.$getNodeByKey = $getNodeByKey;
|
||
exports.$getPreviousSelection = $getPreviousSelection;
|
||
exports.$getRoot = $getRoot;
|
||
exports.$getSelection = $getSelection;
|
||
exports.$getTextContent = $getTextContent;
|
||
exports.$hasAncestor = $hasAncestor;
|
||
exports.$hasUpdateTag = $hasUpdateTag;
|
||
exports.$insertNodes = $insertNodes;
|
||
exports.$isBlockElementNode = $isBlockElementNode;
|
||
exports.$isDecoratorNode = $isDecoratorNode;
|
||
exports.$isElementNode = $isElementNode;
|
||
exports.$isInlineElementOrDecoratorNode = $isInlineElementOrDecoratorNode;
|
||
exports.$isLeafNode = $isLeafNode;
|
||
exports.$isLineBreakNode = $isLineBreakNode;
|
||
exports.$isNodeSelection = $isNodeSelection;
|
||
exports.$isParagraphNode = $isParagraphNode;
|
||
exports.$isRangeSelection = $isRangeSelection;
|
||
exports.$isRootNode = $isRootNode;
|
||
exports.$isRootOrShadowRoot = $isRootOrShadowRoot;
|
||
exports.$isTabNode = $isTabNode;
|
||
exports.$isTextNode = $isTextNode;
|
||
exports.$nodesOfType = $nodesOfType;
|
||
exports.$normalizeSelection__EXPERIMENTAL = $normalizeSelection;
|
||
exports.$parseSerializedNode = $parseSerializedNode;
|
||
exports.$selectAll = $selectAll;
|
||
exports.$setCompositionKey = $setCompositionKey;
|
||
exports.$setSelection = $setSelection;
|
||
exports.$splitNode = $splitNode;
|
||
exports.BLUR_COMMAND = BLUR_COMMAND;
|
||
exports.CAN_REDO_COMMAND = CAN_REDO_COMMAND;
|
||
exports.CAN_UNDO_COMMAND = CAN_UNDO_COMMAND;
|
||
exports.CLEAR_EDITOR_COMMAND = CLEAR_EDITOR_COMMAND;
|
||
exports.CLEAR_HISTORY_COMMAND = CLEAR_HISTORY_COMMAND;
|
||
exports.CLICK_COMMAND = CLICK_COMMAND;
|
||
exports.COMMAND_PRIORITY_CRITICAL = COMMAND_PRIORITY_CRITICAL;
|
||
exports.COMMAND_PRIORITY_EDITOR = COMMAND_PRIORITY_EDITOR;
|
||
exports.COMMAND_PRIORITY_HIGH = COMMAND_PRIORITY_HIGH;
|
||
exports.COMMAND_PRIORITY_LOW = COMMAND_PRIORITY_LOW;
|
||
exports.COMMAND_PRIORITY_NORMAL = COMMAND_PRIORITY_NORMAL;
|
||
exports.CONTROLLED_TEXT_INSERTION_COMMAND = CONTROLLED_TEXT_INSERTION_COMMAND;
|
||
exports.COPY_COMMAND = COPY_COMMAND;
|
||
exports.CUT_COMMAND = CUT_COMMAND;
|
||
exports.DELETE_CHARACTER_COMMAND = DELETE_CHARACTER_COMMAND;
|
||
exports.DELETE_LINE_COMMAND = DELETE_LINE_COMMAND;
|
||
exports.DELETE_WORD_COMMAND = DELETE_WORD_COMMAND;
|
||
exports.DRAGEND_COMMAND = DRAGEND_COMMAND;
|
||
exports.DRAGOVER_COMMAND = DRAGOVER_COMMAND;
|
||
exports.DRAGSTART_COMMAND = DRAGSTART_COMMAND;
|
||
exports.DROP_COMMAND = DROP_COMMAND;
|
||
exports.DecoratorNode = DecoratorNode;
|
||
exports.ElementNode = ElementNode;
|
||
exports.FOCUS_COMMAND = FOCUS_COMMAND;
|
||
exports.FORMAT_ELEMENT_COMMAND = FORMAT_ELEMENT_COMMAND;
|
||
exports.FORMAT_TEXT_COMMAND = FORMAT_TEXT_COMMAND;
|
||
exports.INDENT_CONTENT_COMMAND = INDENT_CONTENT_COMMAND;
|
||
exports.INSERT_LINE_BREAK_COMMAND = INSERT_LINE_BREAK_COMMAND;
|
||
exports.INSERT_PARAGRAPH_COMMAND = INSERT_PARAGRAPH_COMMAND;
|
||
exports.INSERT_TAB_COMMAND = INSERT_TAB_COMMAND;
|
||
exports.KEY_ARROW_DOWN_COMMAND = KEY_ARROW_DOWN_COMMAND;
|
||
exports.KEY_ARROW_LEFT_COMMAND = KEY_ARROW_LEFT_COMMAND;
|
||
exports.KEY_ARROW_RIGHT_COMMAND = KEY_ARROW_RIGHT_COMMAND;
|
||
exports.KEY_ARROW_UP_COMMAND = KEY_ARROW_UP_COMMAND;
|
||
exports.KEY_BACKSPACE_COMMAND = KEY_BACKSPACE_COMMAND;
|
||
exports.KEY_DELETE_COMMAND = KEY_DELETE_COMMAND;
|
||
exports.KEY_DOWN_COMMAND = KEY_DOWN_COMMAND;
|
||
exports.KEY_ENTER_COMMAND = KEY_ENTER_COMMAND;
|
||
exports.KEY_ESCAPE_COMMAND = KEY_ESCAPE_COMMAND;
|
||
exports.KEY_MODIFIER_COMMAND = KEY_MODIFIER_COMMAND;
|
||
exports.KEY_SPACE_COMMAND = KEY_SPACE_COMMAND;
|
||
exports.KEY_TAB_COMMAND = KEY_TAB_COMMAND;
|
||
exports.LineBreakNode = LineBreakNode;
|
||
exports.MOVE_TO_END = MOVE_TO_END;
|
||
exports.MOVE_TO_START = MOVE_TO_START;
|
||
exports.OUTDENT_CONTENT_COMMAND = OUTDENT_CONTENT_COMMAND;
|
||
exports.PASTE_COMMAND = PASTE_COMMAND;
|
||
exports.ParagraphNode = ParagraphNode;
|
||
exports.REDO_COMMAND = REDO_COMMAND;
|
||
exports.REMOVE_TEXT_COMMAND = REMOVE_TEXT_COMMAND;
|
||
exports.RootNode = RootNode;
|
||
exports.SELECTION_CHANGE_COMMAND = SELECTION_CHANGE_COMMAND;
|
||
exports.SELECTION_INSERT_CLIPBOARD_NODES_COMMAND = SELECTION_INSERT_CLIPBOARD_NODES_COMMAND;
|
||
exports.SELECT_ALL_COMMAND = SELECT_ALL_COMMAND;
|
||
exports.TabNode = TabNode;
|
||
exports.TextNode = TextNode;
|
||
exports.UNDO_COMMAND = UNDO_COMMAND;
|
||
exports.createCommand = createCommand;
|
||
exports.createEditor = createEditor;
|
||
exports.getNearestEditorFromDOMNode = getNearestEditorFromDOMNode;
|
||
exports.isCurrentlyReadOnlyMode = isCurrentlyReadOnlyMode;
|
||
exports.isHTMLAnchorElement = isHTMLAnchorElement;
|
||
exports.isHTMLElement = isHTMLElement;
|
||
exports.isSelectionCapturedInDecoratorInput = isSelectionCapturedInDecoratorInput;
|
||
exports.isSelectionWithinEditor = isSelectionWithinEditor;
|