BookStackApp_BookStack/resources/js/wysiwyg/lexical/selection/range-selection.ts
Dan Brown 22d078b47f
Lexical: Imported core lexical libs
Imported at 0.17.1, Modified to work in-app.
Added & configured test dependancies.
Tests need to be altered to avoid using non-included deps including
react dependancies.
2024-09-18 13:43:39 +01:00

609 lines
18 KiB
TypeScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {
BaseSelection,
ElementNode,
LexicalNode,
NodeKey,
Point,
RangeSelection,
TextNode,
} from 'lexical';
import {TableSelection} from '@lexical/table';
import {
$getAdjacentNode,
$getPreviousSelection,
$getRoot,
$hasAncestor,
$isDecoratorNode,
$isElementNode,
$isLeafNode,
$isLineBreakNode,
$isRangeSelection,
$isRootNode,
$isRootOrShadowRoot,
$isTextNode,
$setSelection,
} from 'lexical';
import invariant from 'lexical/shared/invariant';
import {getStyleObjectFromCSS} from './utils';
/**
* 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.
*/
export function $setBlocksType(
selection: BaseSelection | null,
createElement: () => ElementNode,
): void {
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 = $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;
}
invariant($isElementNode(node), '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: Point): boolean {
return point.getNode().isAttached();
}
function $removeParentEmptyElements(startingNode: ElementNode): void {
let node: ElementNode | null = startingNode;
while (node !== null && !$isRootOrShadowRoot(node)) {
const latest = node.getLatest();
const parentNode: ElementNode | null = node.getParent<ElementNode>();
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.
*/
export function $wrapNodes(
selection: BaseSelection,
createElement: () => ElementNode,
wrappingElement: null | ElementNode = null,
): void {
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: LexicalNode[] = [];
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 ($isRootOrShadowRoot(node)) {
$wrapNodesImpl(
selection,
descendants,
descendants.length,
createElement,
wrappingElement,
);
descendants = [];
topLevelNode = node;
} else if (
topLevelNode === null ||
(topLevelNode !== null && $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
*/
export function $wrapNodesImpl(
selection: BaseSelection,
nodes: LexicalNode[],
nodesLength: number,
createElement: () => ElementNode,
wrappingElement: null | ElementNode = null,
): void {
if (nodes.length === 0) {
return;
}
const firstNode = nodes[0];
const elementMapping: Map<NodeKey, ElementNode> = 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 = $isElementNode(firstNode)
? firstNode
: firstNode.getParentOrThrow();
if (target.isInline()) {
target = target.getParentOrThrow();
}
let targetIsPrevSibling = false;
while (target !== null) {
const prevSibling = target.getPreviousSibling<ElementNode>();
if (prevSibling !== null) {
target = prevSibling;
targetIsPrevSibling = true;
break;
}
target = target.getParentOrThrow();
if ($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 ($isElementNode(node) && node.getChildrenSize() === 0) {
emptyElements.add(node.getKey());
}
}
const movedNodes: Set<NodeKey> = 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 &&
$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 ($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())) {
invariant(
$isElementNode(node),
'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 ($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 ($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 = $getPreviousSelection();
if (
$isRangeSelection(prevSelection) &&
isPointAttached(prevSelection.anchor) &&
isPointAttached(prevSelection.focus)
) {
$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.
*/
export function $shouldOverrideDefaultCharacterSelection(
selection: RangeSelection,
isBackward: boolean,
): boolean {
const possibleNode = $getAdjacentNode(selection.focus, isBackward);
return (
($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) ||
($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.
*/
export function $moveCaretSelection(
selection: RangeSelection,
isHoldingShift: boolean,
isBackward: boolean,
granularity: 'character' | 'word' | 'lineboundary',
): void {
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.
*/
export function $isParentElementRTL(selection: RangeSelection): boolean {
const anchorNode = selection.anchor.getNode();
const parent = $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)?
*/
export function $moveCharacter(
selection: RangeSelection,
isHoldingShift: boolean,
isBackward: boolean,
): void {
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.
*/
export function $selectAll(selection: RangeSelection): void {
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' | 'text' = 'element';
let lastType: 'element' | 'text' = 'element';
let lastOffset = 0;
if ($isTextNode(firstNode)) {
firstType = 'text';
} else if (!$isElementNode(firstNode) && firstNode !== null) {
firstNode = firstNode.getParentOrThrow();
}
if ($isTextNode(lastNode)) {
lastType = 'text';
lastOffset = lastNode.getTextContentSize();
} else if (!$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: TextNode,
styleProperty: string,
defaultValue: string,
): string {
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.
*/
export function $getSelectionStyleValueForProperty(
selection: RangeSelection | TableSelection,
styleProperty: string,
defaultValue = '',
): string {
let styleValue: string | null = 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 (
$isRangeSelection(selection) &&
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 ($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.
*/
export function INTERNAL_$isBlock(node: LexicalNode): node is ElementNode {
if ($isDecoratorNode(node)) {
return false;
}
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;
}
export function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
node: LexicalNode,
predicate: (ancestor: LexicalNode) => ancestor is NodeType,
) {
let parent = node;
while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
parent = parent.getParentOrThrow();
}
return predicate(parent) ? parent : null;
}