Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[lexical][@lexical/selection] Feature: add a generic classes property to all nodes for easier customization #6929

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6323262
add __classes, getClasses and mutateClasses
GermanJablo Dec 5, 2024
7bb7820
amend
GermanJablo Dec 5, 2024
bede9a9
fix errors
GermanJablo Dec 5, 2024
264b87b
fix $canSimpleTextNodesBeMerged and exportDOM
GermanJablo Dec 6, 2024
f290e0c
add test
GermanJablo Dec 6, 2024
b9fa298
rename $mutateSelectedTextNodes to $forEachSelectedTextNode
GermanJablo Dec 6, 2024
0ff2383
add docs
GermanJablo Dec 6, 2024
10454ee
add tests for exportJSON
GermanJablo Dec 6, 2024
5c554d2
test name
GermanJablo Dec 6, 2024
b6e7525
spread classes
GermanJablo Dec 9, 2024
38764d4
fix updateDOM
GermanJablo Dec 9, 2024
a367219
comment typo
GermanJablo Dec 9, 2024
df4f570
remove comment
GermanJablo Dec 9, 2024
ae2c32a
replace mutateClasses with setClass
GermanJablo Dec 10, 2024
727d47d
update docs
GermanJablo Dec 10, 2024
00c711e
revert getClasses, allow empty object classes, move clone logic to af…
GermanJablo Dec 10, 2024
3da33aa
fix afterCloneFrom
GermanJablo Dec 10, 2024
110e591
Update packages/lexical/src/LexicalNode.ts
GermanJablo Dec 11, 2024
b96a12e
Update packages/lexical/src/LexicalNode.ts
GermanJablo Dec 11, 2024
a2fb1df
Update packages/lexical/src/LexicalEditorState.ts
GermanJablo Dec 11, 2024
66d0679
revert doc url name changed
GermanJablo Dec 11, 2024
2908b29
node.__classes check is actually needed as long as the property is op…
GermanJablo Dec 11, 2024
b8e09bb
|| {} is actually needed as long as the property is optional
GermanJablo Dec 11, 2024
0372f9e
revert || {} is actually needed as long as the property is optional. …
GermanJablo Dec 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,49 +44,52 @@ import {createRoot, Root} from 'react-dom/client';
import * as ReactTestUtils from 'shared/react-test-utils';

type SerializedCustomTextNode = Spread<
{type: ReturnType<typeof CustomTextNode.getType>; classes: string[]},
{type: ReturnType<typeof CustomTextNode.getType>; classList: string[]},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes in this file are only a rename to avoid collisions and can be ignored.
I have also wondered what would happen if a user has extended a node by adding the classes property to it. Perhaps we could reserve a prefix and use something like $classes instead?

SerializedTextNode
>;

class CustomTextNode extends TextNode {
['constructor']!: KlassConstructor<typeof CustomTextNode>;

__classes: Set<string>;
__classlist: Set<string>;
constructor(text: string, classes: Iterable<string>, 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<string>): this {
const self = this.getWritable();
self.__classes = new Set(classes);
self.__classlist = new Set(classes);
return self;
}
getClasses(): ReadonlySet<string> {
return this.getLatest().__classes;
getClassList(): ReadonlySet<string> {
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(),
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -3171,3 +3172,96 @@ 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.mutateClasses((classes) => {
classes.bg = 'red';
classes.active = true;
classes.highlight = 'yellow';
// @ts-expect-error - just testing what happens if someone ignores the type
classes.disabled = false;
});
});
});

expect(element.innerHTML).toBe(
'<p dir="ltr">' +
'<strong data-lexical-text="true">fir</strong>' +
'<strong class="bg-red active highlight-yellow" data-lexical-text="true">st</strong>' +
'<span class="bg-red active highlight-yellow" data-lexical-text="true">sec</span>' +
'<span data-lexical-text="true">ond</span>' +
'</p>',
);
});
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.mutateClasses((classes) => {
classes.bg = 'red';
classes.active = true;
// @ts-expect-error - just testing what happens if someone ignores the type
classes.highlight = false;
// @ts-expect-error - just testing what happens if someone ignores the type
classes.someNumber = 4;
// @ts-ignore
classes.somethingUndefined = undefined;
});
});
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.mutateClasses((classes) => {
delete classes.bg;
delete classes.active;
});
});
expect('classes' in getSerializedParagraph(editor)).toBe(false);
});
});
2 changes: 2 additions & 0 deletions packages/lexical-selection/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import {
$addNodeStyle,
$forEachSelectedTextNode,
$isAtNodeEnd,
$patchStyleText,
$sliceSelectedTextNodeContent,
Expand Down Expand Up @@ -35,6 +36,7 @@ export {
} from 'lexical';
export {
$addNodeStyle,
$forEachSelectedTextNode,
$isAtNodeEnd,
$patchStyleText,
$sliceSelectedTextNodeContent,
Expand Down
43 changes: 28 additions & 15 deletions packages/lexical-selection/src/lexical-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
$getCharacterOffsets,
$getNodeByKey,
$getPreviousSelection,
$getSelection,
$isElementNode,
$isRangeSelection,
$isRootNode,
Expand Down Expand Up @@ -288,23 +289,35 @@ 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);
});
}
}

/**
* Updates styles on selected TextNodes, splitting nodes if partially selected.
* @param selection - The selected node(s) to update.
* @param fn - The function to apply to the selected TextNodes.
*/
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;
Expand Down Expand Up @@ -355,14 +368,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.
Expand All @@ -383,7 +396,7 @@ export function $patchStyleText(
}
}

$patchStyle(firstNode as TextNode, patch);
fn(firstNode as TextNode);
}

if ($isTextNode(lastNode) && lastNode.canHaveFormat()) {
Expand All @@ -404,7 +417,7 @@ export function $patchStyleText(
}

if (endOffset !== 0 || endType === 'element') {
$patchStyle(lastNode as TextNode, patch);
fn(lastNode as TextNode);
}
}

Expand All @@ -420,7 +433,7 @@ export function $patchStyleText(
selectedNodeKey !== lastNode.getKey() &&
!selectedNode.isToken()
) {
$patchStyle(selectedNode, patch);
fn(selectedNode as TextNode);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,48 @@

# 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 `mutateClasses`.

```ts
export function CoolRedPlugin() {
const [editor] = useLexicalComposerContext();

return (
<button
onClick={() => {
editor.update(() => {
$forEachSelectedTextNode((textNode) => {
// Allows mutation of the classes object where the key-value pairs follow the
// format prefix-suffix for string values, or just prefix for true boolean values.
textNode.mutateClasses((classes) => {
classes.bg = 'red'; // adds the class bg-red
// Perhaps you don't want to allow the same node to have
// both text and background color defined at the same time...
delete classes.text; // ...so here you remove the class text-[color].
classes.cool = true; // adds the class cool (true values don't add a suffix)
});
});
});
}}>
Make text red and cool
</button>
);
}
```

## 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=[
Expand Down
2 changes: 1 addition & 1 deletion packages/lexical-website/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const sidebars = {
items: [
'concepts/editor-state',
'concepts/nodes',
'concepts/node-replacement',
'concepts/node-customization',
GermanJablo marked this conversation as resolved.
Show resolved Hide resolved
'concepts/listeners',
'concepts/transforms',
'concepts/commands',
Expand Down
11 changes: 11 additions & 0 deletions packages/lexical/src/LexicalEditorState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ function exportNodeToJSON<SerializedNode extends SerializedLexicalNode>(
node: LexicalNode,
): SerializedNode {
const serializedNode = node.exportJSON();
if (node.__classes) {
const classes = Object.fromEntries(
Object.entries(node.__classes).filter(
([_, value]: [string, unknown]) =>
typeof value === 'string' || (typeof value === 'boolean' && value),
),
);
if (Object.keys(classes).length > 0) {
serializedNode.classes = classes;
}
}
const nodeClass = node.constructor;

if (serializedNode.type !== nodeClass.getType()) {
Expand Down
Loading
Loading