diff --git a/packages/lexical-history/src/__tests__/unit/LexicalHistory.test.tsx b/packages/lexical-history/src/__tests__/unit/LexicalHistory.test.tsx index 0cef7f59210..902c6d24bd1 100644 --- a/packages/lexical-history/src/__tests__/unit/LexicalHistory.test.tsx +++ b/packages/lexical-history/src/__tests__/unit/LexicalHistory.test.tsx @@ -44,49 +44,52 @@ import {createRoot, Root} from 'react-dom/client'; import * as ReactTestUtils from 'shared/react-test-utils'; type SerializedCustomTextNode = Spread< - {type: ReturnType; classes: string[]}, + {type: ReturnType; classList: string[]}, SerializedTextNode >; class CustomTextNode extends TextNode { ['constructor']!: KlassConstructor; - __classes: Set; + __classlist: Set; constructor(text: string, classes: Iterable, key?: NodeKey) { super(text, key); - this.__classes = new Set(classes); + this.__classlist = new Set(classes); } static getType(): 'custom-text' { return 'custom-text'; } static clone(node: CustomTextNode): CustomTextNode { - return new CustomTextNode(node.__text, node.__classes, node.__key); + return new CustomTextNode(node.__text, node.__classlist, node.__key); } addClass(className: string): this { const self = this.getWritable(); - self.__classes.add(className); + self.__classlist.add(className); return self; } removeClass(className: string): this { const self = this.getWritable(); - self.__classes.delete(className); + self.__classlist.delete(className); return self; } setClasses(classes: Iterable): this { const self = this.getWritable(); - self.__classes = new Set(classes); + self.__classlist = new Set(classes); return self; } - getClasses(): ReadonlySet { - return this.getLatest().__classes; + getClassList(): ReadonlySet { + return this.getLatest().__classlist; } - static importJSON({text, classes}: SerializedCustomTextNode): CustomTextNode { - return $createCustomTextNode(text, classes); + static importJSON({ + text, + classList, + }: SerializedCustomTextNode): CustomTextNode { + return $createCustomTextNode(text, classList); } exportJSON(): SerializedCustomTextNode { return { ...super.exportJSON(), - classes: Array.from(this.getClasses()), + classList: Array.from(this.getClassList()), type: this.constructor.getType(), }; } diff --git a/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts b/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts index 01390ed7180..386984258a9 100644 --- a/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts +++ b/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts @@ -43,6 +43,7 @@ import { TestDecoratorNode, } from 'lexical/src/__tests__/utils'; +import {$forEachSelectedTextNode} from '../../lexical-node'; import {$setAnchorPoint, $setFocusPoint} from '../utils'; Range.prototype.getBoundingClientRect = function (): DOMRect { @@ -3171,3 +3172,84 @@ describe('$patchStyleText', () => { }); }); }); + +describe('classes property', () => { + test('can mutate classes using $forEachSelectedTextNode', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + + await editor.update(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + const text = $createTextNode('first').setFormat('bold'); + paragraph.append(text); + + const textSecond = $createTextNode('second'); + paragraph.append(textSecond); + + $setAnchorPoint({ + key: text.getKey(), + offset: 'fir'.length, + type: 'text', + }); + + $setFocusPoint({ + key: textSecond.getKey(), + offset: 'sec'.length, + type: 'text', + }); + + $forEachSelectedTextNode((textNode) => { + textNode.setClass('bg', 'red'); + textNode.setClass('active', true); + textNode.setClass('highlight', 'yellow'); + textNode.setClass('disabled', false); + }); + }); + + expect(element.innerHTML).toBe( + '

' + + 'fir' + + 'st' + + 'sec' + + 'ond' + + '

', + ); + }); + test('exportJSON', async () => { + const editor = createTestEditor(); + const element = document.createElement('div'); + editor.setRootElement(element); + const getSerializedParagraph = (_editor: LexicalEditor) => { + return _editor.getEditorState().toJSON().root.children[0]; + }; + let p: ParagraphNode; + + await editor.update(() => { + p = $createParagraphNode(); + $getRoot().append(p); + }); + expect('classes' in getSerializedParagraph(editor)).toBe(false); + + // should ignore false, numbers or undefined + await editor.update(() => { + p.setClass('bg', 'red'); + p.setClass('active', true); + p.setClass('highlight', false); + }); + expect('classes' in getSerializedParagraph(editor)).toBe(true); + expect(getSerializedParagraph(editor).classes).toStrictEqual({ + active: true, + bg: 'red', + }); + + // should not export classes if empty + await editor.update(() => { + p.setClass('bg', false); + p.setClass('active', false); + }); + expect('classes' in getSerializedParagraph(editor)).toBe(false); + }); +}); diff --git a/packages/lexical-selection/src/index.ts b/packages/lexical-selection/src/index.ts index d901ab4d4d9..8f83b573279 100644 --- a/packages/lexical-selection/src/index.ts +++ b/packages/lexical-selection/src/index.ts @@ -8,6 +8,7 @@ import { $addNodeStyle, + $forEachSelectedTextNode, $isAtNodeEnd, $patchStyleText, $sliceSelectedTextNodeContent, @@ -35,6 +36,7 @@ export { } from 'lexical'; export { $addNodeStyle, + $forEachSelectedTextNode, $isAtNodeEnd, $patchStyleText, $sliceSelectedTextNodeContent, diff --git a/packages/lexical-selection/src/lexical-node.ts b/packages/lexical-selection/src/lexical-node.ts index 32b7b0b802e..22074d1fd6e 100644 --- a/packages/lexical-selection/src/lexical-node.ts +++ b/packages/lexical-selection/src/lexical-node.ts @@ -10,6 +10,7 @@ import { $getCharacterOffsets, $getNodeByKey, $getPreviousSelection, + $getSelection, $isElementNode, $isRangeSelection, $isRootNode, @@ -288,23 +289,30 @@ export function $patchStyleText( ) => string) >, ): void { - const selectedNodes = selection.getNodes(); - const selectedNodesLength = selectedNodes.length; - const anchorAndFocus = selection.getStartEndPoints(); - if (anchorAndFocus === null) { + if (selection.isCollapsed() && $isRangeSelection(selection)) { + $patchStyle(selection, patch); + } else { + $forEachSelectedTextNode((textNode) => { + $patchStyle(textNode, patch); + }); + } +} + +export function $forEachSelectedTextNode( + fn: (textNode: TextNode) => void, +): void { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { return; } - const [anchor, focus] = anchorAndFocus; + const selectedNodes = selection.getNodes(); + const selectedNodesLength = selectedNodes.length; + const {anchor, focus} = selection; const lastIndex = selectedNodesLength - 1; let firstNode = selectedNodes[0]; let lastNode = selectedNodes[lastIndex]; - if (selection.isCollapsed() && $isRangeSelection(selection)) { - $patchStyle(selection, patch); - return; - } - const firstNodeText = firstNode.getTextContent(); const firstNodeTextLength = firstNodeText.length; const focusOffset = focus.offset; @@ -355,14 +363,14 @@ export function $patchStyleText( $isTokenOrSegmented(firstNode) || (startOffset === 0 && endOffset === firstNodeTextLength) ) { - $patchStyle(firstNode, patch); + fn(firstNode); 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); + fn(replacement); replacement.select(0, endOffset - startOffset); } } // multiple nodes selected. @@ -383,7 +391,7 @@ export function $patchStyleText( } } - $patchStyle(firstNode as TextNode, patch); + fn(firstNode as TextNode); } if ($isTextNode(lastNode) && lastNode.canHaveFormat()) { @@ -404,7 +412,7 @@ export function $patchStyleText( } if (endOffset !== 0 || endType === 'element') { - $patchStyle(lastNode as TextNode, patch); + fn(lastNode as TextNode); } } @@ -420,7 +428,7 @@ export function $patchStyleText( selectedNodeKey !== lastNode.getKey() && !selectedNode.isToken() ) { - $patchStyle(selectedNode, patch); + fn(selectedNode as TextNode); } } } diff --git a/packages/lexical-website/docs/concepts/node-replacement.md b/packages/lexical-website/docs/concepts/node-replacement.md index 1aafbc50595..d157524d451 100644 --- a/packages/lexical-website/docs/concepts/node-replacement.md +++ b/packages/lexical-website/docs/concepts/node-replacement.md @@ -1,12 +1,46 @@ +# Node Customization -# Node Overrides / Node Replacements +Originally the only way to customize nodes was using the node replacement API. Recently we have introduced a second way with the `classes` property which is easier to implement and is sufficient for most cases. + +## Classes Property (New) + +Most of the time when users want to customize a node they just want to add a property to it, which ends up being reflected as a class in the dom element. + +To satisfy that need we have introduced two new methods to all nodes: `getClasses` and `setClass`. + +```ts +export function CoolRedPlugin() { + const [editor] = useLexicalComposerContext(); + + return ( + + ); +} +``` + +## Node Overrides / Node Replacements Some of the most commonly used Lexical Nodes are owned and maintained by the core library. For example, ParagraphNode, HeadingNode, QuoteNode, List(Item)Node etc - these are all provided by Lexical packages, which provides an easier out-of-the-box experience for some editor features, but makes it difficult to override their behavior. For instance, if you wanted to change the behavior of ListNode, you would typically extend the class and override the methods. However, how would you tell Lexical to use *your* ListNode subclass in the ListPlugin instead of using the core ListNode? That's where Node Overrides can help. Node Overrides allow you to replace all instances of a given node in your editor with instances of a different node class. This can be done through the nodes array in the Editor config: -``` +```ts const editorConfig = { ... nodes=[ diff --git a/packages/lexical/src/LexicalEditorState.ts b/packages/lexical/src/LexicalEditorState.ts index aa14f45d87b..f31144fb978 100644 --- a/packages/lexical/src/LexicalEditorState.ts +++ b/packages/lexical/src/LexicalEditorState.ts @@ -57,6 +57,9 @@ function exportNodeToJSON( node: LexicalNode, ): SerializedNode { const serializedNode = node.exportJSON(); + if (node.__classes && Object.keys(node.__classes).length > 0) { + serializedNode.classes = node.__classes; + } const nodeClass = node.constructor; if (serializedNode.type !== nodeClass.getType()) { diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index 0aa1d1ca487..67ee2b77b2d 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -54,6 +54,7 @@ export type NodeMap = Map; export type SerializedLexicalNode = { type: string; version: number; + classes?: ReadonlyClasses; }; /** @internal */ @@ -170,6 +171,8 @@ export type DOMExportOutput = { }; export type NodeKey = string; +export type MutableClasses = {[classSuffix: string]: true | string}; +export type ReadonlyClasses = {readonly [classSuffix: string]: true | string}; export class LexicalNode { // Allow us to look up the type including static props @@ -185,6 +188,53 @@ export class LexicalNode { __prev: null | NodeKey; /** @internal */ __next: null | NodeKey; + /** + * Don't use this directly, use `this.getClasses()` and `this.mutateClasses()` instead + * @internal + */ + __classes?: ReadonlyClasses; + + /** + * Returns an object of classes in the form of `prefix-suffix` for string values, or just `prefix` for true boolean values. + * @example + * const exampleClassesObject = { + * bg: 'red', // the node is rendered with class `bg-red` + * text: 'green', // node is rendered with class `text-green`, + * active: true, // node is rendered with class `active`, + * } + * // Resulting classes: 'bg-red', 'text-green', and 'active' + * + * @returns The classes object. + */ + getClasses() { + const self = this.getLatest(); + return self.__classes; + } + + /** + * Sets a class based on the provided key and value. + * + * @param key - The class key to set or modify. + * @param value - The class value. Possible options: + * - `true`: add the class if it doesn't exist. + * - `false`: remove the class if it exists. + * - `string`: Adds a class in the format `key-value` (e.g., `bg-blue`), useful for overwriting existing classes with the same key. + * + * @example + * node.setClass('active', true); // Adds the class `active`, replacing the one with the key `active` if it exists. + * node.setClass('active', false); // Removes any class with the key `active` (e.g., `active` or `active-blue`). + * node.setClass('bg', 'blue'); // Adds the class `bg-blue` + */ + setClass(key: string, value: boolean | string) { + const self = this.getWritable(); + const classes = {...self.__classes}; + if (value === false) { + delete classes[key]; + } else { + classes[key] = value; + } + self.__classes = classes; + } // Flow doesn't support abstract classes unfortunately, so we can't _force_ // subclasses of Node to implement statics. All subclasses of Node should have @@ -273,6 +323,7 @@ export class LexicalNode { this.__parent = prevNode.__parent; this.__next = prevNode.__next; this.__prev = prevNode.__prev; + this.__classes = prevNode.__classes; } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -858,6 +909,15 @@ export class LexicalNode { * */ exportDOM(editor: LexicalEditor): DOMExportOutput { const element = this.createDOM(editor._config, editor); + if (this.__classes) { + Object.entries(this.__classes).forEach(([classPrefix, classSufix]) => { + if (typeof classSufix === 'string') { + element.classList.add(`${classPrefix}-${classSufix}`); + } else if (typeof classSufix === 'boolean' && classSufix) { + element.classList.add(classPrefix); + } + }); + } return {element}; } diff --git a/packages/lexical/src/LexicalNormalization.ts b/packages/lexical/src/LexicalNormalization.ts index 59a7be64489..957870d9501 100644 --- a/packages/lexical/src/LexicalNormalization.ts +++ b/packages/lexical/src/LexicalNormalization.ts @@ -22,10 +22,16 @@ function $canSimpleTextNodesBeMerged( const node2Mode = node2.__mode; const node2Format = node2.__format; const node2Style = node2.__style; + const node1Classes = node1.__classes || {}; + const node2Classes = node2.__classes || {}; return ( (node1Mode === null || node1Mode === node2Mode) && (node1Format === null || node1Format === node2Format) && - (node1Style === null || node1Style === node2Style) + (node1Style === null || node1Style === node2Style) && + Object.keys(node1Classes).length === Object.keys(node2Classes).length && + Object.keys(node1Classes).every( + (key) => node1Classes[key] === node2Classes[key], + ) ); } diff --git a/packages/lexical/src/LexicalReconciler.ts b/packages/lexical/src/LexicalReconciler.ts index 6fa946f61d6..216db70ea48 100644 --- a/packages/lexical/src/LexicalReconciler.ts +++ b/packages/lexical/src/LexicalReconciler.ts @@ -173,6 +173,15 @@ function $createNode(key: NodeKey, slot: ElementDOMSlot | null): HTMLElement { invariant(false, 'createNode: node does not exist in nodeMap'); } const dom = node.createDOM(activeEditorConfig, activeEditor); + if (node.__classes) { + Object.entries(node.__classes).forEach(([classPrefix, classSufix]) => { + if (typeof classSufix === 'string') { + dom.classList.add(`${classPrefix}-${classSufix}`); + } else if (typeof classSufix === 'boolean' && classSufix) { + dom.classList.add(classPrefix); + } + }); + } storeDOMWithKey(key, dom, activeEditor); // This helps preserve the text, and stops spell check tools from @@ -632,8 +641,16 @@ function $reconcileNode( ); } + const nextClasses = nextNode.__classes || {}; + const prevClasses = prevNode.__classes || {}; + const classesChanged = !( + Object.keys(nextClasses).length === Object.keys(prevClasses).length && + Object.keys(nextClasses).every( + (_key) => nextClasses[_key] === prevClasses[_key], + ) + ); // Update node. If it returns true, we need to unmount and re-create the node - if (nextNode.updateDOM(prevNode, dom, activeEditorConfig)) { + if (nextNode.updateDOM(prevNode, dom, activeEditorConfig) || classesChanged) { const replacementDOM = $createNode(key, null); if (parentDOM === null) { diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index fb7a62dbea0..1de9a01ffaa 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -1180,6 +1180,7 @@ export class RangeSelection implements BaseSelection { } } + // TO-DO: Migrate this method to the new utility function $forEachSelectedTextNode (share similar logic) /** * Applies the provided format to the TextNodes in the Selection, splitting or * merging nodes as necessary. diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index 11296a2be5a..64a49e470b4 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -7,7 +7,11 @@ */ import type {SerializedEditorState} from './LexicalEditorState'; -import type {LexicalNode, SerializedLexicalNode} from './LexicalNode'; +import type { + LexicalNode, + ReadonlyClasses, + SerializedLexicalNode, +} from './LexicalNode'; import invariant from 'shared/invariant'; @@ -338,6 +342,7 @@ type InternalSerializedNode = { children?: Array; type: string; version: number; + classes?: ReadonlyClasses; }; export function $parseSerializedNode( @@ -374,6 +379,9 @@ function $parseSerializedNodeImpl< } const node = nodeClass.importJSON(serializedNode); + if (serializedNode.classes) { + node.__classes = serializedNode.classes; + } const children = serializedNode.children; if ($isElementNode(node) && Array.isArray(children)) { diff --git a/packages/lexical/src/nodes/LexicalTextNode.ts b/packages/lexical/src/nodes/LexicalTextNode.ts index fad639a1c72..a9f4c618b78 100644 --- a/packages/lexical/src/nodes/LexicalTextNode.ts +++ b/packages/lexical/src/nodes/LexicalTextNode.ts @@ -947,6 +947,7 @@ export class TextNode extends LexicalNode { let writableNode; const format = self.getFormat(); const style = self.getStyle(); + const classes = self.getClasses(); const detail = self.__detail; let hasReplacedSelf = false; @@ -955,6 +956,7 @@ export class TextNode extends LexicalNode { writableNode = $createTextNode(firstPart); writableNode.__format = format; writableNode.__style = style; + writableNode.__classes = classes; writableNode.__detail = detail; hasReplacedSelf = true; } else { @@ -976,6 +978,7 @@ export class TextNode extends LexicalNode { const sibling = $createTextNode(part).getWritable(); sibling.__format = format; sibling.__style = style; + sibling.__classes = classes; sibling.__detail = detail; const siblingKey = sibling.__key; const nextTextSize = textSize + partSize;