@lexical/mark
Version:
This package contains helpers and nodes for wrapping content in marks for Lexical.
172 lines (157 loc) • 5.29 kB
text/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 {
$createRangeSelection,
$isDecoratorNode,
$isElementNode,
$isTextNode,
defineExtension,
type ElementNode,
type LexicalNode,
type RangeSelection,
type TextNode,
} from 'lexical';
import {
$createMarkNode,
$isMarkNode,
MarkNode,
type SerializedMarkNode,
} from './MarkNode';
export function $unwrapMarkNode(node: MarkNode): void {
const children = node.getChildren();
let target = null;
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (target === null) {
node.insertBefore(child);
} else {
target.insertAfter(child);
}
target = child;
}
node.remove();
}
export function $wrapSelectionInMarkNode(
selection: RangeSelection,
isBackward: boolean,
id: string,
createNode?: (ids: string[]) => MarkNode,
): void {
// Force a forwards selection since append is used, ignore the argument.
// A new selection is used to avoid side-effects of flipping the given
// selection
const forwardSelection = $createRangeSelection();
const [startPoint, endPoint] = selection.isBackward()
? [selection.focus, selection.anchor]
: [selection.anchor, selection.focus];
forwardSelection.anchor.set(
startPoint.key,
startPoint.offset,
startPoint.type,
);
forwardSelection.focus.set(endPoint.key, endPoint.offset, endPoint.type);
let currentNodeParent: ElementNode | null | undefined;
let lastCreatedMarkNode: MarkNode | undefined;
// Note that extract will split text nodes at the boundaries
const nodes = forwardSelection.extract();
// We only want wrap adjacent text nodes, line break nodes
// and inline element nodes. For decorator nodes and block
// element nodes, we step out of their boundary and start
// again after, if there are more nodes.
for (const node of nodes) {
if (
$isElementNode(lastCreatedMarkNode) &&
lastCreatedMarkNode.isParentOf(node)
) {
// If the current node is a child of the last created mark node, there is nothing to do here
continue;
}
let targetNode: LexicalNode | null = null;
if ($isTextNode(node)) {
// Case 1: The node is a text node and we can include it
targetNode = node;
} else if ($isMarkNode(node)) {
// Case 2: the node is a mark node and we can ignore it as a target,
// moving on to its children. Note that when we make a mark inside
// another mark, it may ultimately be unnested by a call to
// `registerNestedElementResolver<MarkNode>` somewhere else in the
// codebase.
continue;
} else if (
($isElementNode(node) || $isDecoratorNode(node)) &&
node.isInline()
) {
// Case 3: inline element/decorator nodes can be added in their entirety
// to the new mark
targetNode = node;
}
if (targetNode !== null) {
// Now that we have a target node for wrapping with a mark, we can run
// through special cases.
if (targetNode && targetNode.is(currentNodeParent)) {
// The current node is a child of the target node to be wrapped, there
// is nothing to do here.
continue;
}
const parentNode = targetNode.getParent();
if (parentNode == null || !parentNode.is(currentNodeParent)) {
// If the parent node is not the current node's parent node, we can
// clear the last created mark node.
lastCreatedMarkNode = undefined;
}
currentNodeParent = parentNode;
if (lastCreatedMarkNode === undefined) {
// If we don't have a created mark node, we can make one
const createMarkNode = createNode || $createMarkNode;
lastCreatedMarkNode = createMarkNode([id]);
targetNode.insertBefore(lastCreatedMarkNode);
}
// Add the target node to be wrapped in the latest created mark node
lastCreatedMarkNode.append(targetNode);
} else {
// If we don't have a target node to wrap we can clear our state and
// continue on with the next node
currentNodeParent = undefined;
lastCreatedMarkNode = undefined;
}
}
// Make selection collapsed at the end
if ($isElementNode(lastCreatedMarkNode)) {
if (isBackward) {
lastCreatedMarkNode.selectStart();
} else {
lastCreatedMarkNode.selectEnd();
}
}
}
export function $getMarkIDs(node: TextNode, offset: number): null | string[] {
let currentNode: LexicalNode | null = node;
while (currentNode !== null) {
if ($isMarkNode(currentNode)) {
return currentNode.getIDs();
} else if (
$isTextNode(currentNode) &&
offset === currentNode.getTextContentSize()
) {
const nextSibling = currentNode.getNextSibling();
if ($isMarkNode(nextSibling)) {
return nextSibling.getIDs();
}
}
currentNode = currentNode.getParent();
}
return null;
}
/**
* Configures {@link MarkNode}
*/
export const MarkExtension = /* @__PURE__ */ defineExtension({
name: '@lexical/mark',
nodes: () => [MarkNode],
});
export {$createMarkNode, $isMarkNode, MarkNode, SerializedMarkNode};