[Breaking Change][lexical] Feature: Add updateFromJSON and move more textFormat/textStyle to ElementNode (#6970)

This commit is contained in:
Bob Ippolito
2025-01-01 12:48:12 -08:00
committed by GitHub
parent aaa9009950
commit 7c21d4ff39
66 changed files with 810 additions and 579 deletions

View File

@ -10,6 +10,7 @@ import type {
EditorConfig,
EditorThemeClasses,
LexicalNode,
LexicalUpdateJSON,
LineBreakNode,
NodeKey,
SerializedTextNode,
@ -97,7 +98,7 @@ export class CodeHighlightNode extends TextNode {
__highlightType: string | null | undefined;
constructor(
text: string,
text: string = '',
highlightType?: string | null | undefined,
key?: NodeKey,
) {
@ -122,6 +123,12 @@ export class CodeHighlightNode extends TextNode {
return self.__highlightType;
}
setHighlightType(highlightType?: string | null | undefined): this {
const self = this.getWritable();
self.__highlightType = highlightType || undefined;
return self;
}
canHaveFormat(): boolean {
return false;
}
@ -160,15 +167,15 @@ export class CodeHighlightNode extends TextNode {
static importJSON(
serializedNode: SerializedCodeHighlightNode,
): CodeHighlightNode {
const node = $createCodeHighlightNode(
serializedNode.text,
serializedNode.highlightType,
);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
return $createCodeHighlightNode().updateFromJSON(serializedNode);
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedCodeHighlightNode>,
): this {
return super
.updateFromJSON(serializedNode)
.setHighlightType(serializedNode.highlightType);
}
exportJSON(): SerializedCodeHighlightNode {
@ -205,7 +212,7 @@ function getHighlightThemeClass(
}
export function $createCodeHighlightNode(
text: string,
text: string = '',
highlightType?: string | null | undefined,
): CodeHighlightNode {
return $applyNodeReplacement(new CodeHighlightNode(text, highlightType));

View File

@ -14,6 +14,7 @@ import type {
EditorConfig,
LexicalEditor,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
ParagraphNode,
RangeSelection,
@ -88,7 +89,7 @@ export class CodeNode extends ElementNode {
constructor(language?: string | null | undefined, key?: NodeKey) {
super(key);
this.__language = language;
this.__language = language || undefined;
this.__isSyntaxHighlightSupported = isLanguageSupportedByPrism(language);
}
@ -212,11 +213,13 @@ export class CodeNode extends ElementNode {
}
static importJSON(serializedNode: SerializedCodeNode): CodeNode {
const node = $createCodeNode(serializedNode.language);
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
return $createCodeNode().updateFromJSON(serializedNode);
}
updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedCodeNode>): this {
return super
.updateFromJSON(serializedNode)
.setLanguage(serializedNode.language);
}
exportJSON(): SerializedCodeNode {
@ -317,11 +320,12 @@ export class CodeNode extends ElementNode {
return true;
}
setLanguage(language: string): void {
setLanguage(language: string | null | undefined): this {
const writable = this.getWritable();
writable.__language = language;
writable.__language = language || undefined;
writable.__isSyntaxHighlightSupported =
isLanguageSupportedByPrism(language);
return writable;
}
getLanguage(): string | null | undefined {

View File

@ -6,12 +6,7 @@
*
*/
import type {
EditorConfig,
LexicalNode,
NodeKey,
SerializedTextNode,
} from 'lexical';
import type {EditorConfig, LexicalNode, SerializedTextNode} from 'lexical';
import {addClassNamesToElement} from '@lexical/utils';
import {$applyNodeReplacement, TextNode} from 'lexical';
@ -26,10 +21,6 @@ export class HashtagNode extends TextNode {
return new HashtagNode(node.__text, node.__key);
}
constructor(text: string, key?: NodeKey) {
super(text, key);
}
createDOM(config: EditorConfig): HTMLElement {
const element = super.createDOM(config);
addClassNamesToElement(element, config.theme.hashtag);
@ -37,12 +28,7 @@ export class HashtagNode extends TextNode {
}
static importJSON(serializedNode: SerializedTextNode): HashtagNode {
const node = $createHashtagNode(serializedNode.text);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
return $createHashtagNode().updateFromJSON(serializedNode);
}
canInsertTextBefore(): boolean {

View File

@ -13,6 +13,7 @@ import type {
EditorConfig,
LexicalCommand,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
Point,
RangeSelection,
@ -87,7 +88,11 @@ export class LinkNode extends ElementNode {
);
}
constructor(url: string, attributes: LinkAttributes = {}, key?: NodeKey) {
constructor(
url: string = '',
attributes: LinkAttributes = {},
key?: NodeKey,
) {
super(key);
const {target = null, rel = null, title = null} = attributes;
this.__url = url;
@ -162,18 +167,17 @@ export class LinkNode extends ElementNode {
};
}
static importJSON(
serializedNode: SerializedLinkNode | SerializedAutoLinkNode,
): LinkNode {
const node = $createLinkNode(serializedNode.url, {
rel: serializedNode.rel,
target: serializedNode.target,
title: serializedNode.title,
});
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
static importJSON(serializedNode: SerializedLinkNode): LinkNode {
return $createLinkNode().updateFromJSON(serializedNode);
}
updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedLinkNode>): this {
return super
.updateFromJSON(serializedNode)
.setURL(serializedNode.url)
.setRel(serializedNode.rel || null)
.setTarget(serializedNode.target || null)
.setTitle(serializedNode.title || null);
}
sanitizeUrl(url: string): string {
@ -203,36 +207,40 @@ export class LinkNode extends ElementNode {
return this.getLatest().__url;
}
setURL(url: string): void {
setURL(url: string): this {
const writable = this.getWritable();
writable.__url = url;
return writable;
}
getTarget(): null | string {
return this.getLatest().__target;
}
setTarget(target: null | string): void {
setTarget(target: null | string): this {
const writable = this.getWritable();
writable.__target = target;
return writable;
}
getRel(): null | string {
return this.getLatest().__rel;
}
setRel(rel: null | string): void {
setRel(rel: null | string): this {
const writable = this.getWritable();
writable.__rel = rel;
return writable;
}
getTitle(): null | string {
return this.getLatest().__title;
}
setTitle(title: null | string): void {
setTitle(title: null | string): this {
const writable = this.getWritable();
writable.__title = title;
return writable;
}
insertNewAfter(
@ -316,7 +324,7 @@ function $convertAnchorElement(domNode: Node): DOMConversionOutput {
* @returns The LinkNode.
*/
export function $createLinkNode(
url: string,
url: string = '',
attributes?: LinkAttributes,
): LinkNode {
return $applyNodeReplacement(new LinkNode(url, attributes));
@ -347,7 +355,11 @@ export class AutoLinkNode extends LinkNode {
/** Indicates whether the autolink was ever unlinked. **/
__isUnlinked: boolean;
constructor(url: string, attributes: AutoLinkAttributes = {}, key?: NodeKey) {
constructor(
url: string = '',
attributes: AutoLinkAttributes = {},
key?: NodeKey,
) {
super(url, attributes, key);
this.__isUnlinked =
attributes.isUnlinked !== undefined && attributes.isUnlinked !== null
@ -376,7 +388,7 @@ export class AutoLinkNode extends LinkNode {
return this.__isUnlinked;
}
setIsUnlinked(value: boolean) {
setIsUnlinked(value: boolean): this {
const self = this.getWritable();
self.__isUnlinked = value;
return self;
@ -402,16 +414,15 @@ export class AutoLinkNode extends LinkNode {
}
static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
const node = $createAutoLinkNode(serializedNode.url, {
isUnlinked: serializedNode.isUnlinked,
rel: serializedNode.rel,
target: serializedNode.target,
title: serializedNode.title,
});
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
return $createAutoLinkNode().updateFromJSON(serializedNode);
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedAutoLinkNode>,
): this {
return super
.updateFromJSON(serializedNode)
.setIsUnlinked(serializedNode.isUnlinked || false);
}
static importDOM(): null {
@ -456,7 +467,7 @@ export class AutoLinkNode extends LinkNode {
* @returns The LinkNode.
*/
export function $createAutoLinkNode(
url: string,
url: string = '',
attributes?: AutoLinkAttributes,
): AutoLinkNode {
return $applyNodeReplacement(new AutoLinkNode(url, attributes));

View File

@ -15,6 +15,7 @@ import type {
EditorConfig,
EditorThemeClasses,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
ParagraphNode,
RangeSelection,
@ -123,12 +124,16 @@ export class ListItemNode extends ElementNode {
}
static importJSON(serializedNode: SerializedListItemNode): ListItemNode {
const node = $createListItemNode();
node.setChecked(serializedNode.checked);
node.setValue(serializedNode.value);
node.setFormat(serializedNode.format);
node.setDirection(serializedNode.direction);
return node;
return $createListItemNode().updateFromJSON(serializedNode);
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedListItemNode>,
): this {
return super
.updateFromJSON(serializedNode)
.setValue(serializedNode.value)
.setChecked(serializedNode.checked);
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
@ -257,9 +262,10 @@ export class ListItemNode extends ElementNode {
_: RangeSelection,
restoreSelection = true,
): ListItemNode | ParagraphNode {
const newElement = $createListItemNode(
this.__checked == null ? undefined : false,
);
const newElement = $createListItemNode()
.updateFromJSON(this.exportJSON())
.setChecked(this.getChecked() ? false : undefined);
this.insertAfter(newElement, restoreSelection);
return newElement;
@ -310,9 +316,10 @@ export class ListItemNode extends ElementNode {
return self.__value;
}
setValue(value: number): void {
setValue(value: number): this {
const self = this.getWritable();
self.__value = value;
return self;
}
getChecked(): boolean | undefined {
@ -328,13 +335,15 @@ export class ListItemNode extends ElementNode {
return listType === 'check' ? Boolean(self.__checked) : undefined;
}
setChecked(checked?: boolean): void {
setChecked(checked?: boolean): this {
const self = this.getWritable();
self.__checked = checked;
return self;
}
toggleChecked(): void {
this.setChecked(!this.__checked);
toggleChecked(): this {
const self = this.getWritable();
return self.setChecked(!self.__checked);
}
getIndent(): number {

View File

@ -23,6 +23,7 @@ import {
ElementNode,
LexicalEditor,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
SerializedElementNode,
Spread,
@ -69,7 +70,7 @@ export class ListNode extends ElementNode {
return new ListNode(listType, node.__start, node.__key);
}
constructor(listType: ListType, start: number, key?: NodeKey) {
constructor(listType: ListType = 'number', start: number = 1, key?: NodeKey) {
super(key);
const _listType = TAG_TO_LIST_TYPE[listType] || listType;
this.__listType = _listType;
@ -81,10 +82,11 @@ export class ListNode extends ElementNode {
return this.__tag;
}
setListType(type: ListType): void {
setListType(type: ListType): this {
const writable = this.getWritable();
writable.__listType = type;
writable.__tag = type === 'number' ? 'ol' : 'ul';
return writable;
}
getListType(): ListType {
@ -95,6 +97,12 @@ export class ListNode extends ElementNode {
return this.__start;
}
setStart(start: number): this {
const self = this.getWritable();
self.__start = start;
return self;
}
// View
createDOM(config: EditorConfig, _editor?: LexicalEditor): HTMLElement {
@ -143,11 +151,14 @@ export class ListNode extends ElementNode {
}
static importJSON(serializedNode: SerializedListNode): ListNode {
const node = $createListNode(serializedNode.listType, serializedNode.start);
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
return $createListNode().updateFromJSON(serializedNode);
}
updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedListNode>): this {
return super
.updateFromJSON(serializedNode)
.setListType(serializedNode.listType)
.setStart(serializedNode.start);
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
@ -349,7 +360,10 @@ const TAG_TO_LIST_TYPE: Record<string, ListType> = {
* @param start - Where an ordered list starts its count, start = 1 if left undefined.
* @returns The new ListNode
*/
export function $createListNode(listType: ListType, start = 1): ListNode {
export function $createListNode(
listType: ListType = 'number',
start = 1,
): ListNode {
return $applyNodeReplacement(new ListNode(listType, start));
}

View File

@ -57,9 +57,9 @@ describe('LexicalListNode tests', () => {
expect(listNode.getTag()).toBe('ul');
expect(listNode.getTextContent()).toBe('');
});
// @ts-expect-error
expect(() => $createListNode()).toThrow();
await editor.update(() => {
expect(() => $createListNode()).not.toThrow();
});
});
test('ListNode.getTag()', async () => {

View File

@ -10,6 +10,7 @@ import type {
BaseSelection,
EditorConfig,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
RangeSelection,
SerializedElementNode,
@ -29,6 +30,8 @@ export type SerializedMarkNode = Spread<
SerializedElementNode
>;
const NO_IDS: readonly string[] = [];
/** @noInheritDoc */
export class MarkNode extends ElementNode {
/** @internal */
@ -47,23 +50,23 @@ export class MarkNode extends ElementNode {
}
static importJSON(serializedNode: SerializedMarkNode): MarkNode {
const node = $createMarkNode(serializedNode.ids);
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
return $createMarkNode().updateFromJSON(serializedNode);
}
updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedMarkNode>): this {
return super.updateFromJSON(serializedNode).setIDs(serializedNode.ids);
}
exportJSON(): SerializedMarkNode {
return {
...super.exportJSON(),
ids: Array.from(this.getIDs()),
ids: this.getIDs(),
};
}
constructor(ids: readonly string[], key?: NodeKey) {
constructor(ids: readonly string[] = NO_IDS, key?: NodeKey) {
super(key);
this.__ids = ids || [];
this.__ids = ids;
}
createDOM(config: EditorConfig): HTMLElement {
@ -99,47 +102,33 @@ export class MarkNode extends ElementNode {
}
hasID(id: string): boolean {
const ids = this.getIDs();
for (let i = 0; i < ids.length; i++) {
if (id === ids[i]) {
return true;
}
}
return false;
return this.getIDs().includes(id);
}
getIDs(): Array<string> {
const self = this.getLatest();
return $isMarkNode(self) ? Array.from(self.__ids) : [];
return Array.from(this.getLatest().__ids);
}
addID(id: string): void {
setIDs(ids: readonly string[]): this {
const self = this.getWritable();
if ($isMarkNode(self)) {
const ids = Array.from(self.__ids);
self.__ids = ids;
for (let i = 0; i < ids.length; i++) {
// If we already have it, don't add again
if (id === ids[i]) {
return;
}
}
ids.push(id);
}
return self;
}
deleteID(id: string): void {
addID(id: string): this {
const self = this.getWritable();
if ($isMarkNode(self)) {
return self.__ids.includes(id) ? self.setIDs([...self.__ids, id]) : self;
}
deleteID(id: string): this {
const self = this.getWritable();
const idx = self.__ids.indexOf(id);
if (idx === -1) {
return self;
}
const ids = Array.from(self.__ids);
self.__ids = ids;
for (let i = 0; i < ids.length; i++) {
if (id === ids[i]) {
ids.splice(i, 1);
return;
}
}
}
ids.splice(idx, 1);
return self.setIDs(ids);
}
insertNewAfter(
@ -195,7 +184,7 @@ export class MarkNode extends ElementNode {
}
}
export function $createMarkNode(ids: readonly string[]): MarkNode {
export function $createMarkNode(ids: readonly string[] = NO_IDS): MarkNode {
return $applyNodeReplacement(new MarkNode(ids));
}

View File

@ -9,7 +9,6 @@
import type {
EditorConfig,
LexicalNode,
NodeKey,
RangeSelection,
SerializedElementNode,
} from 'lexical';
@ -30,18 +29,13 @@ export class OverflowNode extends ElementNode {
}
static importJSON(serializedNode: SerializedOverflowNode): OverflowNode {
return $createOverflowNode();
return $createOverflowNode().updateFromJSON(serializedNode);
}
static importDOM(): null {
return null;
}
constructor(key?: NodeKey) {
super(key);
this.__type = 'overflow';
}
createDOM(config: EditorConfig): HTMLElement {
const div = document.createElement('span');
const className = config.theme.characterLimit;

View File

@ -9,6 +9,7 @@
import {expect} from '@playwright/test';
import {
indent,
moveLeft,
moveRight,
moveToEditorBeginning,
@ -1901,4 +1902,48 @@ test.describe.parallel('Nested List', () => {
});
},
);
test('new list item should preserve format from previous list item even after new list item is indented', async ({
page,
}) => {
await focusEditor(page);
await toggleBulletList(page);
await toggleBold(page);
await page.keyboard.type('MLH Fellowship');
await page.keyboard.press('Enter');
await indent(page, 1);
await page.keyboard.type('Fall 2024');
await assertHTML(
page,
html`
<ul class="PlaygroundEditorTheme__ul">
<li
class="PlaygroundEditorTheme__listItem PlaygroundEditorTheme__ltr"
dir="ltr"
value="1">
<strong
class="PlaygroundEditorTheme__textBold"
data-lexical-text="true">
MLH Fellowship
</strong>
</li>
<li
class="PlaygroundEditorTheme__listItem PlaygroundEditorTheme__nestedListItem"
value="2">
<ul class="PlaygroundEditorTheme__ul">
<li
class="PlaygroundEditorTheme__listItem PlaygroundEditorTheme__ltr"
dir="ltr"
value="1">
<strong
class="PlaygroundEditorTheme__textBold"
data-lexical-text="true">
Fall 2024
</strong>
</li>
</ul>
</li>
</ul>
`,
);
});
});

View File

@ -48,15 +48,10 @@ export class AutocompleteNode extends TextNode {
static importJSON(
serializedNode: SerializedAutocompleteNode,
): AutocompleteNode {
const node = $createAutocompleteNode(
return $createAutocompleteNode(
serializedNode.text,
serializedNode.uuid,
);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
).updateFromJSON(serializedNode);
}
exportJSON(): SerializedAutocompleteNode {

View File

@ -58,15 +58,10 @@ export class EmojiNode extends TextNode {
}
static importJSON(serializedNode: SerializedEmojiNode): EmojiNode {
const node = $createEmojiNode(
return $createEmojiNode(
serializedNode.className,
serializedNode.text,
);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
).updateFromJSON(serializedNode);
}
exportJSON(): SerializedEmojiNode {

View File

@ -65,11 +65,10 @@ export class EquationNode extends DecoratorNode<JSX.Element> {
}
static importJSON(serializedNode: SerializedEquationNode): EquationNode {
const node = $createEquationNode(
return $createEquationNode(
serializedNode.equation,
serializedNode.inline,
);
return node;
).updateFromJSON(serializedNode);
}
exportJSON(): SerializedEquationNode {

View File

@ -79,7 +79,7 @@ export class ExcalidrawNode extends DecoratorNode<JSX.Element> {
serializedNode.data,
serializedNode.width ?? 'inherit',
serializedNode.height ?? 'inherit',
);
).updateFromJSON(serializedNode);
}
exportJSON(): SerializedExcalidrawNode {

View File

@ -73,9 +73,9 @@ export class FigmaNode extends DecoratorBlockNode {
}
static importJSON(serializedNode: SerializedFigmaNode): FigmaNode {
const node = $createFigmaNode(serializedNode.documentID);
node.setFormat(serializedNode.format);
return node;
return $createFigmaNode(serializedNode.documentID).updateFromJSON(
serializedNode,
);
}
exportJSON(): SerializedFigmaNode {

View File

@ -13,6 +13,7 @@ import type {
EditorConfig,
LexicalEditor,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
SerializedEditor,
SerializedLexicalNode,
@ -99,16 +100,21 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
}
static importJSON(serializedNode: SerializedImageNode): ImageNode {
const {altText, height, width, maxWidth, caption, src, showCaption} =
serializedNode;
const node = $createImageNode({
const {altText, height, width, maxWidth, src, showCaption} = serializedNode;
return $createImageNode({
altText,
height,
maxWidth,
showCaption,
src,
width,
});
}).updateFromJSON(serializedNode);
}
updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedImageNode>): this {
const node = super.updateFromJSON(serializedNode);
const {caption} = serializedNode;
const nestedEditor = node.__caption;
const editorState = nestedEditor.parseEditorState(caption.editorState);
if (!editorState.isEmpty()) {

View File

@ -13,6 +13,7 @@ import type {
EditorConfig,
LexicalEditor,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
SerializedEditor,
SerializedLexicalNode,
@ -100,16 +101,22 @@ export class InlineImageNode extends DecoratorNode<JSX.Element> {
static importJSON(
serializedNode: SerializedInlineImageNode,
): InlineImageNode {
const {altText, height, width, caption, src, showCaption, position} =
serializedNode;
const node = $createInlineImageNode({
const {altText, height, width, src, showCaption, position} = serializedNode;
return $createInlineImageNode({
altText,
height,
position,
showCaption,
src,
width,
});
}).updateFromJSON(serializedNode);
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedInlineImageNode>,
): this {
const {caption} = serializedNode;
const node = super.updateFromJSON(serializedNode);
const nestedEditor = node.__caption;
const editorState = nestedEditor.parseEditorState(caption.editorState);
if (!editorState.isEmpty()) {

View File

@ -8,7 +8,7 @@
import type {EditorConfig, LexicalNode, SerializedTextNode} from 'lexical';
import {TextNode} from 'lexical';
import {$applyNodeReplacement, TextNode} from 'lexical';
export type SerializedKeywordNode = SerializedTextNode;
@ -22,12 +22,7 @@ export class KeywordNode extends TextNode {
}
static importJSON(serializedNode: SerializedKeywordNode): KeywordNode {
const node = $createKeywordNode(serializedNode.text);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
return $createKeywordNode().updateFromJSON(serializedNode);
}
createDOM(config: EditorConfig): HTMLElement {
@ -50,8 +45,8 @@ export class KeywordNode extends TextNode {
}
}
export function $createKeywordNode(keyword: string): KeywordNode {
return new KeywordNode(keyword);
export function $createKeywordNode(keyword: string = ''): KeywordNode {
return $applyNodeReplacement(new KeywordNode(keyword));
}
export function $isKeywordNode(node: LexicalNode | null | undefined): boolean {

View File

@ -12,6 +12,7 @@ import type {
DOMExportOutput,
EditorConfig,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
SerializedElementNode,
Spread,
@ -95,7 +96,15 @@ export class LayoutContainerNode extends ElementNode {
}
static importJSON(json: SerializedLayoutContainerNode): LayoutContainerNode {
return $createLayoutContainerNode(json.templateColumns);
return $createLayoutContainerNode().updateFromJSON(json);
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedLayoutContainerNode>,
): this {
return super
.updateFromJSON(serializedNode)
.setTemplateColumns(serializedNode.templateColumns);
}
isShadowRoot(): boolean {
@ -117,13 +126,15 @@ export class LayoutContainerNode extends ElementNode {
return this.getLatest().__templateColumns;
}
setTemplateColumns(templateColumns: string) {
this.getWritable().__templateColumns = templateColumns;
setTemplateColumns(templateColumns: string): this {
const self = this.getWritable();
self.__templateColumns = templateColumns;
return self;
}
}
export function $createLayoutContainerNode(
templateColumns: string,
templateColumns: string = '',
): LayoutContainerNode {
return new LayoutContainerNode(templateColumns);
}

View File

@ -59,8 +59,8 @@ export class LayoutItemNode extends ElementNode {
};
}
static importJSON(): LayoutItemNode {
return $createLayoutItemNode();
static importJSON(serializedNode: SerializedLayoutItemNode): LayoutItemNode {
return $createLayoutItemNode().updateFromJSON(serializedNode);
}
isShadowRoot(): boolean {

View File

@ -30,9 +30,13 @@ function $convertMentionElement(
domNode: HTMLElement,
): DOMConversionOutput | null {
const textContent = domNode.textContent;
const mentionName = domNode.getAttribute('data-lexical-mention-name');
if (textContent !== null) {
const node = $createMentionNode(textContent);
const node = $createMentionNode(
typeof mentionName === 'string' ? mentionName : textContent,
textContent,
);
return {
node,
};
@ -53,13 +57,9 @@ export class MentionNode extends TextNode {
return new MentionNode(node.__mention, node.__text, node.__key);
}
static importJSON(serializedNode: SerializedMentionNode): MentionNode {
const node = $createMentionNode(serializedNode.mentionName);
node.setTextContent(serializedNode.text);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
return $createMentionNode(serializedNode.mentionName).updateFromJSON(
serializedNode,
);
}
constructor(mentionName: string, text?: string, key?: NodeKey) {
@ -85,6 +85,9 @@ export class MentionNode extends TextNode {
exportDOM(): DOMExportOutput {
const element = document.createElement('span');
element.setAttribute('data-lexical-mention', 'true');
if (this.__text !== this.__mention) {
element.setAttribute('data-lexical-mention-name', this.__mention);
}
element.textContent = this.__text;
return {element};
}
@ -116,8 +119,11 @@ export class MentionNode extends TextNode {
}
}
export function $createMentionNode(mentionName: string): MentionNode {
const mentionNode = new MentionNode(mentionName);
export function $createMentionNode(
mentionName: string,
textContent?: string,
): MentionNode {
const mentionNode = new MentionNode(mentionName, (textContent = mentionName));
mentionNode.setMode('segmented').toggleDirectionless();
return $applyNodeReplacement(mentionNode);
}

View File

@ -105,7 +105,7 @@ export class PageBreakNode extends DecoratorNode<JSX.Element> {
}
static importJSON(serializedNode: SerializedPageBreakNode): PageBreakNode {
return $createPageBreakNode();
return $createPageBreakNode().updateFromJSON(serializedNode);
}
static importDOM(): DOMConversionMap | null {

View File

@ -87,12 +87,10 @@ export class PollNode extends DecoratorNode<JSX.Element> {
}
static importJSON(serializedNode: SerializedPollNode): PollNode {
const node = $createPollNode(
return $createPollNode(
serializedNode.question,
serializedNode.options,
);
serializedNode.options.forEach(node.addOption);
return node;
).updateFromJSON(serializedNode);
}
constructor(question: string, options: Options, key?: NodeKey) {

View File

@ -6,12 +6,7 @@
*
*/
import type {
EditorConfig,
LexicalNode,
NodeKey,
SerializedTextNode,
} from 'lexical';
import type {EditorConfig, LexicalNode, SerializedTextNode} from 'lexical';
import {addClassNamesToElement} from '@lexical/utils';
import {$applyNodeReplacement, TextNode} from 'lexical';
@ -26,10 +21,6 @@ export class SpecialTextNode extends TextNode {
return new SpecialTextNode(node.__text, node.__key);
}
constructor(text: string, key?: NodeKey) {
super(text, key);
}
createDOM(config: EditorConfig): HTMLElement {
const dom = document.createElement('span');
addClassNamesToElement(dom, config.theme.specialText);
@ -49,12 +40,7 @@ export class SpecialTextNode extends TextNode {
}
static importJSON(serializedNode: SerializedTextNode): SpecialTextNode {
const node = $createSpecialTextNode(serializedNode.text);
node.setFormat(serializedNode.format);
node.setStyle(serializedNode.style);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
return node;
return $createSpecialTextNode().updateFromJSON(serializedNode);
}
isTextEntity(): true {

View File

@ -10,6 +10,7 @@ import type {
EditorConfig,
LexicalEditor,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
SerializedEditor,
SerializedLexicalNode,
@ -55,11 +56,17 @@ export class StickyNode extends DecoratorNode<JSX.Element> {
);
}
static importJSON(serializedNode: SerializedStickyNode): StickyNode {
const stickyNode = new StickyNode(
return new StickyNode(
serializedNode.xOffset,
serializedNode.yOffset,
serializedNode.color,
);
).updateFromJSON(serializedNode);
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedStickyNode>,
): this {
const stickyNode = super.updateFromJSON(serializedNode);
const caption = serializedNode.caption;
const nestedEditor = stickyNode.__caption;
const editorState = nestedEditor.parseEditorState(caption.editorState);

View File

@ -142,9 +142,7 @@ export class TweetNode extends DecoratorBlockNode {
}
static importJSON(serializedNode: SerializedTweetNode): TweetNode {
const node = $createTweetNode(serializedNode.id);
node.setFormat(serializedNode.format);
return node;
return $createTweetNode(serializedNode.id).updateFromJSON(serializedNode);
}
exportJSON(): SerializedTweetNode {

View File

@ -89,9 +89,9 @@ export class YouTubeNode extends DecoratorBlockNode {
}
static importJSON(serializedNode: SerializedYouTubeNode): YouTubeNode {
const node = $createYouTubeNode(serializedNode.videoID);
node.setFormat(serializedNode.format);
return node;
return $createYouTubeNode(serializedNode.videoID).updateFromJSON(
serializedNode,
);
}
exportJSON(): SerializedYouTubeNode {

View File

@ -118,8 +118,9 @@ export class CollapsibleContainerNode extends ElementNode {
static importJSON(
serializedNode: SerializedCollapsibleContainerNode,
): CollapsibleContainerNode {
const node = $createCollapsibleContainerNode(serializedNode.open);
return node;
return $createCollapsibleContainerNode(serializedNode.open).updateFromJSON(
serializedNode,
);
}
exportDOM(): DOMExportOutput {

View File

@ -100,7 +100,7 @@ export class CollapsibleContentNode extends ElementNode {
static importJSON(
serializedNode: SerializedCollapsibleContentNode,
): CollapsibleContentNode {
return $createCollapsibleContentNode();
return $createCollapsibleContentNode().updateFromJSON(serializedNode);
}
isShadowRoot(): boolean {

View File

@ -80,7 +80,7 @@ export class CollapsibleTitleNode extends ElementNode {
static importJSON(
serializedNode: SerializedCollapsibleTitleNode,
): CollapsibleTitleNode {
return $createCollapsibleTitleNode();
return $createCollapsibleTitleNode().updateFromJSON(serializedNode);
}
collapseAtStart(_selection: RangeSelection): boolean {

View File

@ -22,7 +22,7 @@ import {
DRAGOVER_COMMAND,
DRAGSTART_COMMAND,
DROP_COMMAND,
getDOMSelection,
getDOMSelectionFromTarget,
isHTMLElement,
LexicalCommand,
LexicalEditor,
@ -367,14 +367,7 @@ function canDropImage(event: DragEvent): boolean {
function getDragSelection(event: DragEvent): Range | null | undefined {
let range;
const target = event.target as null | Element | Document;
const targetWindow =
target == null
? null
: target.nodeType === 9
? (target as Document).defaultView
: (target as Element).ownerDocument.defaultView;
const domSelection = getDOMSelection(targetWindow);
const domSelection = getDOMSelectionFromTarget(event.target);
if (document.caretRangeFromPoint) {
range = document.caretRangeFromPoint(event.clientX, event.clientY);
} else if (event.rangeParent && domSelection !== null) {

View File

@ -26,7 +26,7 @@ import {
DRAGOVER_COMMAND,
DRAGSTART_COMMAND,
DROP_COMMAND,
getDOMSelection,
getDOMSelectionFromTarget,
isHTMLElement,
LexicalCommand,
LexicalEditor,
@ -319,14 +319,7 @@ function canDropImage(event: DragEvent): boolean {
function getDragSelection(event: DragEvent): Range | null | undefined {
let range;
const target = event.target as null | Element | Document;
const targetWindow =
target == null
? null
: target.nodeType === 9
? (target as Document).defaultView
: (target as Element).ownerDocument.defaultView;
const domSelection = getDOMSelection(targetWindow);
const domSelection = getDOMSelectionFromTarget(event.target);
if (document.caretRangeFromPoint) {
range = document.caretRangeFromPoint(event.clientX, event.clientY);
} else if (event.rangeParent && domSelection !== null) {

View File

@ -9,6 +9,7 @@
import type {
ElementFormatType,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
SerializedLexicalNode,
Spread,
@ -38,6 +39,14 @@ export class DecoratorBlockNode extends DecoratorNode<JSX.Element> {
};
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedDecoratorBlockNode>,
): this {
return super
.updateFromJSON(serializedNode)
.setFormat(serializedNode.format || '');
}
canIndent(): false {
return false;
}
@ -50,9 +59,10 @@ export class DecoratorBlockNode extends DecoratorNode<JSX.Element> {
return false;
}
setFormat(format: ElementFormatType): void {
setFormat(format: ElementFormatType): this {
const self = this.getWritable();
self.__format = format;
return self;
}
isInline(): false {

View File

@ -126,7 +126,7 @@ export class HorizontalRuleNode extends DecoratorNode<JSX.Element> {
static importJSON(
serializedNode: SerializedHorizontalRuleNode,
): HorizontalRuleNode {
return $createHorizontalRuleNode();
return $createHorizontalRuleNode().updateFromJSON(serializedNode);
}
static importDOM(): DOMConversionMap | null {

View File

@ -74,13 +74,26 @@ describe('LexicalHeadingNode tests', () => {
test('HeadingNode.updateDOM()', async () => {
const {editor} = testEnv;
await editor.update(() => {
const headingNode = new HeadingNode('h1');
const headingNode = $createHeadingNode('h1');
const domElement = headingNode.createDOM(editorConfig);
expect(domElement.outerHTML).toBe('<h1 class="my-h1-class"></h1>');
const newHeadingNode = new HeadingNode('h2');
const result = newHeadingNode.updateDOM(headingNode, domElement);
const newHeadingNode = $createHeadingNode('h1');
const result = newHeadingNode.updateDOM(
headingNode,
domElement,
editor._config,
);
expect(result).toBe(false);
expect(domElement.outerHTML).toBe('<h1 class="my-h1-class"></h1>');
// When the HTML tag changes we must return true and not update the DOM, as createDOM will be called
const newTag = $createHeadingNode('h2');
const newTagResult = newTag.updateDOM(
headingNode,
domElement,
editor._config,
);
expect(newTagResult).toBe(true);
expect(domElement.outerHTML).toBe('<h1 class="my-h1-class"></h1>');
});
});

View File

@ -16,6 +16,7 @@ import type {
LexicalCommand,
LexicalEditor,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
ParagraphNode,
PasteCommandType,
@ -128,10 +129,6 @@ export class QuoteNode extends ElementNode {
return new QuoteNode(node.__key);
}
constructor(key?: NodeKey) {
super(key);
}
// View
createDOM(config: EditorConfig): HTMLElement {
@ -175,11 +172,7 @@ export class QuoteNode extends ElementNode {
}
static importJSON(serializedNode: SerializedQuoteNode): QuoteNode {
const node = $createQuoteNode();
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
return $createQuoteNode().updateFromJSON(serializedNode);
}
// Mutation
@ -239,6 +232,12 @@ export class HeadingNode extends ElementNode {
return this.__tag;
}
setTag(tag: HeadingTagType): this {
const self = this.getWritable();
this.__tag = tag;
return self;
}
// View
createDOM(config: EditorConfig): HTMLElement {
@ -253,8 +252,8 @@ export class HeadingNode extends ElementNode {
return element;
}
updateDOM(prevNode: this, dom: HTMLElement): boolean {
return false;
updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean {
return prevNode.__tag !== this.__tag;
}
static importDOM(): DOMConversionMap | null {
@ -334,11 +333,15 @@ export class HeadingNode extends ElementNode {
}
static importJSON(serializedNode: SerializedHeadingNode): HeadingNode {
const node = $createHeadingNode(serializedNode.tag);
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
return $createHeadingNode(serializedNode.tag).updateFromJSON(
serializedNode,
);
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedHeadingNode>,
): this {
return super.updateFromJSON(serializedNode).setTag(serializedNode.tag);
}
exportJSON(): SerializedHeadingNode {
@ -426,7 +429,9 @@ function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput {
return {node};
}
export function $createHeadingNode(headingTag: HeadingTagType): HeadingNode {
export function $createHeadingNode(
headingTag: HeadingTagType = 'h1',
): HeadingNode {
return $applyNodeReplacement(new HeadingNode(headingTag));
}

View File

@ -13,6 +13,7 @@ import type {
EditorConfig,
LexicalEditor,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
SerializedElementNode,
Spread,
@ -61,7 +62,7 @@ export class TableCellNode extends ElementNode {
/** @internal */
__headerState: TableCellHeaderState;
/** @internal */
__width?: number;
__width?: number | undefined;
/** @internal */
__backgroundColor: null | string;
@ -98,14 +99,18 @@ export class TableCellNode extends ElementNode {
}
static importJSON(serializedNode: SerializedTableCellNode): TableCellNode {
const colSpan = serializedNode.colSpan || 1;
const rowSpan = serializedNode.rowSpan || 1;
return $createTableCellNode(
serializedNode.headerState,
colSpan,
serializedNode.width || undefined,
)
.setRowSpan(rowSpan)
return $createTableCellNode().updateFromJSON(serializedNode);
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedTableCellNode>,
): this {
return super
.updateFromJSON(serializedNode)
.setHeaderStyles(serializedNode.headerState)
.setColSpan(serializedNode.colSpan || 1)
.setRowSpan(serializedNode.rowSpan || 1)
.setWidth(serializedNode.width || undefined)
.setBackgroundColor(serializedNode.backgroundColor || null);
}
@ -224,7 +229,7 @@ export class TableCellNode extends ElementNode {
return this.getLatest().__headerState;
}
setWidth(width: number): this {
setWidth(width: number | undefined): this {
const self = this.getWritable();
self.__width = width;
return self;

View File

@ -25,6 +25,7 @@ import {
ElementNode,
LexicalEditor,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
SerializedElementNode,
setDOMUnmanaged,
@ -114,21 +115,22 @@ export function setScrollableTablesActive(
export class TableNode extends ElementNode {
/** @internal */
__rowStriping: boolean;
__colWidths?: number[] | readonly number[];
__colWidths?: readonly number[];
static getType(): string {
return 'table';
}
getColWidths(): number[] | readonly number[] | undefined {
getColWidths(): readonly number[] | undefined {
const self = this.getLatest();
return self.__colWidths;
}
setColWidths(colWidths: readonly number[]): this {
setColWidths(colWidths: readonly number[] | undefined): this {
const self = this.getWritable();
// NOTE: Node properties should be immutable. Freeze to prevent accidental mutation.
self.__colWidths = __DEV__ ? Object.freeze(colWidths) : colWidths;
self.__colWidths =
colWidths !== undefined && __DEV__ ? Object.freeze(colWidths) : colWidths;
return self;
}
@ -152,10 +154,14 @@ export class TableNode extends ElementNode {
}
static importJSON(serializedNode: SerializedTableNode): TableNode {
const tableNode = $createTableNode();
tableNode.__rowStriping = serializedNode.rowStriping || false;
tableNode.__colWidths = serializedNode.colWidths;
return tableNode;
return $createTableNode().updateFromJSON(serializedNode);
}
updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedTableNode>): this {
return super
.updateFromJSON(serializedNode)
.setRowStriping(serializedNode.rowStriping || false)
.setColWidths(serializedNode.colWidths);
}
constructor(key?: NodeKey) {
@ -425,8 +431,10 @@ export class TableNode extends ElementNode {
return Boolean(this.getLatest().__rowStriping);
}
setRowStriping(newRowStriping: boolean): void {
this.getWritable().__rowStriping = newRowStriping;
setRowStriping(newRowStriping: boolean): this {
const self = this.getWritable();
self.__rowStriping = newRowStriping;
return self;
}
canSelectBefore(): true {

View File

@ -6,7 +6,7 @@
*
*/
import type {BaseSelection, Spread} from 'lexical';
import type {BaseSelection, LexicalUpdateJSON, Spread} from 'lexical';
import {$descendantsMatching, addClassNamesToElement} from '@lexical/utils';
import {
@ -53,7 +53,15 @@ export class TableRowNode extends ElementNode {
}
static importJSON(serializedNode: SerializedTableRowNode): TableRowNode {
return $createTableRowNode(serializedNode.height);
return $createTableRowNode().updateFromJSON(serializedNode);
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedTableRowNode>,
): this {
return super
.updateFromJSON(serializedNode)
.setHeight(serializedNode.height);
}
constructor(height?: number, key?: NodeKey) {
@ -62,9 +70,10 @@ export class TableRowNode extends ElementNode {
}
exportJSON(): SerializedTableRowNode {
const height = this.getHeight();
return {
...super.exportJSON(),
...(this.getHeight() && {height: this.getHeight()}),
...(height === undefined ? undefined : {height}),
};
}
@ -92,10 +101,10 @@ export class TableRowNode extends ElementNode {
return true;
}
setHeight(height: number): number | null | undefined {
setHeight(height?: number | undefined): this {
const self = this.getWritable();
self.__height = height;
return this.__height;
return self;
}
getHeight(): number | undefined {

View File

@ -135,6 +135,8 @@ describe('table selection', () => {
__prev: null,
__size: 1,
__style: '',
__textFormat: 0,
__textStyle: '',
__type: 'root',
});
expect(parsedParagraph).toEqual({

View File

@ -203,7 +203,7 @@ exportJSON(): SerializedHeadingNode {
#### `LexicalNode.importJSON()`
You can control how a `LexicalNode` is serialized back into a node from JSON by adding an `importJSON()` method.
You can control how a `LexicalNode` is deserialized back into a node from JSON by adding an `importJSON()` method.
```js
export type SerializedLexicalNode = {
@ -216,23 +216,62 @@ importJSON(jsonNode: SerializedLexicalNode): LexicalNode
This method works in the opposite way to how `exportJSON` works. Lexical uses the `type` field on the JSON object to determine what Lexical node class it needs to map to, so keeping the `type` field consistent with the `getType()` of the LexicalNode is essential.
You should use the `updateFromJSON` method in your `importJSON` to simplify the implementation and allow for future extension by the base classes.
Here's an example of `importJSON` for the `HeadingNode`:
```js
```ts
static importJSON(serializedNode: SerializedHeadingNode): HeadingNode {
const node = $createHeadingNode(serializedNode.tag);
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
return $createHeadingNode().updateFromJSON(serializedNode);
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedHeadingNode>,
): this {
return super.updateFromJSON(serializedNode).setTag(serializedNode.tag);
}
```
#### `LexicalNode.updateFromJSON()`
`updateFromJSON` is a method introduced in Lexical 0.23 to simplify the implementation of `importJSON`, so that a base class can expose the code that it is using to set all of the node's properties based on the JSON to any subclass.
:::note
The input type used in this method is not sound in the general case, but it is safe if subclasses only add optional properties to the JSON. Even though it is not sound, the usage in this library is safe as long as your `importJSON` method does not upcast the node before calling `updateFromJSON`.
```ts
export type SerializedExtendedTextNode = Spread<
// UNSAFE. This property is not optional
{ newProperty: string },
SerializedTextNode
>;
```
```ts
export type SerializedExtendedTextNode = Spread<
// SAFE. This property is not optional
{ newProperty?: string },
SerializedTextNode
>;
```
This is because it's possible to cast to a more general type, e.g.
```ts
const serializedNode: SerializedTextNode = { /* ... */ };
const newNode: TextNode = $createExtendedTextNode();
// This passes the type check, but would fail at runtime if the updateFromJSON method required newProperty
newNode.updateFromJSON(serializedNode);
```
:::
### Versioning & Breaking Changes
It's important to note that you should avoid making breaking changes to existing fields in your JSON object, especially if backwards compatibility is an important part of your editor. That's why we recommend using a version field to separate the different changes in your node as you add or change functionality of custom nodes. Here's the serialized type definition for Lexical's base `TextNode` class:
```js
```ts
import type {Spread} from 'lexical';
// Spread is a Typescript utility that allows us to spread the properties
@ -249,21 +288,10 @@ export type SerializedTextNode = Spread<
>;
```
If we wanted to make changes to the above `TextNode`, we should be sure to not remove or change an existing property, as this can cause data corruption. Instead, opt to add the functionality as a new property field instead, and use the version to determine how to handle the differences in your node.
If we wanted to make changes to the above `TextNode`, we should be sure to not remove or change an existing property, as this can cause data corruption. Instead, opt to add the functionality as a new optional property field instead.
```js
export type SerializedTextNodeV1 = Spread<
{
detail: number;
format: number;
mode: TextModeType;
style: string;
text: string;
},
SerializedLexicalNode
>;
export type SerializedTextNodeV2 = Spread<
```ts
export type SerializedTextNode = Spread<
{
detail: number;
format: number;
@ -271,15 +299,53 @@ export type SerializedTextNodeV2 = Spread<
style: string;
text: string;
// Our new field we've added
newField: string,
// Notice the version is now 2
version: 2,
newField?: string,
},
SerializedLexicalNode
>;
export type SerializedTextNode = SerializedTextNodeV1 | SerializedTextNodeV2;
```
### Dangers of a flat version property
The `updateFromJSON` method should ignore `type` and `version`, to support subclassing and code re-use. Ideally, you should only evolve your types in a backwards compatible way (new fields are optional), and/or have a uniquely named property to store the version in your class. Generally speaking, it's best if nearly all properties are optional and the node provides defaults for each property. This allows you to write less boilerplate code and produce smaller JSON.
The reason that `version` is no longer recommended is that it does not compose with subclasses. Consider this hierarchy:
```ts
class TextNode {
exportJSON() {
return { /* ... */, version: 1 };
}
}
class ExtendedTextNode extends TextNode {
exportJSON() {
return { ...super.exportJSON() };
}
}
```
If `TextNode` is updated to `version: 2` then this version and new serialization will propagate to `ExtendedTextNode` via the `super.exportJSON()` call, but this leaves nowhere to store a version for `ExtendedTextNode` or vice versa. If the `ExtendedTextNode` explicitly specified a `version`, then the version of the base class will be ignored even though the representation of the JSON from the base class may change:
```ts
class TextNode {
exportJSON() {
return { /* ... */, version: 2 };
}
}
class ExtendedTextNode extends TextNode {
exportJSON() {
// The super's layout has changed, but the version information is lost
return { ...super.exportJSON(), version: 1 };
}
}
```
So then you have a situation where there are possibly two JSON layouts for `ExtendedTextNode` with the same version, because the base class version changed due to a package upgrade.
If you do have incompatible representations, it's probably best to choose a new type. This is basically the only way that will force old configurations to fail, as `importJSON` implementations often don't do runtime validation and dangerously assume that the values are the correct type.
There are other schemes that would allow for composable versions, such as nesting the superclass data, or choosing a different name for a version property in each subclass. In practice, explicit versioning is generally redundant if the serialization is properly parsed, so it is recommended that you use the simpler approach with a flat representation with mostly optional properties.
### Handling extended HTML styling
Since the TextNode is foundational to all Lexical packages, including the plain text use case. Handling any rich text logic is undesirable. This creates the need to override the TextNode to handle serialization and deserialization of HTML/CSS styling properties to achieve full fidelity between JSON \<-\> HTML. Since this is a very popular use case, below we are proving a recipe to handle the most common use cases.
@ -364,7 +430,7 @@ export class ExtendedTextNode extends TextNode {
}
static importJSON(serializedNode: SerializedTextNode): TextNode {
return TextNode.importJSON(serializedNode);
return $createExtendedTextNode().updateFromJSON(serializedNode);
}
isSimpleText() {
@ -374,7 +440,7 @@ export class ExtendedTextNode extends TextNode {
// no need to add exportJSON here, since we are not adding any new properties
}
export function $createExtendedTextNode(text: string): ExtendedTextNode {
export function $createExtendedTextNode(text: string = ''): ExtendedTextNode {
return $applyNodeReplacement(new ExtendedTextNode(text));
}

View File

@ -69,7 +69,7 @@ export class EmojiNode extends TextNode {
}
static importJSON(serializedNode: SerializedEmojiNode): EmojiNode {
return $createEmojiNode(serializedNode.unifiedID);
return $createEmojiNode(serializedNode.unifiedID).updateFromJSON(serializedNode);
}
exportJSON(): SerializedEmojiNode {

View File

@ -393,6 +393,7 @@ declare export class LexicalNode {
constructor(key?: NodeKey): void;
exportDOM(editor: LexicalEditor): DOMExportOutput;
exportJSON(): SerializedLexicalNode;
updateFromJSON(serializedNode: $FlowFixMe): this;
getType(): string;
isAttached(): boolean;
isSelected(): boolean;

View File

@ -23,6 +23,8 @@ import {
// DOM
export const DOM_ELEMENT_TYPE = 1;
export const DOM_TEXT_TYPE = 3;
export const DOM_DOCUMENT_TYPE = 9;
export const DOM_DOCUMENT_FRAGMENT_TYPE = 11;
// Reconciling
export const NO_DIRTY_NODES = 0;

View File

@ -40,7 +40,7 @@ import {
markNodesWithTypesAsDirty,
} from './LexicalUtils';
import {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode';
import {DecoratorNode} from './nodes/LexicalDecoratorNode';
import {$isDecoratorNode} from './nodes/LexicalDecoratorNode';
import {LineBreakNode} from './nodes/LexicalLineBreakNode';
import {ParagraphNode} from './nodes/LexicalParagraphNode';
import {RootNode} from './nodes/LexicalRootNode';
@ -498,7 +498,7 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor {
`${name} should implement "importDOM" if using a custom "exportDOM" method to ensure HTML serialization (important for copy & paste) works as expected`,
);
}
if (proto instanceof DecoratorNode) {
if ($isDecoratorNode(proto)) {
// eslint-disable-next-line no-prototype-builtins
if (!proto.hasOwnProperty('decorate')) {
console.warn(

View File

@ -59,7 +59,6 @@ import {
KEY_TAB_COMMAND,
MOVE_TO_END,
MOVE_TO_START,
ParagraphNode,
PASTE_COMMAND,
REDO_COMMAND,
REMOVE_TEXT_COMMAND,
@ -69,8 +68,6 @@ import {
import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands';
import {
COMPOSITION_START_CHAR,
DOM_ELEMENT_TYPE,
DOM_TEXT_TYPE,
DOUBLE_LINE_BREAK,
IS_ALL_FORMATTING,
} from './LexicalConstants';
@ -92,6 +89,7 @@ import {
doesContainGrapheme,
getAnchorTextFromDOM,
getDOMSelection,
getDOMSelectionFromTarget,
getDOMTextNode,
getEditorPropertyFromDOMNode,
getEditorsToPropagate,
@ -109,8 +107,10 @@ import {
isDeleteWordBackward,
isDeleteWordForward,
isDOMNode,
isDOMTextNode,
isEscape,
isFirefoxClipboardEvents,
isHTMLElement,
isItalic,
isLexicalEditor,
isLineBreak,
@ -254,9 +254,8 @@ function shouldSkipSelectionChange(
offset: number,
): boolean {
return (
domNode !== null &&
isDOMTextNode(domNode) &&
domNode.nodeValue !== null &&
domNode.nodeType === DOM_TEXT_TYPE &&
offset !== 0 &&
offset !== domNode.nodeValue.length
);
@ -349,11 +348,15 @@ function onSelectionChange(
selection.format = anchorNode.getFormat();
selection.style = anchorNode.getStyle();
} else if (anchor.type === 'element' && !isRootTextContentEmpty) {
invariant(
$isElementNode(anchorNode),
'Point.getNode() must return ElementNode when type is element',
);
const lastNode = anchor.getNode();
selection.style = '';
if (
lastNode instanceof ParagraphNode &&
lastNode.getChildrenSize() === 0
// This previously applied to all ParagraphNode
lastNode.isEmpty()
) {
selection.format = lastNode.getTextFormat();
selection.style = lastNode.getTextStyle();
@ -455,13 +458,11 @@ function onClick(event: PointerEvent, editor: LexicalEditor): void {
// This is used to update the selection on touch devices when the user clicks on text after a
// node selection. See isSelectionChangeFromMouseDown for the inverse
const domAnchorNode = domSelection.anchorNode;
if (domAnchorNode !== null) {
const nodeType = domAnchorNode.nodeType;
// If the user is attempting to click selection back onto text, then
// we should attempt create a range selection.
// When we click on an empty paragraph node or the end of a paragraph that ends
// with an image/poll, the nodeType will be ELEMENT_NODE
if (nodeType === DOM_ELEMENT_TYPE || nodeType === DOM_TEXT_TYPE) {
if (isHTMLElement(domAnchorNode) || isDOMTextNode(domAnchorNode)) {
const newSelection = $internalCreateRangeSelection(
lastSelection,
domSelection,
@ -472,7 +473,6 @@ function onClick(event: PointerEvent, editor: LexicalEditor): void {
}
}
}
}
dispatchCommand(editor, CLICK_COMMAND, event);
});
@ -1133,14 +1133,7 @@ function getRootElementRemoveHandles(
const activeNestedEditorsMap: Map<string, LexicalEditor> = new Map();
function onDocumentSelectionChange(event: Event): void {
const target = event.target as null | Element | Document;
const targetWindow =
target == null
? null
: target.nodeType === 9
? (target as Document).defaultView
: (target as Element).ownerDocument.defaultView;
const domSelection = getDOMSelection(targetWindow);
const domSelection = getDOMSelectionFromTarget(event.target);
if (domSelection === null) {
return;
}
@ -1154,17 +1147,11 @@ function onDocumentSelectionChange(event: Event): void {
updateEditor(nextActiveEditor, () => {
const lastSelection = $getPreviousSelection();
const domAnchorNode = domSelection.anchorNode;
if (domAnchorNode === null) {
return;
}
const nodeType = domAnchorNode.nodeType;
if (isHTMLElement(domAnchorNode) || isDOMTextNode(domAnchorNode)) {
// If the user is attempting to click selection back onto text, then
// we should attempt create a range selection.
// When we click on an empty paragraph node or the end of a paragraph that ends
// with an image/poll, the nodeType will be ELEMENT_NODE
if (nodeType !== DOM_ELEMENT_TYPE && nodeType !== DOM_TEXT_TYPE) {
return;
}
const newSelection = $internalCreateRangeSelection(
lastSelection,
domSelection,
@ -1172,6 +1159,7 @@ function onDocumentSelectionChange(event: Event): void {
event,
);
$setSelection(newSelection);
}
});
}

View File

@ -21,7 +21,6 @@ import {
$isTextNode,
$setSelection,
} from '.';
import {DOM_TEXT_TYPE} from './LexicalConstants';
import {updateEditor} from './LexicalUpdates';
import {
$getNodeByKey,
@ -32,6 +31,7 @@ import {
getParentElement,
getWindow,
internalGetRoot,
isDOMTextNode,
isDOMUnmanaged,
isFirefoxClipboardEvents,
isHTMLElement,
@ -112,7 +112,7 @@ function shouldUpdateTextNodeFromMutation(
return false;
}
}
return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached();
return isDOMTextNode(targetDOM) && targetNode.isAttached();
}
function $getNearestManagedNodePairFromDOMNode(
@ -183,14 +183,10 @@ export function $flushMutations(
if (
shouldFlushTextMutations &&
$isTextNode(targetNode) &&
isDOMTextNode(targetDOM) &&
shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode)
) {
$handleTextMutation(
// nodeType === DOM_TEXT_TYPE is a Text DOM node
targetDOM as Text,
targetNode,
editor,
);
$handleTextMutation(targetDOM, targetNode, editor);
}
} else if (type === 'childList') {
shouldRevertSelection = true;

View File

@ -51,11 +51,24 @@ import {
export type NodeMap = Map<NodeKey, LexicalNode>;
/**
* The base type for all serialized nodes
*/
export type SerializedLexicalNode = {
/** The type string used by the Node class */
type: string;
/** A numeric version for this schema, defaulting to 1, but not generally recommended for use */
version: number;
};
/**
* Omit the children, type, and version properties from the given SerializedLexicalNode definition.
*/
export type LexicalUpdateJSON<T extends SerializedLexicalNode> = Omit<
T,
'children' | 'type' | 'version'
>;
/** @internal */
export interface LexicalPrivateDOM {
__lexicalTextContent?: string | undefined | null;
@ -889,6 +902,41 @@ export class LexicalNode {
this.name,
);
}
/**
* Update this LexicalNode instance from serialized JSON. It's recommended
* to implement as much logic as possible in this method instead of the
* static importJSON method, so that the functionality can be inherited in subclasses.
*
* The LexicalUpdateJSON utility type should be used to ignore any type, version,
* or children properties in the JSON so that the extended JSON from subclasses
* are acceptable parameters for the super call.
*
* If overridden, this method must call super.
*
* @example
* ```ts
* class MyTextNode extends TextNode {
* // ...
* static importJSON(serializedNode: SerializedMyTextNode): MyTextNode {
* return $createMyTextNode()
* .updateFromJSON(serializedNode);
* }
* updateFromJSON(
* serializedNode: LexicalUpdateJSON<SerializedMyTextNode>,
* ): this {
* return super.updateFromJSON(serializedNode)
* .setMyProperty(serializedNode.myProperty);
* }
* }
* ```
**/
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedLexicalNode>,
): this {
return this;
}
/**
* @experimental
*

View File

@ -23,7 +23,6 @@ import {
$isDecoratorNode,
$isElementNode,
$isLineBreakNode,
$isParagraphNode,
$isRootNode,
$isTextNode,
} from '.';
@ -332,9 +331,8 @@ function reconcileElementTerminatingLineBreak(
}
}
function reconcileParagraphFormat(element: ElementNode): void {
function reconcileTextFormat(element: ElementNode): void {
if (
$isParagraphNode(element) &&
subTreeTextFormat != null &&
subTreeTextFormat !== element.__textFormat &&
!activeEditorStateReadOnly
@ -344,9 +342,8 @@ function reconcileParagraphFormat(element: ElementNode): void {
}
}
function reconcileParagraphStyle(element: ElementNode): void {
function reconcileTextStyle(element: ElementNode): void {
if (
$isParagraphNode(element) &&
subTreeTextStyle !== '' &&
subTreeTextStyle !== element.__textStyle &&
!activeEditorStateReadOnly
@ -438,8 +435,8 @@ function $reconcileChildrenWithDirection(
subTreeTextStyle = '';
$reconcileChildren(prevElement, nextElement, nextElement.getDOMSlot(dom));
reconcileBlockDirection(nextElement, dom);
reconcileParagraphFormat(nextElement);
reconcileParagraphStyle(nextElement);
reconcileTextFormat(nextElement);
reconcileTextStyle(nextElement);
subTreeDirectionedTextContent = previousSubTreeDirectionTextContent;
}

View File

@ -21,14 +21,13 @@ import {
$isDecoratorNode,
$isElementNode,
$isLineBreakNode,
$isParagraphNode,
$isRootNode,
$isTextNode,
$setSelection,
SELECTION_CHANGE_COMMAND,
TextNode,
} from '.';
import {DOM_ELEMENT_TYPE, TEXT_TYPE_TO_FORMAT} from './LexicalConstants';
import {TEXT_TYPE_TO_FORMAT} from './LexicalConstants';
import {
markCollapsedSelectionFormat,
markSelectionChangeFromDOMUpdate,
@ -57,6 +56,7 @@ import {
getElementByKeyOrThrow,
getTextNodeOffset,
INTERNAL_$isBlock,
isHTMLElement,
isSelectionCapturedInDecoratorInput,
isSelectionWithinEditor,
removeDOMBlockCursorElement,
@ -1225,9 +1225,9 @@ export class RangeSelection implements BaseSelection {
selectedTextNodes.push(selectedNode);
}
}
const applyFormatToParagraphs = (alignWith: number | null) => {
const applyFormatToElements = (alignWith: number | null) => {
selectedNodes.forEach((node) => {
if ($isParagraphNode(node)) {
if ($isElementNode(node)) {
const newFormat = node.getFormatFlags(formatType, alignWith);
node.setTextFormat(newFormat);
}
@ -1239,7 +1239,7 @@ export class RangeSelection implements BaseSelection {
this.toggleFormat(formatType);
// When changing format, we should stop composition
$setCompositionKey(null);
applyFormatToParagraphs(alignWithFormat);
applyFormatToElements(alignWithFormat);
return;
}
@ -1271,7 +1271,7 @@ export class RangeSelection implements BaseSelection {
formatType,
alignWithFormat,
);
applyFormatToParagraphs(firstNextFormat);
applyFormatToElements(firstNextFormat);
const lastIndex = selectedTextNodesLength - 1;
let lastNode = selectedTextNodes[lastIndex];
@ -2086,7 +2086,7 @@ function $internalResolveSelectionPoint(
// need to figure out (using the offset) what text
// node should be selected.
if (dom.nodeType === DOM_ELEMENT_TYPE) {
if (isHTMLElement(dom)) {
// Resolve element to a ElementNode, or TextNode, or null
let moveSelectionToEnd = false;
// Given we're moving selection to another node, selection is
@ -2924,7 +2924,7 @@ export function updateDOMSelection(
rootElement === document.activeElement
) {
const selectionTarget: null | Range | HTMLElement | Text =
nextSelection instanceof RangeSelection &&
$isRangeSelection(nextSelection) &&
nextSelection.anchor.type === 'element'
? (nextAnchorNode.childNodes[nextAnchorOffset] as HTMLElement | Text) ||
null

View File

@ -55,6 +55,9 @@ import {
} from '.';
import {
COMPOSITION_SUFFIX,
DOM_DOCUMENT_FRAGMENT_TYPE,
DOM_DOCUMENT_TYPE,
DOM_ELEMENT_TYPE,
DOM_TEXT_TYPE,
HAS_DIRTY_NODES,
LTR_REGEX,
@ -193,8 +196,20 @@ export function $isTokenOrSegmented(node: TextNode): boolean {
return node.isToken() || node.isSegmented();
}
export function isDOMTextNode(node: Node): node is Text {
return node.nodeType === DOM_TEXT_TYPE;
/**
* @param node - The element being tested
* @returns Returns true if node is an DOM Text node, false otherwise.
*/
export function isDOMTextNode(node: unknown): node is Text {
return isDOMNode(node) && node.nodeType === DOM_TEXT_TYPE;
}
/**
* @param node - The element being tested
* @returns Returns true if node is an DOM Document node, false otherwise.
*/
export function isDOMDocumentNode(node: unknown): node is Document {
return isDOMNode(node) && node.nodeType === DOM_DOCUMENT_TYPE;
}
export function getDOMTextNode(element: Node | null): Text | null {
@ -633,10 +648,7 @@ export function createUID(): string {
}
export function getAnchorTextFromDOM(anchorNode: Node): null | string {
if (anchorNode.nodeType === DOM_TEXT_TYPE) {
return anchorNode.nodeValue;
}
return null;
return isDOMTextNode(anchorNode) ? anchorNode.nodeValue : null;
}
export function $updateSelectedTextFromDOM(
@ -1304,15 +1316,25 @@ export function getParentElement(node: Node): HTMLElement | null {
: parentElement;
}
export function getDOMOwnerDocument(
target: EventTarget | null,
): Document | null {
return isDOMDocumentNode(target)
? target
: isHTMLElement(target)
? target.ownerDocument
: null;
}
export function scrollIntoViewIfNeeded(
editor: LexicalEditor,
selectionRect: DOMRect,
rootElement: HTMLElement,
): void {
const doc = rootElement.ownerDocument;
const defaultView = doc.defaultView;
const doc = getDOMOwnerDocument(rootElement);
const defaultView = getDefaultView(doc);
if (defaultView === null) {
if (doc === null || defaultView === null) {
return;
}
let {top: currentTop, bottom: currentBottom} = selectionRect;
@ -1414,9 +1436,9 @@ export function $hasAncestor(
return false;
}
export function getDefaultView(domElem: HTMLElement): Window | null {
const ownerDoc = domElem.ownerDocument;
return (ownerDoc && ownerDoc.defaultView) || null;
export function getDefaultView(domElem: EventTarget | null): Window | null {
const ownerDoc = getDOMOwnerDocument(domElem);
return ownerDoc ? ownerDoc.defaultView : null;
}
export function getWindow(editor: LexicalEditor): Window {
@ -1658,6 +1680,19 @@ export function getDOMSelection(targetWindow: null | Window): null | Selection {
return !CAN_USE_DOM ? null : (targetWindow || window).getSelection();
}
/**
* Returns the selection for the defaultView of the ownerDocument of given EventTarget.
*
* @param eventTarget The node to get the selection from
* @returns a Selection or null
*/
export function getDOMSelectionFromTarget(
eventTarget: null | EventTarget,
): null | Selection {
const defaultView = getDefaultView(eventTarget);
return defaultView ? defaultView.getSelection() : null;
}
export function $splitNode(
node: ElementNode,
offset: number,
@ -1736,7 +1771,7 @@ export function isHTMLAnchorElement(x: unknown): x is HTMLAnchorElement {
* @returns Returns true if x is an HTML element, false otherwise.
*/
export function isHTMLElement(x: unknown): x is HTMLElement {
return isDOMNode(x) && x.nodeType === 1;
return isDOMNode(x) && x.nodeType === DOM_ELEMENT_TYPE;
}
/**
@ -1757,7 +1792,7 @@ export function isDOMNode(x: unknown): x is Node {
* @returns Returns true if x is a document fragment, false otherwise.
*/
export function isDocumentFragment(x: unknown): x is DocumentFragment {
return isDOMNode(x) && x.nodeType === 11;
return isDOMNode(x) && x.nodeType === DOM_DOCUMENT_FRAGMENT_TYPE;
}
/**

View File

@ -63,6 +63,7 @@ import invariant from 'shared/invariant';
import * as ReactTestUtils from 'shared/react-test-utils';
import {emptyFunction} from '../../LexicalUtils';
import {SerializedParagraphNode} from '../../nodes/LexicalParagraphNode';
import {
$createTestDecoratorNode,
$createTestElementNode,
@ -1027,7 +1028,7 @@ describe('LexicalEditor tests', () => {
editable ? 'editable' : 'non-editable'
})`, async () => {
const JSON_EDITOR_STATE =
'{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"123","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';
'{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"123","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';
init();
const contentEditable = editor.getRootElement();
editor.setEditable(editable);
@ -1194,6 +1195,8 @@ describe('LexicalEditor tests', () => {
__prev: null,
__size: 1,
__style: '',
__textFormat: 0,
__textStyle: '',
__type: 'root',
});
expect(paragraph).toEqual({
@ -1281,6 +1284,8 @@ describe('LexicalEditor tests', () => {
__prev: null,
__size: 1,
__style: '',
__textFormat: 0,
__textStyle: '',
__type: 'root',
});
expect(parsedParagraph).toEqual({
@ -1363,6 +1368,8 @@ describe('LexicalEditor tests', () => {
__prev: null,
__size: 1,
__style: '',
__textFormat: 0,
__textStyle: '',
__type: 'root',
});
expect(parsedParagraph).toEqual({
@ -2775,8 +2782,8 @@ describe('LexicalEditor tests', () => {
return new CustomParagraphNode(node.__key);
}
static importJSON() {
return new CustomParagraphNode();
static importJSON(serializedNode: SerializedParagraphNode) {
return new CustomParagraphNode().updateFromJSON(serializedNode);
}
}

View File

@ -63,6 +63,8 @@ describe('LexicalEditorState tests', () => {
__prev: null,
__size: 1,
__style: '',
__textFormat: 0,
__textStyle: '',
__type: 'root',
});
expect(paragraph).toEqual({
@ -149,6 +151,8 @@ describe('LexicalEditorState tests', () => {
__prev: null,
__size: 0,
__style: '',
__textFormat: 0,
__textStyle: '',
__type: 'root',
},
],

View File

@ -21,6 +21,7 @@ import {
NodeKey,
ParagraphNode,
RangeSelection,
SerializedLexicalNode,
SerializedTextNode,
TextNode,
} from 'lexical';
@ -48,8 +49,8 @@ class TestNode extends LexicalNode {
return document.createElement('div');
}
static importJSON() {
return new TestNode();
static importJSON(serializedNode: SerializedLexicalNode) {
return new TestNode().updateFromJSON(serializedNode);
}
}
@ -62,8 +63,8 @@ class InlineDecoratorNode extends DecoratorNode<string> {
return new InlineDecoratorNode();
}
static importJSON() {
return new InlineDecoratorNode();
static importJSON(serializedNode: SerializedLexicalNode) {
return new InlineDecoratorNode().updateFromJSON(serializedNode);
}
createDOM(): HTMLElement {

View File

@ -24,6 +24,7 @@ import {
TextNode,
} from 'lexical';
import {SerializedElementNode} from '../..';
import {$assertRangeSelection, initializeUnitTest, invariant} from '../utils';
describe('LexicalSelection tests', () => {
@ -801,8 +802,8 @@ describe('Regression tests for #6701', () => {
static getType() {
return 'inline-element-node';
}
static importJSON() {
return new InlineElementNode();
static importJSON(serializedNode: SerializedElementNode) {
return new InlineElementNode().updateFromJSON(serializedNode);
}
isInline() {
return true;

File diff suppressed because one or more lines are too long

View File

@ -373,20 +373,12 @@ describe('$applyNodeReplacement', () => {
return new ExtendedTextNode(node.__text, node.getKey());
}
initWithTextNode(node: TextNode): this {
this.__text = node.__text;
TextNode.prototype.afterCloneFrom.call(this, node);
return this;
}
initWithJSON(serializedNode: SerializedTextNode): this {
this.setTextContent(serializedNode.text);
this.setFormat(serializedNode.format);
this.setDetail(serializedNode.detail);
this.setMode(serializedNode.mode);
this.setStyle(serializedNode.style);
return this;
const self = this.getWritable();
TextNode.prototype.updateFromJSON.call(self, node.exportJSON());
return self;
}
static importJSON(serializedNode: SerializedTextNode): ExtendedTextNode {
return $createExtendedTextNode().initWithJSON(serializedNode);
return $createExtendedTextNode().updateFromJSON(serializedNode);
}
}
class ExtendedExtendedTextNode extends ExtendedTextNode {
@ -402,7 +394,7 @@ describe('$applyNodeReplacement', () => {
static importJSON(
serializedNode: SerializedTextNode,
): ExtendedExtendedTextNode {
return $createExtendedExtendedTextNode().initWithJSON(serializedNode);
return $createExtendedExtendedTextNode().updateFromJSON(serializedNode);
}
}
function $createExtendedTextNode(text: string = '') {

View File

@ -175,11 +175,7 @@ export class TestElementNode extends ElementNode {
static importJSON(
serializedNode: SerializedTestElementNode,
): TestInlineElementNode {
const node = $createTestInlineElementNode();
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
return $createTestInlineElementNode().updateFromJSON(serializedNode);
}
createDOM() {
@ -207,7 +203,7 @@ export class TestTextNode extends TextNode {
}
static importJSON(serializedNode: SerializedTestTextNode): TestTextNode {
return new TestTextNode(serializedNode.text);
return new TestTextNode().updateFromJSON(serializedNode);
}
}
@ -225,11 +221,7 @@ export class TestInlineElementNode extends ElementNode {
static importJSON(
serializedNode: SerializedTestInlineElementNode,
): TestInlineElementNode {
const node = $createTestInlineElementNode();
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
return $createTestInlineElementNode().updateFromJSON(serializedNode);
}
createDOM() {
@ -263,11 +255,7 @@ export class TestShadowRootNode extends ElementNode {
static importJSON(
serializedNode: SerializedTestShadowRootNode,
): TestShadowRootNode {
const node = $createTestShadowRootNode();
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
return $createTestShadowRootNode().updateFromJSON(serializedNode);
}
createDOM() {
@ -301,16 +289,11 @@ export class TestSegmentedNode extends TextNode {
static importJSON(
serializedNode: SerializedTestSegmentedNode,
): TestSegmentedNode {
const node = $createTestSegmentedNode(serializedNode.text);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
return $createTestSegmentedNode().updateFromJSON(serializedNode);
}
}
export function $createTestSegmentedNode(text: string): TestSegmentedNode {
export function $createTestSegmentedNode(text: string = ''): TestSegmentedNode {
return new TestSegmentedNode(text).setMode('segmented');
}
@ -328,11 +311,9 @@ export class TestExcludeFromCopyElementNode extends ElementNode {
static importJSON(
serializedNode: SerializedTestExcludeFromCopyElementNode,
): TestExcludeFromCopyElementNode {
const node = $createTestExcludeFromCopyElementNode();
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
return $createTestExcludeFromCopyElementNode().updateFromJSON(
serializedNode,
);
}
createDOM() {
@ -366,7 +347,7 @@ export class TestDecoratorNode extends DecoratorNode<JSX.Element> {
static importJSON(
serializedNode: SerializedTestDecoratorNode,
): TestDecoratorNode {
return $createTestDecoratorNode();
return $createTestDecoratorNode().updateFromJSON(serializedNode);
}
static importDOM() {

View File

@ -45,6 +45,7 @@ export type {
DOMExportOutput,
DOMExportOutputMap,
LexicalNode,
LexicalUpdateJSON,
NodeKey,
NodeMap,
SerializedLexicalNode,
@ -180,12 +181,15 @@ export {
$setCompositionKey,
$setSelection,
$splitNode,
getDOMOwnerDocument,
getDOMSelection,
getDOMSelectionFromTarget,
getDOMTextNode,
getEditorPropertyFromDOMNode,
getNearestEditorFromDOMNode,
isBlockDomNode,
isDocumentFragment,
isDOMDocumentNode,
isDOMNode,
isDOMTextNode,
isDOMUnmanaged,

View File

@ -7,7 +7,6 @@
*/
import type {KlassConstructor, LexicalEditor} from '../LexicalEditor';
import type {NodeKey} from '../LexicalNode';
import type {ElementNode} from './LexicalElementNode';
import {EditorConfig} from 'lexical';
@ -25,9 +24,6 @@ export interface DecoratorNode<T> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class DecoratorNode<T> extends LexicalNode {
['constructor']!: KlassConstructor<typeof DecoratorNode<T>>;
constructor(key?: NodeKey) {
super(key);
}
/**
* The returned value is added to the LexicalEditor._decorators

View File

@ -17,7 +17,13 @@ import type {
PointType,
RangeSelection,
} from '../LexicalSelection';
import type {KlassConstructor, LexicalEditor, Spread} from 'lexical';
import type {
KlassConstructor,
LexicalEditor,
LexicalUpdateJSON,
Spread,
TextFormatType,
} from 'lexical';
import {IS_IOS, IS_SAFARI} from 'shared/environment';
import invariant from 'shared/invariant';
@ -27,6 +33,7 @@ import {
DOUBLE_LINE_BREAK,
ELEMENT_FORMAT_TO_TYPE,
ELEMENT_TYPE_TO_FORMAT,
TEXT_TYPE_TO_FORMAT,
} from '../LexicalConstants';
import {LexicalNode} from '../LexicalNode';
import {
@ -41,6 +48,7 @@ import {
$isRootOrShadowRoot,
isHTMLElement,
removeFromParent,
toggleTextFormatType,
} from '../LexicalUtils';
export type SerializedElementNode<
@ -51,6 +59,8 @@ export type SerializedElementNode<
direction: 'ltr' | 'rtl' | null;
format: ElementFormatType;
indent: number;
textFormat?: number;
textStyle?: string;
},
SerializedLexicalNode
>;
@ -307,6 +317,10 @@ export class ElementNode extends LexicalNode {
__indent: number;
/** @internal */
__dir: 'ltr' | 'rtl' | null;
/** @internal */
__textFormat: number;
/** @internal */
__textStyle: string;
constructor(key?: NodeKey) {
super(key);
@ -317,6 +331,8 @@ export class ElementNode extends LexicalNode {
this.__style = '';
this.__indent = 0;
this.__dir = null;
this.__textFormat = 0;
this.__textStyle = '';
}
afterCloneFrom(prevNode: this) {
@ -328,6 +344,8 @@ export class ElementNode extends LexicalNode {
this.__format = prevNode.__format;
this.__style = prevNode.__style;
this.__dir = prevNode.__dir;
this.__textFormat = prevNode.__textFormat;
this.__textStyle = prevNode.__textStyle;
}
getFormat(): number {
@ -527,6 +545,10 @@ export class ElementNode extends LexicalNode {
const self = this.getLatest();
return self.__dir;
}
getTextFormat(): number {
const self = this.getLatest();
return self.__textFormat;
}
hasFormat(type: ElementFormatType): boolean {
if (type !== '') {
const formatFlag = ELEMENT_TYPE_TO_FORMAT[type];
@ -534,6 +556,25 @@ export class ElementNode extends LexicalNode {
}
return false;
}
hasTextFormat(type: TextFormatType): boolean {
const formatFlag = TEXT_TYPE_TO_FORMAT[type];
return (this.getTextFormat() & formatFlag) !== 0;
}
/**
* Returns the format flags applied to the node as a 32-bit integer.
*
* @returns a number representing the TextFormatTypes applied to the node.
*/
getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number {
const self = this.getLatest();
const format = self.__textFormat;
return toggleTextFormatType(format, type, alignWithFormat);
}
getTextStyle(): string {
const self = this.getLatest();
return self.__textStyle;
}
// Mutators
@ -614,6 +655,16 @@ export class ElementNode extends LexicalNode {
self.__style = style || '';
return this;
}
setTextFormat(type: number): this {
const self = this.getWritable();
self.__textFormat = type;
return self;
}
setTextStyle(style: string): this {
const self = this.getWritable();
self.__textStyle = style;
return self;
}
setIndent(indentLevel: number): this {
const self = this.getWritable();
self.__indent = indentLevel;
@ -787,7 +838,7 @@ export class ElementNode extends LexicalNode {
}
// JSON serialization
exportJSON(): SerializedElementNode {
return {
const json: SerializedElementNode = {
children: [],
direction: this.getDirection(),
format: this.getFormatType(),
@ -797,6 +848,26 @@ export class ElementNode extends LexicalNode {
// that use the serialized string representation.
...super.exportJSON(),
};
const textFormat = this.getTextFormat();
const textStyle = this.getTextStyle();
if (textFormat !== 0) {
json.textFormat = textFormat;
}
if (textStyle !== '') {
json.textStyle = textStyle;
}
return json;
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedElementNode>,
): this {
return super
.updateFromJSON(serializedNode)
.setFormat(serializedNode.format)
.setIndent(serializedNode.indent)
.setDirection(serializedNode.direction)
.setTextFormat(serializedNode.textFormat || 0)
.setTextStyle(serializedNode.textStyle || '');
}
// These are intended to be extends for specific element heuristics.
insertNewAfter(

View File

@ -14,9 +14,12 @@ import type {
SerializedLexicalNode,
} from '../LexicalNode';
import {DOM_TEXT_TYPE} from '../LexicalConstants';
import {LexicalNode} from '../LexicalNode';
import {$applyNodeReplacement, isBlockDomNode} from '../LexicalUtils';
import {
$applyNodeReplacement,
isBlockDomNode,
isDOMTextNode,
} from '../LexicalUtils';
export type SerializedLineBreakNode = SerializedLexicalNode;
@ -64,7 +67,7 @@ export class LineBreakNode extends LexicalNode {
static importJSON(
serializedLineBreakNode: SerializedLineBreakNode,
): LineBreakNode {
return $createLineBreakNode();
return $createLineBreakNode().updateFromJSON(serializedLineBreakNode);
}
}
@ -128,8 +131,5 @@ function isLastChildInBlockNode(node: Node): boolean {
}
function isWhitespaceDomTextNode(node: Node): boolean {
return (
node.nodeType === DOM_TEXT_TYPE &&
/^( |\t|\r?\n)+$/.test(node.textContent || '')
);
return isDOMTextNode(node) && /^( |\t|\r?\n)+$/.test(node.textContent || '');
}

View File

@ -17,24 +17,21 @@ import type {
DOMConversionOutput,
DOMExportOutput,
LexicalNode,
NodeKey,
} from '../LexicalNode';
import type {RangeSelection} from '../LexicalSelection';
import type {
ElementFormatType,
SerializedElementNode,
} from './LexicalElementNode';
import type {RangeSelection} from 'lexical';
import {TEXT_TYPE_TO_FORMAT} from '../LexicalConstants';
import {
$applyNodeReplacement,
getCachedClassNameArray,
isHTMLElement,
setNodeIndentFromDOM,
toggleTextFormatType,
} from '../LexicalUtils';
import {ElementNode} from './LexicalElementNode';
import {$isTextNode, TextFormatType} from './LexicalTextNode';
import {$isTextNode} from './LexicalTextNode';
export type SerializedParagraphNode = Spread<
{
@ -47,68 +44,15 @@ export type SerializedParagraphNode = Spread<
/** @noInheritDoc */
export class ParagraphNode extends ElementNode {
['constructor']!: KlassConstructor<typeof ParagraphNode>;
/** @internal */
__textFormat: number;
__textStyle: string;
constructor(key?: NodeKey) {
super(key);
this.__textFormat = 0;
this.__textStyle = '';
}
static getType(): string {
return 'paragraph';
}
getTextFormat(): number {
const self = this.getLatest();
return self.__textFormat;
}
setTextFormat(type: number): this {
const self = this.getWritable();
self.__textFormat = type;
return self;
}
hasTextFormat(type: TextFormatType): boolean {
const formatFlag = TEXT_TYPE_TO_FORMAT[type];
return (this.getTextFormat() & formatFlag) !== 0;
}
/**
* Returns the format flags applied to the node as a 32-bit integer.
*
* @returns a number representing the TextFormatTypes applied to the node.
*/
getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number {
const self = this.getLatest();
const format = self.__textFormat;
return toggleTextFormatType(format, type, alignWithFormat);
}
getTextStyle(): string {
const self = this.getLatest();
return self.__textStyle;
}
setTextStyle(style: string): this {
const self = this.getWritable();
self.__textStyle = style;
return self;
}
static clone(node: ParagraphNode): ParagraphNode {
return new ParagraphNode(node.__key);
}
afterCloneFrom(prevNode: this) {
super.afterCloneFrom(prevNode);
this.__textFormat = prevNode.__textFormat;
this.__textStyle = prevNode.__textStyle;
}
// View
createDOM(config: EditorConfig): HTMLElement {
@ -160,17 +104,13 @@ export class ParagraphNode extends ElementNode {
}
static importJSON(serializedNode: SerializedParagraphNode): ParagraphNode {
const node = $createParagraphNode();
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
node.setTextFormat(serializedNode.textFormat);
return node;
return $createParagraphNode().updateFromJSON(serializedNode);
}
exportJSON(): SerializedParagraphNode {
return {
...super.exportJSON(),
// These are included explicitly for backwards compatibility
textFormat: this.getTextFormat(),
textStyle: this.getTextStyle(),
};

View File

@ -98,11 +98,7 @@ export class RootNode extends ElementNode {
static importJSON(serializedNode: SerializedRootNode): RootNode {
// We don't create a root, and instead use the existing root.
const node = $getRoot();
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
return $getRoot().updateFromJSON(serializedNode);
}
collapseAtStart(): true {

View File

@ -33,12 +33,6 @@ export class TabNode extends TextNode {
return new TabNode(node.__key);
}
afterCloneFrom(prevNode: this): void {
super.afterCloneFrom(prevNode);
// TabNode __text can be either '\t' or ''. insertText will remove the empty Node
this.__text = prevNode.__text;
}
constructor(key?: NodeKey) {
super('\t', key);
this.__detail = IS_UNMERGEABLE;
@ -60,22 +54,25 @@ export class TabNode extends TextNode {
}
static importJSON(serializedTabNode: SerializedTabNode): TabNode {
const node = $createTabNode();
node.setFormat(serializedTabNode.format);
node.setStyle(serializedTabNode.style);
return node;
return $createTabNode().updateFromJSON(serializedTabNode);
}
setTextContent(_text: string): this {
invariant(false, 'TabNode does not support setTextContent');
setTextContent(text: string): this {
invariant(
text === '\t' || text === '',
'TabNode does not support setTextContent',
);
return super.setTextContent(text);
}
setDetail(_detail: TextDetailType | number): this {
invariant(false, 'TabNode does not support setDetail');
setDetail(detail: TextDetailType | number): this {
invariant(detail === IS_UNMERGEABLE, 'TabNode does not support setDetail');
return this;
}
setMode(_type: TextModeType): this {
invariant(false, 'TabNode does not support setMode');
setMode(type: TextModeType): this {
invariant(type !== 'normal', 'TabNode does not support setMode');
return this;
}
canInsertTextBefore(): boolean {

View File

@ -17,6 +17,7 @@ import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
LexicalUpdateJSON,
NodeKey,
SerializedLexicalNode,
} from '../LexicalNode';
@ -29,8 +30,6 @@ import invariant from 'shared/invariant';
import {
COMPOSITION_SUFFIX,
DETAIL_TYPE_TO_DETAIL,
DOM_ELEMENT_TYPE,
DOM_TEXT_TYPE,
IS_BOLD,
IS_CODE,
IS_DIRECTIONLESS,
@ -62,6 +61,7 @@ import {
$setCompositionKey,
getCachedClassNameArray,
internalMarkSiblingsAsDirty,
isDOMTextNode,
isHTMLElement,
isInlineDomNode,
toggleTextFormatType,
@ -308,13 +308,14 @@ export class TextNode extends LexicalNode {
afterCloneFrom(prevNode: this): void {
super.afterCloneFrom(prevNode);
this.__text = prevNode.__text;
this.__format = prevNode.__format;
this.__style = prevNode.__style;
this.__mode = prevNode.__mode;
this.__detail = prevNode.__detail;
}
constructor(text: string, key?: NodeKey) {
constructor(text: string = '', key?: NodeKey) {
super(key);
this.__text = text;
this.__format = 0;
@ -606,12 +607,17 @@ export class TextNode extends LexicalNode {
}
static importJSON(serializedNode: SerializedTextNode): TextNode {
const node = $createTextNode(serializedNode.text);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
return $createTextNode().updateFromJSON(serializedNode);
}
updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedTextNode>): this {
return super
.updateFromJSON(serializedNode)
.setTextContent(serializedNode.text)
.setFormat(serializedNode.format)
.setDetail(serializedNode.detail)
.setMode(serializedNode.mode)
.setStyle(serializedNode.style);
}
// This improves Lexical's basic text output in copy+paste plus
@ -1138,13 +1144,13 @@ function convertBringAttentionToElement(
const preParentCache = new WeakMap<Node, null | Node>();
function isNodePre(node: Node): boolean {
return (
node.nodeName === 'PRE' ||
(node.nodeType === DOM_ELEMENT_TYPE &&
(node as HTMLElement).style !== undefined &&
(node as HTMLElement).style.whiteSpace !== undefined &&
(node as HTMLElement).style.whiteSpace.startsWith('pre'))
);
if (!isHTMLElement(node)) {
return false;
} else if (node.nodeName === 'PRE') {
return true;
}
const whiteSpace = node.style.whiteSpace;
return typeof whiteSpace === 'string' && whiteSpace.startsWith('pre');
}
export function findParentPreDOMNode(node: Node) {
@ -1260,8 +1266,8 @@ function findTextInLine(text: Text, forward: boolean): null | Text {
node = parentElement;
}
node = sibling;
if (node.nodeType === DOM_ELEMENT_TYPE) {
const display = (node as HTMLElement).style.display;
if (isHTMLElement(node)) {
const display = node.style.display;
if (
(display === '' && !isInlineDomNode(node)) ||
(display !== '' && !display.startsWith('inline'))
@ -1273,8 +1279,8 @@ function findTextInLine(text: Text, forward: boolean): null | Text {
while ((descendant = forward ? node.firstChild : node.lastChild) !== null) {
node = descendant;
}
if (node.nodeType === DOM_TEXT_TYPE) {
return node as Text;
if (isDOMTextNode(node)) {
return node;
} else if (node.nodeName === 'BR') {
return null;
}