UNPKG

@lexical/mark

Version:

This package contains helpers and nodes for wrapping content in marks for Lexical.

262 lines (250 loc) 8.47 kB
/** * 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'); var utils = require('@lexical/utils'); /** * 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 NO_IDS = []; /** @noInheritDoc */ class MarkNode extends lexical.ElementNode { /** @internal */ static getType() { return 'mark'; } static clone(node) { return new MarkNode(node.__ids, node.__key); } static importDOM() { return null; } static importJSON(serializedNode) { return $createMarkNode().updateFromJSON(serializedNode); } updateFromJSON(serializedNode) { return super.updateFromJSON(serializedNode).setIDs(serializedNode.ids); } exportJSON() { return { ...super.exportJSON(), ids: this.getIDs() }; } constructor(ids = NO_IDS, key) { super(key); this.__ids = ids; } createDOM(config) { const element = document.createElement('mark'); utils.addClassNamesToElement(element, config.theme.mark); if (this.__ids.length > 1) { utils.addClassNamesToElement(element, config.theme.markOverlap); } return element; } updateDOM(prevNode, element, config) { const prevIDs = prevNode.__ids; const nextIDs = this.__ids; const prevIDsCount = prevIDs.length; const nextIDsCount = nextIDs.length; const overlapTheme = config.theme.markOverlap; if (prevIDsCount !== nextIDsCount) { if (prevIDsCount === 1) { if (nextIDsCount === 2) { utils.addClassNamesToElement(element, overlapTheme); } } else if (nextIDsCount === 1) { utils.removeClassNamesFromElement(element, overlapTheme); } } return false; } hasID(id) { return this.getIDs().includes(id); } getIDs() { return Array.from(this.getLatest().__ids); } setIDs(ids) { const self = this.getWritable(); self.__ids = ids; return self; } addID(id) { const self = this.getWritable(); return self.__ids.includes(id) ? self : self.setIDs([...self.__ids, id]); } deleteID(id) { const self = this.getWritable(); const idx = self.__ids.indexOf(id); if (idx === -1) { return self; } const ids = Array.from(self.__ids); ids.splice(idx, 1); return self.setIDs(ids); } insertNewAfter(selection, restoreSelection = true) { const markNode = $createMarkNode(this.__ids); this.insertAfter(markNode, restoreSelection); return markNode; } canInsertTextBefore() { return false; } canInsertTextAfter() { return false; } canBeEmpty() { return false; } isInline() { return true; } extractWithChild(child, selection, destination) { if (!lexical.$isRangeSelection(selection) || destination === 'html') { return false; } const anchor = selection.anchor; const focus = selection.focus; const anchorNode = anchor.getNode(); const focusNode = focus.getNode(); const isBackward = selection.isBackward(); const selectionLength = isBackward ? anchor.offset - focus.offset : focus.offset - anchor.offset; return this.isParentOf(anchorNode) && this.isParentOf(focusNode) && this.getTextContent().length === selectionLength; } excludeFromCopy(destination) { return destination !== 'clone'; } } function $createMarkNode(ids = NO_IDS) { return lexical.$applyNodeReplacement(new MarkNode(ids)); } function $isMarkNode(node) { return node instanceof MarkNode; } /** * 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 $unwrapMarkNode(node) { 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(); } function $wrapSelectionInMarkNode(selection, isBackward, id, createNode) { // 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 = lexical.$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; let lastCreatedMarkNode; // 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 (lexical.$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 = null; if (lexical.$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 ((lexical.$isElementNode(node) || lexical.$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 (lexical.$isElementNode(lastCreatedMarkNode)) { // eslint-disable-next-line no-unused-expressions isBackward ? lastCreatedMarkNode.selectStart() : lastCreatedMarkNode.selectEnd(); } } function $getMarkIDs(node, offset) { let currentNode = node; while (currentNode !== null) { if ($isMarkNode(currentNode)) { return currentNode.getIDs(); } else if (lexical.$isTextNode(currentNode) && offset === currentNode.getTextContentSize()) { const nextSibling = currentNode.getNextSibling(); if ($isMarkNode(nextSibling)) { return nextSibling.getIDs(); } } currentNode = currentNode.getParent(); } return null; } exports.$createMarkNode = $createMarkNode; exports.$getMarkIDs = $getMarkIDs; exports.$isMarkNode = $isMarkNode; exports.$unwrapMarkNode = $unwrapMarkNode; exports.$wrapSelectionInMarkNode = $wrapSelectionInMarkNode; exports.MarkNode = MarkNode;