990 lines
36 KiB
JavaScript
990 lines
36 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';
|
|
|
|
var lexical = require('lexical');
|
|
|
|
/**
|
|
* 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 CSS_TO_STYLES = new Map();
|
|
|
|
/**
|
|
* 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 getDOMTextNode(element) {
|
|
let node = element;
|
|
while (node != null) {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
return node;
|
|
}
|
|
node = node.firstChild;
|
|
}
|
|
return null;
|
|
}
|
|
function getDOMIndexWithinParent(node) {
|
|
const parent = node.parentNode;
|
|
if (parent == null) {
|
|
throw new Error('Should never happen');
|
|
}
|
|
return [parent, Array.from(parent.childNodes).indexOf(node)];
|
|
}
|
|
|
|
/**
|
|
* Creates a selection range for the DOM.
|
|
* @param editor - The lexical editor.
|
|
* @param anchorNode - The anchor node of a selection.
|
|
* @param _anchorOffset - The amount of space offset from the anchor to the focus.
|
|
* @param focusNode - The current focus.
|
|
* @param _focusOffset - The amount of space offset from the focus to the anchor.
|
|
* @returns The range of selection for the DOM that was created.
|
|
*/
|
|
function createDOMRange(editor, anchorNode, _anchorOffset, focusNode, _focusOffset) {
|
|
const anchorKey = anchorNode.getKey();
|
|
const focusKey = focusNode.getKey();
|
|
const range = document.createRange();
|
|
let anchorDOM = editor.getElementByKey(anchorKey);
|
|
let focusDOM = editor.getElementByKey(focusKey);
|
|
let anchorOffset = _anchorOffset;
|
|
let focusOffset = _focusOffset;
|
|
if (lexical.$isTextNode(anchorNode)) {
|
|
anchorDOM = getDOMTextNode(anchorDOM);
|
|
}
|
|
if (lexical.$isTextNode(focusNode)) {
|
|
focusDOM = getDOMTextNode(focusDOM);
|
|
}
|
|
if (anchorNode === undefined || focusNode === undefined || anchorDOM === null || focusDOM === null) {
|
|
return null;
|
|
}
|
|
if (anchorDOM.nodeName === 'BR') {
|
|
[anchorDOM, anchorOffset] = getDOMIndexWithinParent(anchorDOM);
|
|
}
|
|
if (focusDOM.nodeName === 'BR') {
|
|
[focusDOM, focusOffset] = getDOMIndexWithinParent(focusDOM);
|
|
}
|
|
const firstChild = anchorDOM.firstChild;
|
|
if (anchorDOM === focusDOM && firstChild != null && firstChild.nodeName === 'BR' && anchorOffset === 0 && focusOffset === 0) {
|
|
focusOffset = 1;
|
|
}
|
|
try {
|
|
range.setStart(anchorDOM, anchorOffset);
|
|
range.setEnd(focusDOM, focusOffset);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
if (range.collapsed && (anchorOffset !== focusOffset || anchorKey !== focusKey)) {
|
|
// Range is backwards, we need to reverse it
|
|
range.setStart(focusDOM, focusOffset);
|
|
range.setEnd(anchorDOM, anchorOffset);
|
|
}
|
|
return range;
|
|
}
|
|
|
|
/**
|
|
* Creates DOMRects, generally used to help the editor find a specific location on the screen.
|
|
* @param editor - The lexical editor
|
|
* @param range - A fragment of a document that can contain nodes and parts of text nodes.
|
|
* @returns The selectionRects as an array.
|
|
*/
|
|
function createRectsFromDOMRange(editor, range) {
|
|
const rootElement = editor.getRootElement();
|
|
if (rootElement === null) {
|
|
return [];
|
|
}
|
|
const rootRect = rootElement.getBoundingClientRect();
|
|
const computedStyle = getComputedStyle(rootElement);
|
|
const rootPadding = parseFloat(computedStyle.paddingLeft) + parseFloat(computedStyle.paddingRight);
|
|
const selectionRects = Array.from(range.getClientRects());
|
|
let selectionRectsLength = selectionRects.length;
|
|
//sort rects from top left to bottom right.
|
|
selectionRects.sort((a, b) => {
|
|
const top = a.top - b.top;
|
|
// Some rects match position closely, but not perfectly,
|
|
// so we give a 3px tolerance.
|
|
if (Math.abs(top) <= 3) {
|
|
return a.left - b.left;
|
|
}
|
|
return top;
|
|
});
|
|
let prevRect;
|
|
for (let i = 0; i < selectionRectsLength; i++) {
|
|
const selectionRect = selectionRects[i];
|
|
// Exclude rects that overlap preceding Rects in the sorted list.
|
|
const isOverlappingRect = prevRect && prevRect.top <= selectionRect.top && prevRect.top + prevRect.height > selectionRect.top && prevRect.left + prevRect.width > selectionRect.left;
|
|
// Exclude selections that span the entire element
|
|
const selectionSpansElement = selectionRect.width + rootPadding === rootRect.width;
|
|
if (isOverlappingRect || selectionSpansElement) {
|
|
selectionRects.splice(i--, 1);
|
|
selectionRectsLength--;
|
|
continue;
|
|
}
|
|
prevRect = selectionRect;
|
|
}
|
|
return selectionRects;
|
|
}
|
|
|
|
/**
|
|
* Creates an object containing all the styles and their values provided in the CSS string.
|
|
* @param css - The CSS string of styles and their values.
|
|
* @returns The styleObject containing all the styles and their values.
|
|
*/
|
|
function getStyleObjectFromRawCSS(css) {
|
|
const styleObject = {};
|
|
const styles = css.split(';');
|
|
for (const style of styles) {
|
|
if (style !== '') {
|
|
const [key, value] = style.split(/:([^]+)/); // split on first colon
|
|
if (key && value) {
|
|
styleObject[key.trim()] = value.trim();
|
|
}
|
|
}
|
|
}
|
|
return styleObject;
|
|
}
|
|
|
|
/**
|
|
* Given a CSS string, returns an object from the style cache.
|
|
* @param css - The CSS property as a string.
|
|
* @returns The value of the given CSS property.
|
|
*/
|
|
function getStyleObjectFromCSS(css) {
|
|
let value = CSS_TO_STYLES.get(css);
|
|
if (value === undefined) {
|
|
value = getStyleObjectFromRawCSS(css);
|
|
CSS_TO_STYLES.set(css, value);
|
|
}
|
|
{
|
|
// Freeze the value in DEV to prevent accidental mutations
|
|
Object.freeze(value);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Gets the CSS styles from the style object.
|
|
* @param styles - The style object containing the styles to get.
|
|
* @returns A string containing the CSS styles and their values.
|
|
*/
|
|
function getCSSFromStyleObject(styles) {
|
|
let css = '';
|
|
for (const style in styles) {
|
|
if (style) {
|
|
css += `${style}: ${styles[style]};`;
|
|
}
|
|
}
|
|
return css;
|
|
}
|
|
|
|
/**
|
|
* 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 $updateElementNodeProperties(target, source) {
|
|
target.__first = source.__first;
|
|
target.__last = source.__last;
|
|
target.__size = source.__size;
|
|
target.__format = source.__format;
|
|
target.__indent = source.__indent;
|
|
target.__dir = source.__dir;
|
|
return target;
|
|
}
|
|
function $updateTextNodeProperties(target, source) {
|
|
target.__format = source.__format;
|
|
target.__style = source.__style;
|
|
target.__mode = source.__mode;
|
|
target.__detail = source.__detail;
|
|
return target;
|
|
}
|
|
|
|
/**
|
|
* Returns a copy of a node, but generates a new key for the copy.
|
|
* @param node - The node to be cloned.
|
|
* @returns The clone of the node.
|
|
*/
|
|
function $cloneWithProperties(node) {
|
|
const constructor = node.constructor;
|
|
// @ts-expect-error
|
|
const clone = constructor.clone(node);
|
|
clone.__parent = node.__parent;
|
|
clone.__next = node.__next;
|
|
clone.__prev = node.__prev;
|
|
if (lexical.$isElementNode(node) && lexical.$isElementNode(clone)) {
|
|
return $updateElementNodeProperties(clone, node);
|
|
}
|
|
if (lexical.$isTextNode(node) && lexical.$isTextNode(clone)) {
|
|
return $updateTextNodeProperties(clone, node);
|
|
}
|
|
return clone;
|
|
}
|
|
|
|
/**
|
|
* Generally used to append text content to HTML and JSON. Grabs the text content and "slices"
|
|
* it to be generated into the new TextNode.
|
|
* @param selection - The selection containing the node whose TextNode is to be edited.
|
|
* @param textNode - The TextNode to be edited.
|
|
* @returns The updated TextNode.
|
|
*/
|
|
function $sliceSelectedTextNodeContent(selection, textNode) {
|
|
const anchorAndFocus = selection.getStartEndPoints();
|
|
if (textNode.isSelected(selection) && !textNode.isSegmented() && !textNode.isToken() && anchorAndFocus !== null) {
|
|
const [anchor, focus] = anchorAndFocus;
|
|
const isBackward = selection.isBackward();
|
|
const anchorNode = anchor.getNode();
|
|
const focusNode = focus.getNode();
|
|
const isAnchor = textNode.is(anchorNode);
|
|
const isFocus = textNode.is(focusNode);
|
|
if (isAnchor || isFocus) {
|
|
const [anchorOffset, focusOffset] = lexical.$getCharacterOffsets(selection);
|
|
const isSame = anchorNode.is(focusNode);
|
|
const isFirst = textNode.is(isBackward ? focusNode : anchorNode);
|
|
const isLast = textNode.is(isBackward ? anchorNode : focusNode);
|
|
let startOffset = 0;
|
|
let endOffset = undefined;
|
|
if (isSame) {
|
|
startOffset = anchorOffset > focusOffset ? focusOffset : anchorOffset;
|
|
endOffset = anchorOffset > focusOffset ? anchorOffset : focusOffset;
|
|
} else if (isFirst) {
|
|
const offset = isBackward ? focusOffset : anchorOffset;
|
|
startOffset = offset;
|
|
endOffset = undefined;
|
|
} else if (isLast) {
|
|
const offset = isBackward ? anchorOffset : focusOffset;
|
|
startOffset = 0;
|
|
endOffset = offset;
|
|
}
|
|
textNode.__text = textNode.__text.slice(startOffset, endOffset);
|
|
return textNode;
|
|
}
|
|
}
|
|
return textNode;
|
|
}
|
|
|
|
/**
|
|
* Determines if the current selection is at the end of the node.
|
|
* @param point - The point of the selection to test.
|
|
* @returns true if the provided point offset is in the last possible position, false otherwise.
|
|
*/
|
|
function $isAtNodeEnd(point) {
|
|
if (point.type === 'text') {
|
|
return point.offset === point.getNode().getTextContentSize();
|
|
}
|
|
const node = point.getNode();
|
|
if (!lexical.$isElementNode(node)) {
|
|
throw Error(`isAtNodeEnd: node must be a TextNode or ElementNode`);
|
|
}
|
|
return point.offset === node.getChildrenSize();
|
|
}
|
|
|
|
/**
|
|
* Trims text from a node in order to shorten it, eg. to enforce a text's max length. If it deletes text
|
|
* that is an ancestor of the anchor then it will leave 2 indents, otherwise, if no text content exists, it deletes
|
|
* the TextNode. It will move the focus to either the end of any left over text or beginning of a new TextNode.
|
|
* @param editor - The lexical editor.
|
|
* @param anchor - The anchor of the current selection, where the selection should be pointing.
|
|
* @param delCount - The amount of characters to delete. Useful as a dynamic variable eg. textContentSize - maxLength;
|
|
*/
|
|
function trimTextContentFromAnchor(editor, anchor, delCount) {
|
|
// Work from the current selection anchor point
|
|
let currentNode = anchor.getNode();
|
|
let remaining = delCount;
|
|
if (lexical.$isElementNode(currentNode)) {
|
|
const descendantNode = currentNode.getDescendantByIndex(anchor.offset);
|
|
if (descendantNode !== null) {
|
|
currentNode = descendantNode;
|
|
}
|
|
}
|
|
while (remaining > 0 && currentNode !== null) {
|
|
if (lexical.$isElementNode(currentNode)) {
|
|
const lastDescendant = currentNode.getLastDescendant();
|
|
if (lastDescendant !== null) {
|
|
currentNode = lastDescendant;
|
|
}
|
|
}
|
|
let nextNode = currentNode.getPreviousSibling();
|
|
let additionalElementWhitespace = 0;
|
|
if (nextNode === null) {
|
|
let parent = currentNode.getParentOrThrow();
|
|
let parentSibling = parent.getPreviousSibling();
|
|
while (parentSibling === null) {
|
|
parent = parent.getParent();
|
|
if (parent === null) {
|
|
nextNode = null;
|
|
break;
|
|
}
|
|
parentSibling = parent.getPreviousSibling();
|
|
}
|
|
if (parent !== null) {
|
|
additionalElementWhitespace = parent.isInline() ? 0 : 2;
|
|
nextNode = parentSibling;
|
|
}
|
|
}
|
|
let text = currentNode.getTextContent();
|
|
// If the text is empty, we need to consider adding in two line breaks to match
|
|
// the content if we were to get it from its parent.
|
|
if (text === '' && lexical.$isElementNode(currentNode) && !currentNode.isInline()) {
|
|
// TODO: should this be handled in core?
|
|
text = '\n\n';
|
|
}
|
|
const currentNodeSize = text.length;
|
|
if (!lexical.$isTextNode(currentNode) || remaining >= currentNodeSize) {
|
|
const parent = currentNode.getParent();
|
|
currentNode.remove();
|
|
if (parent != null && parent.getChildrenSize() === 0 && !lexical.$isRootNode(parent)) {
|
|
parent.remove();
|
|
}
|
|
remaining -= currentNodeSize + additionalElementWhitespace;
|
|
currentNode = nextNode;
|
|
} else {
|
|
const key = currentNode.getKey();
|
|
// See if we can just revert it to what was in the last editor state
|
|
const prevTextContent = editor.getEditorState().read(() => {
|
|
const prevNode = lexical.$getNodeByKey(key);
|
|
if (lexical.$isTextNode(prevNode) && prevNode.isSimpleText()) {
|
|
return prevNode.getTextContent();
|
|
}
|
|
return null;
|
|
});
|
|
const offset = currentNodeSize - remaining;
|
|
const slicedText = text.slice(0, offset);
|
|
if (prevTextContent !== null && prevTextContent !== text) {
|
|
const prevSelection = lexical.$getPreviousSelection();
|
|
let target = currentNode;
|
|
if (!currentNode.isSimpleText()) {
|
|
const textNode = lexical.$createTextNode(prevTextContent);
|
|
currentNode.replace(textNode);
|
|
target = textNode;
|
|
} else {
|
|
currentNode.setTextContent(prevTextContent);
|
|
}
|
|
if (lexical.$isRangeSelection(prevSelection) && prevSelection.isCollapsed()) {
|
|
const prevOffset = prevSelection.anchor.offset;
|
|
target.select(prevOffset, prevOffset);
|
|
}
|
|
} else if (currentNode.isSimpleText()) {
|
|
// Split text
|
|
const isSelected = anchor.key === key;
|
|
let anchorOffset = anchor.offset;
|
|
// Move offset to end if it's less than the remaining number, otherwise
|
|
// we'll have a negative splitStart.
|
|
if (anchorOffset < remaining) {
|
|
anchorOffset = currentNodeSize;
|
|
}
|
|
const splitStart = isSelected ? anchorOffset - remaining : 0;
|
|
const splitEnd = isSelected ? anchorOffset : offset;
|
|
if (isSelected && splitStart === 0) {
|
|
const [excessNode] = currentNode.splitText(splitStart, splitEnd);
|
|
excessNode.remove();
|
|
} else {
|
|
const [, excessNode] = currentNode.splitText(splitStart, splitEnd);
|
|
excessNode.remove();
|
|
}
|
|
} else {
|
|
const textNode = lexical.$createTextNode(slicedText);
|
|
currentNode.replace(textNode);
|
|
}
|
|
remaining = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the TextNode's style object and adds the styles to the CSS.
|
|
* @param node - The TextNode to add styles to.
|
|
*/
|
|
function $addNodeStyle(node) {
|
|
const CSSText = node.getStyle();
|
|
const styles = getStyleObjectFromRawCSS(CSSText);
|
|
CSS_TO_STYLES.set(CSSText, styles);
|
|
}
|
|
function $patchStyle(target, patch) {
|
|
const prevStyles = getStyleObjectFromCSS('getStyle' in target ? target.getStyle() : target.style);
|
|
const newStyles = Object.entries(patch).reduce((styles, [key, value]) => {
|
|
if (value instanceof Function) {
|
|
styles[key] = value(prevStyles[key]);
|
|
} else if (value === null) {
|
|
delete styles[key];
|
|
} else {
|
|
styles[key] = value;
|
|
}
|
|
return styles;
|
|
}, {
|
|
...prevStyles
|
|
} || {});
|
|
const newCSSText = getCSSFromStyleObject(newStyles);
|
|
target.setStyle(newCSSText);
|
|
CSS_TO_STYLES.set(newCSSText, newStyles);
|
|
}
|
|
|
|
/**
|
|
* Applies the provided styles to the TextNodes in the provided Selection.
|
|
* Will update partially selected TextNodes by splitting the TextNode and applying
|
|
* the styles to the appropriate one.
|
|
* @param selection - The selected node(s) to update.
|
|
* @param patch - The patch to apply, which can include multiple styles. { CSSProperty: value }. Can also accept a function that returns the new property value.
|
|
*/
|
|
function $patchStyleText(selection, patch) {
|
|
const selectedNodes = selection.getNodes();
|
|
const selectedNodesLength = selectedNodes.length;
|
|
const anchorAndFocus = selection.getStartEndPoints();
|
|
if (anchorAndFocus === null) {
|
|
return;
|
|
}
|
|
const [anchor, focus] = anchorAndFocus;
|
|
const lastIndex = selectedNodesLength - 1;
|
|
let firstNode = selectedNodes[0];
|
|
let lastNode = selectedNodes[lastIndex];
|
|
if (selection.isCollapsed() && lexical.$isRangeSelection(selection)) {
|
|
$patchStyle(selection, patch);
|
|
return;
|
|
}
|
|
const firstNodeText = firstNode.getTextContent();
|
|
const firstNodeTextLength = firstNodeText.length;
|
|
const focusOffset = focus.offset;
|
|
let anchorOffset = anchor.offset;
|
|
const isBefore = anchor.isBefore(focus);
|
|
let startOffset = isBefore ? anchorOffset : focusOffset;
|
|
let endOffset = isBefore ? focusOffset : anchorOffset;
|
|
const startType = isBefore ? anchor.type : focus.type;
|
|
const endType = isBefore ? focus.type : anchor.type;
|
|
const endKey = isBefore ? focus.key : anchor.key;
|
|
|
|
// This is the case where the user only selected the very end of the
|
|
// first node so we don't want to include it in the formatting change.
|
|
if (lexical.$isTextNode(firstNode) && startOffset === firstNodeTextLength) {
|
|
const nextSibling = firstNode.getNextSibling();
|
|
if (lexical.$isTextNode(nextSibling)) {
|
|
// we basically make the second node the firstNode, changing offsets accordingly
|
|
anchorOffset = 0;
|
|
startOffset = 0;
|
|
firstNode = nextSibling;
|
|
}
|
|
}
|
|
|
|
// This is the case where we only selected a single node
|
|
if (selectedNodes.length === 1) {
|
|
if (lexical.$isTextNode(firstNode) && firstNode.canHaveFormat()) {
|
|
startOffset = startType === 'element' ? 0 : anchorOffset > focusOffset ? focusOffset : anchorOffset;
|
|
endOffset = endType === 'element' ? firstNodeTextLength : anchorOffset > focusOffset ? anchorOffset : focusOffset;
|
|
|
|
// 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 === firstNodeTextLength) {
|
|
$patchStyle(firstNode, patch);
|
|
firstNode.select(startOffset, endOffset);
|
|
} else {
|
|
// The node is partially selected, so split it into two nodes
|
|
// and style the selected one.
|
|
const splitNodes = firstNode.splitText(startOffset, endOffset);
|
|
const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
|
|
$patchStyle(replacement, patch);
|
|
replacement.select(0, endOffset - startOffset);
|
|
}
|
|
} // multiple nodes selected.
|
|
} else {
|
|
if (lexical.$isTextNode(firstNode) && startOffset < firstNode.getTextContentSize() && firstNode.canHaveFormat()) {
|
|
if (startOffset !== 0) {
|
|
// the entire first node isn't selected, so split it
|
|
firstNode = firstNode.splitText(startOffset)[1];
|
|
startOffset = 0;
|
|
anchor.set(firstNode.getKey(), startOffset, 'text');
|
|
}
|
|
$patchStyle(firstNode, patch);
|
|
}
|
|
if (lexical.$isTextNode(lastNode) && lastNode.canHaveFormat()) {
|
|
const lastNodeText = lastNode.getTextContent();
|
|
const lastNodeTextLength = lastNodeText.length;
|
|
|
|
// The last node might not actually be the end node
|
|
//
|
|
// If not, assume the last node is fully-selected unless the end offset is
|
|
// zero.
|
|
if (lastNode.__key !== endKey && endOffset !== 0) {
|
|
endOffset = lastNodeTextLength;
|
|
}
|
|
|
|
// if the entire last node isn't selected, split it
|
|
if (endOffset !== lastNodeTextLength) {
|
|
[lastNode] = lastNode.splitText(endOffset);
|
|
}
|
|
if (endOffset !== 0 || endType === 'element') {
|
|
$patchStyle(lastNode, patch);
|
|
}
|
|
}
|
|
|
|
// style all the text nodes in between
|
|
for (let i = 1; i < lastIndex; i++) {
|
|
const selectedNode = selectedNodes[i];
|
|
const selectedNodeKey = selectedNode.getKey();
|
|
if (lexical.$isTextNode(selectedNode) && selectedNode.canHaveFormat() && selectedNodeKey !== firstNode.getKey() && selectedNodeKey !== lastNode.getKey() && !selectedNode.isToken()) {
|
|
$patchStyle(selectedNode, patch);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
*/
|
|
|
|
/**
|
|
* Converts all nodes in the selection that are of one block type to another.
|
|
* @param selection - The selected blocks to be converted.
|
|
* @param createElement - The function that creates the node. eg. $createParagraphNode.
|
|
*/
|
|
function $setBlocksType(selection, createElement) {
|
|
if (selection === null) {
|
|
return;
|
|
}
|
|
const anchorAndFocus = selection.getStartEndPoints();
|
|
const anchor = anchorAndFocus ? anchorAndFocus[0] : null;
|
|
if (anchor !== null && anchor.key === 'root') {
|
|
const element = createElement();
|
|
const root = lexical.$getRoot();
|
|
const firstChild = root.getFirstChild();
|
|
if (firstChild) {
|
|
firstChild.replace(element, true);
|
|
} else {
|
|
root.append(element);
|
|
}
|
|
return;
|
|
}
|
|
const nodes = selection.getNodes();
|
|
const firstSelectedBlock = anchor !== null ? $getAncestor(anchor.getNode(), INTERNAL_$isBlock) : false;
|
|
if (firstSelectedBlock && nodes.indexOf(firstSelectedBlock) === -1) {
|
|
nodes.push(firstSelectedBlock);
|
|
}
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
const node = nodes[i];
|
|
if (!INTERNAL_$isBlock(node)) {
|
|
continue;
|
|
}
|
|
if (!lexical.$isElementNode(node)) {
|
|
throw Error(`Expected block node to be an ElementNode`);
|
|
}
|
|
const targetElement = createElement();
|
|
targetElement.setFormat(node.getFormatType());
|
|
targetElement.setIndent(node.getIndent());
|
|
node.replace(targetElement, true);
|
|
}
|
|
}
|
|
function isPointAttached(point) {
|
|
return point.getNode().isAttached();
|
|
}
|
|
function $removeParentEmptyElements(startingNode) {
|
|
let node = startingNode;
|
|
while (node !== null && !lexical.$isRootOrShadowRoot(node)) {
|
|
const latest = node.getLatest();
|
|
const parentNode = node.getParent();
|
|
if (latest.getChildrenSize() === 0) {
|
|
node.remove(true);
|
|
}
|
|
node = parentNode;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @deprecated
|
|
* Wraps all nodes in the selection into another node of the type returned by createElement.
|
|
* @param selection - The selection of nodes to be wrapped.
|
|
* @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
|
|
* @param wrappingElement - An element to append the wrapped selection and its children to.
|
|
*/
|
|
function $wrapNodes(selection, createElement, wrappingElement = null) {
|
|
const anchorAndFocus = selection.getStartEndPoints();
|
|
const anchor = anchorAndFocus ? anchorAndFocus[0] : null;
|
|
const nodes = selection.getNodes();
|
|
const nodesLength = nodes.length;
|
|
if (anchor !== null && (nodesLength === 0 || nodesLength === 1 && anchor.type === 'element' && anchor.getNode().getChildrenSize() === 0)) {
|
|
const target = anchor.type === 'text' ? anchor.getNode().getParentOrThrow() : anchor.getNode();
|
|
const children = target.getChildren();
|
|
let element = createElement();
|
|
element.setFormat(target.getFormatType());
|
|
element.setIndent(target.getIndent());
|
|
children.forEach(child => element.append(child));
|
|
if (wrappingElement) {
|
|
element = wrappingElement.append(element);
|
|
}
|
|
target.replace(element);
|
|
return;
|
|
}
|
|
let topLevelNode = null;
|
|
let descendants = [];
|
|
for (let i = 0; i < nodesLength; i++) {
|
|
const node = nodes[i];
|
|
// Determine whether wrapping has to be broken down into multiple chunks. This can happen if the
|
|
// user selected multiple Root-like nodes that have to be treated separately as if they are
|
|
// their own branch. I.e. you don't want to wrap a whole table, but rather the contents of each
|
|
// of each of the cell nodes.
|
|
if (lexical.$isRootOrShadowRoot(node)) {
|
|
$wrapNodesImpl(selection, descendants, descendants.length, createElement, wrappingElement);
|
|
descendants = [];
|
|
topLevelNode = node;
|
|
} else if (topLevelNode === null || topLevelNode !== null && lexical.$hasAncestor(node, topLevelNode)) {
|
|
descendants.push(node);
|
|
} else {
|
|
$wrapNodesImpl(selection, descendants, descendants.length, createElement, wrappingElement);
|
|
descendants = [node];
|
|
}
|
|
}
|
|
$wrapNodesImpl(selection, descendants, descendants.length, createElement, wrappingElement);
|
|
}
|
|
|
|
/**
|
|
* Wraps each node into a new ElementNode.
|
|
* @param selection - The selection of nodes to wrap.
|
|
* @param nodes - An array of nodes, generally the descendants of the selection.
|
|
* @param nodesLength - The length of nodes.
|
|
* @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
|
|
* @param wrappingElement - An element to wrap all the nodes into.
|
|
* @returns
|
|
*/
|
|
function $wrapNodesImpl(selection, nodes, nodesLength, createElement, wrappingElement = null) {
|
|
if (nodes.length === 0) {
|
|
return;
|
|
}
|
|
const firstNode = nodes[0];
|
|
const elementMapping = new Map();
|
|
const elements = [];
|
|
// The below logic is to find the right target for us to
|
|
// either insertAfter/insertBefore/append the corresponding
|
|
// elements to. This is made more complicated due to nested
|
|
// structures.
|
|
let target = lexical.$isElementNode(firstNode) ? firstNode : firstNode.getParentOrThrow();
|
|
if (target.isInline()) {
|
|
target = target.getParentOrThrow();
|
|
}
|
|
let targetIsPrevSibling = false;
|
|
while (target !== null) {
|
|
const prevSibling = target.getPreviousSibling();
|
|
if (prevSibling !== null) {
|
|
target = prevSibling;
|
|
targetIsPrevSibling = true;
|
|
break;
|
|
}
|
|
target = target.getParentOrThrow();
|
|
if (lexical.$isRootOrShadowRoot(target)) {
|
|
break;
|
|
}
|
|
}
|
|
const emptyElements = new Set();
|
|
|
|
// Find any top level empty elements
|
|
for (let i = 0; i < nodesLength; i++) {
|
|
const node = nodes[i];
|
|
if (lexical.$isElementNode(node) && node.getChildrenSize() === 0) {
|
|
emptyElements.add(node.getKey());
|
|
}
|
|
}
|
|
const movedNodes = new Set();
|
|
|
|
// Move out all leaf nodes into our elements array.
|
|
// If we find a top level empty element, also move make
|
|
// an element for that.
|
|
for (let i = 0; i < nodesLength; i++) {
|
|
const node = nodes[i];
|
|
let parent = node.getParent();
|
|
if (parent !== null && parent.isInline()) {
|
|
parent = parent.getParent();
|
|
}
|
|
if (parent !== null && lexical.$isLeafNode(node) && !movedNodes.has(node.getKey())) {
|
|
const parentKey = parent.getKey();
|
|
if (elementMapping.get(parentKey) === undefined) {
|
|
const targetElement = createElement();
|
|
targetElement.setFormat(parent.getFormatType());
|
|
targetElement.setIndent(parent.getIndent());
|
|
elements.push(targetElement);
|
|
elementMapping.set(parentKey, targetElement);
|
|
// Move node and its siblings to the new
|
|
// element.
|
|
parent.getChildren().forEach(child => {
|
|
targetElement.append(child);
|
|
movedNodes.add(child.getKey());
|
|
if (lexical.$isElementNode(child)) {
|
|
// Skip nested leaf nodes if the parent has already been moved
|
|
child.getChildrenKeys().forEach(key => movedNodes.add(key));
|
|
}
|
|
});
|
|
$removeParentEmptyElements(parent);
|
|
}
|
|
} else if (emptyElements.has(node.getKey())) {
|
|
if (!lexical.$isElementNode(node)) {
|
|
throw Error(`Expected node in emptyElements to be an ElementNode`);
|
|
}
|
|
const targetElement = createElement();
|
|
targetElement.setFormat(node.getFormatType());
|
|
targetElement.setIndent(node.getIndent());
|
|
elements.push(targetElement);
|
|
node.remove(true);
|
|
}
|
|
}
|
|
if (wrappingElement !== null) {
|
|
for (let i = 0; i < elements.length; i++) {
|
|
const element = elements[i];
|
|
wrappingElement.append(element);
|
|
}
|
|
}
|
|
let lastElement = null;
|
|
|
|
// If our target is Root-like, let's see if we can re-adjust
|
|
// so that the target is the first child instead.
|
|
if (lexical.$isRootOrShadowRoot(target)) {
|
|
if (targetIsPrevSibling) {
|
|
if (wrappingElement !== null) {
|
|
target.insertAfter(wrappingElement);
|
|
} else {
|
|
for (let i = elements.length - 1; i >= 0; i--) {
|
|
const element = elements[i];
|
|
target.insertAfter(element);
|
|
}
|
|
}
|
|
} else {
|
|
const firstChild = target.getFirstChild();
|
|
if (lexical.$isElementNode(firstChild)) {
|
|
target = firstChild;
|
|
}
|
|
if (firstChild === null) {
|
|
if (wrappingElement) {
|
|
target.append(wrappingElement);
|
|
} else {
|
|
for (let i = 0; i < elements.length; i++) {
|
|
const element = elements[i];
|
|
target.append(element);
|
|
lastElement = element;
|
|
}
|
|
}
|
|
} else {
|
|
if (wrappingElement !== null) {
|
|
firstChild.insertBefore(wrappingElement);
|
|
} else {
|
|
for (let i = 0; i < elements.length; i++) {
|
|
const element = elements[i];
|
|
firstChild.insertBefore(element);
|
|
lastElement = element;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if (wrappingElement) {
|
|
target.insertAfter(wrappingElement);
|
|
} else {
|
|
for (let i = elements.length - 1; i >= 0; i--) {
|
|
const element = elements[i];
|
|
target.insertAfter(element);
|
|
lastElement = element;
|
|
}
|
|
}
|
|
}
|
|
const prevSelection = lexical.$getPreviousSelection();
|
|
if (lexical.$isRangeSelection(prevSelection) && isPointAttached(prevSelection.anchor) && isPointAttached(prevSelection.focus)) {
|
|
lexical.$setSelection(prevSelection.clone());
|
|
} else if (lastElement !== null) {
|
|
lastElement.selectEnd();
|
|
} else {
|
|
selection.dirty = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determines if the default character selection should be overridden. Used with DecoratorNodes
|
|
* @param selection - The selection whose default character selection may need to be overridden.
|
|
* @param isBackward - Is the selection backwards (the focus comes before the anchor)?
|
|
* @returns true if it should be overridden, false if not.
|
|
*/
|
|
function $shouldOverrideDefaultCharacterSelection(selection, isBackward) {
|
|
const possibleNode = lexical.$getAdjacentNode(selection.focus, isBackward);
|
|
return lexical.$isDecoratorNode(possibleNode) && !possibleNode.isIsolated() || lexical.$isElementNode(possibleNode) && !possibleNode.isInline() && !possibleNode.canBeEmpty();
|
|
}
|
|
|
|
/**
|
|
* Moves the selection according to the arguments.
|
|
* @param selection - The selected text or nodes.
|
|
* @param isHoldingShift - Is the shift key being held down during the operation.
|
|
* @param isBackward - Is the selection selected backwards (the focus comes before the anchor)?
|
|
* @param granularity - The distance to adjust the current selection.
|
|
*/
|
|
function $moveCaretSelection(selection, isHoldingShift, isBackward, granularity) {
|
|
selection.modify(isHoldingShift ? 'extend' : 'move', isBackward, granularity);
|
|
}
|
|
|
|
/**
|
|
* Tests a parent element for right to left direction.
|
|
* @param selection - The selection whose parent is to be tested.
|
|
* @returns true if the selections' parent element has a direction of 'rtl' (right to left), false otherwise.
|
|
*/
|
|
function $isParentElementRTL(selection) {
|
|
const anchorNode = selection.anchor.getNode();
|
|
const parent = lexical.$isRootNode(anchorNode) ? anchorNode : anchorNode.getParentOrThrow();
|
|
return parent.getDirection() === 'rtl';
|
|
}
|
|
|
|
/**
|
|
* Moves selection by character according to arguments.
|
|
* @param selection - The selection of the characters to move.
|
|
* @param isHoldingShift - Is the shift key being held down during the operation.
|
|
* @param isBackward - Is the selection backward (the focus comes before the anchor)?
|
|
*/
|
|
function $moveCharacter(selection, isHoldingShift, isBackward) {
|
|
const isRTL = $isParentElementRTL(selection);
|
|
$moveCaretSelection(selection, isHoldingShift, isBackward ? !isRTL : isRTL, 'character');
|
|
}
|
|
|
|
/**
|
|
* Expands the current Selection to cover all of the content in the editor.
|
|
* @param selection - The current selection.
|
|
*/
|
|
function $selectAll(selection) {
|
|
const anchor = selection.anchor;
|
|
const focus = selection.focus;
|
|
const anchorNode = anchor.getNode();
|
|
const topParent = anchorNode.getTopLevelElementOrThrow();
|
|
const root = topParent.getParentOrThrow();
|
|
let firstNode = root.getFirstDescendant();
|
|
let lastNode = root.getLastDescendant();
|
|
let firstType = 'element';
|
|
let lastType = 'element';
|
|
let lastOffset = 0;
|
|
if (lexical.$isTextNode(firstNode)) {
|
|
firstType = 'text';
|
|
} else if (!lexical.$isElementNode(firstNode) && firstNode !== null) {
|
|
firstNode = firstNode.getParentOrThrow();
|
|
}
|
|
if (lexical.$isTextNode(lastNode)) {
|
|
lastType = 'text';
|
|
lastOffset = lastNode.getTextContentSize();
|
|
} else if (!lexical.$isElementNode(lastNode) && lastNode !== null) {
|
|
lastNode = lastNode.getParentOrThrow();
|
|
}
|
|
if (firstNode && lastNode) {
|
|
anchor.set(firstNode.getKey(), 0, firstType);
|
|
focus.set(lastNode.getKey(), lastOffset, lastType);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the current value of a CSS property for Nodes, if set. If not set, it returns the defaultValue.
|
|
* @param node - The node whose style value to get.
|
|
* @param styleProperty - The CSS style property.
|
|
* @param defaultValue - The default value for the property.
|
|
* @returns The value of the property for node.
|
|
*/
|
|
function $getNodeStyleValueForProperty(node, styleProperty, defaultValue) {
|
|
const css = node.getStyle();
|
|
const styleObject = getStyleObjectFromCSS(css);
|
|
if (styleObject !== null) {
|
|
return styleObject[styleProperty] || defaultValue;
|
|
}
|
|
return defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Returns the current value of a CSS property for TextNodes in the Selection, if set. If not set, it returns the defaultValue.
|
|
* If all TextNodes do not have the same value, it returns an empty string.
|
|
* @param selection - The selection of TextNodes whose value to find.
|
|
* @param styleProperty - The CSS style property.
|
|
* @param defaultValue - The default value for the property, defaults to an empty string.
|
|
* @returns The value of the property for the selected TextNodes.
|
|
*/
|
|
function $getSelectionStyleValueForProperty(selection, styleProperty, defaultValue = '') {
|
|
let styleValue = null;
|
|
const nodes = selection.getNodes();
|
|
const anchor = selection.anchor;
|
|
const focus = selection.focus;
|
|
const isBackward = selection.isBackward();
|
|
const endOffset = isBackward ? focus.offset : anchor.offset;
|
|
const endNode = isBackward ? focus.getNode() : anchor.getNode();
|
|
if (selection.isCollapsed() && selection.style !== '') {
|
|
const css = selection.style;
|
|
const styleObject = getStyleObjectFromCSS(css);
|
|
if (styleObject !== null && styleProperty in styleObject) {
|
|
return styleObject[styleProperty];
|
|
}
|
|
}
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
const node = nodes[i];
|
|
|
|
// if no actual characters in the end node are selected, we don't
|
|
// include it in the selection for purposes of determining style
|
|
// value
|
|
if (i !== 0 && endOffset === 0 && node.is(endNode)) {
|
|
continue;
|
|
}
|
|
if (lexical.$isTextNode(node)) {
|
|
const nodeStyleValue = $getNodeStyleValueForProperty(node, styleProperty, defaultValue);
|
|
if (styleValue === null) {
|
|
styleValue = nodeStyleValue;
|
|
} else if (styleValue !== nodeStyleValue) {
|
|
// multiple text nodes are in the selection and they don't all
|
|
// have the same style.
|
|
styleValue = '';
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return styleValue === null ? defaultValue : styleValue;
|
|
}
|
|
|
|
/**
|
|
* 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 (lexical.$isDecoratorNode(node)) {
|
|
return false;
|
|
}
|
|
if (!lexical.$isElementNode(node) || lexical.$isRootOrShadowRoot(node)) {
|
|
return false;
|
|
}
|
|
const firstChild = node.getFirstChild();
|
|
const isLeafElement = firstChild === null || lexical.$isLineBreakNode(firstChild) || lexical.$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;
|
|
}
|
|
|
|
exports.$addNodeStyle = $addNodeStyle;
|
|
exports.$cloneWithProperties = $cloneWithProperties;
|
|
exports.$getSelectionStyleValueForProperty = $getSelectionStyleValueForProperty;
|
|
exports.$isAtNodeEnd = $isAtNodeEnd;
|
|
exports.$isParentElementRTL = $isParentElementRTL;
|
|
exports.$moveCaretSelection = $moveCaretSelection;
|
|
exports.$moveCharacter = $moveCharacter;
|
|
exports.$patchStyleText = $patchStyleText;
|
|
exports.$selectAll = $selectAll;
|
|
exports.$setBlocksType = $setBlocksType;
|
|
exports.$shouldOverrideDefaultCharacterSelection = $shouldOverrideDefaultCharacterSelection;
|
|
exports.$sliceSelectedTextNodeContent = $sliceSelectedTextNodeContent;
|
|
exports.$wrapNodes = $wrapNodes;
|
|
exports.createDOMRange = createDOMRange;
|
|
exports.createRectsFromDOMRange = createRectsFromDOMRange;
|
|
exports.getStyleObjectFromCSS = getStyleObjectFromCSS;
|
|
exports.trimTextContentFromAnchor = trimTextContentFromAnchor;
|