diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 40a5efb595e..cd5946f8341 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -29,6 +29,7 @@ import { $isTextNode, ArtificialNode__DO_NOT_USE, ElementNode, + getRegisteredNode, isDocumentFragment, isInlineDomNode, } from 'lexical'; @@ -110,7 +111,7 @@ function $appendNodesToHTML( target = clone; } const children = $isElementNode(target) ? target.getChildren() : []; - const registeredNode = editor._nodes.get(target.getType()); + const registeredNode = getRegisteredNode(editor, target.getType()); let exportOutput; // Use HTMLConfig overrides, if available. diff --git a/packages/lexical-list/src/LexicalListItemNode.ts b/packages/lexical-list/src/LexicalListItemNode.ts index a58d1cc0b6e..da9fd244564 100644 --- a/packages/lexical-list/src/LexicalListItemNode.ts +++ b/packages/lexical-list/src/LexicalListItemNode.ts @@ -9,7 +9,6 @@ import type {ListNode, ListType} from './'; import type { BaseSelection, - DOMConversionMap, DOMConversionOutput, DOMExportOutput, EditorConfig, @@ -34,6 +33,7 @@ import { $isElementNode, $isParagraphNode, $isRangeSelection, + buildImportMap, ElementNode, LexicalEditor, } from 'lexical'; @@ -79,20 +79,45 @@ export class ListItemNode extends ElementNode { /** @internal */ __checked?: boolean; - static getType(): string { - return 'listitem'; - } - - static clone(node: ListItemNode): ListItemNode { - return new ListItemNode(node.__value, node.__checked, node.__key); + /** @internal */ + $config() { + return this.config('listitem', { + $transform: (node: ListItemNode): void => { + if (node.__checked == null) { + return; + } + const parent = node.getParent(); + if ($isListNode(parent)) { + if (parent.getListType() !== 'check' && node.getChecked() != null) { + node.setChecked(undefined); + } + } + }, + importDOM: buildImportMap({ + li: () => ({ + conversion: $convertListItemElement, + priority: 0, + }), + }), + }); } - constructor(value?: number, checked?: boolean, key?: NodeKey) { + constructor( + value: number = 1, + checked: undefined | boolean = undefined, + key?: NodeKey, + ) { super(key); this.__value = value === undefined ? 1 : value; this.__checked = checked; } + afterCloneFrom(prevNode: this): void { + super.afterCloneFrom(prevNode); + this.__value = prevNode.__value; + this.__checked = prevNode.__checked; + } + createDOM(config: EditorConfig): HTMLElement { const element = document.createElement('li'); this.updateListItemDOM(null, element, config); @@ -136,34 +161,6 @@ export class ListItemNode extends ElementNode { return false; } - static transform(): (node: LexicalNode) => void { - return (node: LexicalNode) => { - invariant($isListItemNode(node), 'node is not a ListItemNode'); - if (node.__checked == null) { - return; - } - const parent = node.getParent(); - if ($isListNode(parent)) { - if (parent.getListType() !== 'check' && node.getChecked() != null) { - node.setChecked(undefined); - } - } - }; - } - - static importDOM(): DOMConversionMap | null { - return { - li: () => ({ - conversion: $convertListItemElement, - priority: 0, - }), - }; - } - - static importJSON(serializedNode: SerializedListItemNode): ListItemNode { - return $createListItemNode().updateFromJSON(serializedNode); - } - updateFromJSON( serializedNode: LexicalUpdateJSON, ): this { diff --git a/packages/lexical-list/src/LexicalListNode.ts b/packages/lexical-list/src/LexicalListNode.ts index f81f6b42e99..f3bfb4f745c 100644 --- a/packages/lexical-list/src/LexicalListNode.ts +++ b/packages/lexical-list/src/LexicalListNode.ts @@ -15,7 +15,7 @@ import { $applyNodeReplacement, $createTextNode, $isElementNode, - DOMConversionMap, + buildImportMap, DOMConversionOutput, DOMExportOutput, EditorConfig, @@ -28,7 +28,6 @@ import { SerializedElementNode, Spread, } from 'lexical'; -import invariant from 'shared/invariant'; import normalizeClassNames from 'shared/normalizeClassNames'; import {$createListItemNode, $isListItemNode, ListItemNode} from '.'; @@ -60,14 +59,25 @@ export class ListNode extends ElementNode { /** @internal */ __listType: ListType; - static getType(): string { - return 'list'; - } - - static clone(node: ListNode): ListNode { - const listType = node.__listType || TAG_TO_LIST_TYPE[node.__tag]; - - return new ListNode(listType, node.__start, node.__key); + /** @internal */ + $config() { + return this.config('list', { + $transform: (node: ListNode): void => { + mergeNextSiblingListIfSameType(node); + updateChildrenListItemValue(node); + }, + + importDOM: buildImportMap({ + ol: () => ({ + conversion: $convertListNode, + priority: 0, + }), + ul: () => ({ + conversion: $convertListNode, + priority: 0, + }), + }), + }); } constructor(listType: ListType = 'number', start: number = 1, key?: NodeKey) { @@ -78,8 +88,15 @@ export class ListNode extends ElementNode { this.__start = start; } + afterCloneFrom(prevNode: this): void { + super.afterCloneFrom(prevNode); + this.__listType = prevNode.__listType; + this.__tag = prevNode.__tag; + this.__start = prevNode.__start; + } + getTag(): ListNodeTagType { - return this.__tag; + return this.getLatest().__tag; } setListType(type: ListType): this { @@ -90,11 +107,11 @@ export class ListNode extends ElementNode { } getListType(): ListType { - return this.__listType; + return this.getLatest().__listType; } getStart(): number { - return this.__start; + return this.getLatest().__start; } setStart(start: number): this { @@ -129,31 +146,6 @@ export class ListNode extends ElementNode { return false; } - static transform(): (node: LexicalNode) => void { - return (node: LexicalNode) => { - invariant($isListNode(node), 'node is not a ListNode'); - mergeNextSiblingListIfSameType(node); - updateChildrenListItemValue(node); - }; - } - - static importDOM(): DOMConversionMap | null { - return { - ol: () => ({ - conversion: $convertListNode, - priority: 0, - }), - ul: () => ({ - conversion: $convertListNode, - priority: 0, - }), - }; - } - - static importJSON(serializedNode: SerializedListNode): ListNode { - return $createListNode().updateFromJSON(serializedNode); - } - updateFromJSON(serializedNode: LexicalUpdateJSON): this { return super .updateFromJSON(serializedNode) @@ -323,7 +315,9 @@ function isDomChecklist(domNode: HTMLElement) { return false; } -function $convertListNode(domNode: HTMLElement): DOMConversionOutput { +function $convertListNode( + domNode: HTMLOListElement | HTMLUListElement, +): DOMConversionOutput { const nodeName = domNode.nodeName.toLowerCase(); let node = null; if (nodeName === 'ol') { diff --git a/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts b/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts index 09e664b3f90..b690b3fd4dd 100644 --- a/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts +++ b/packages/lexical-list/src/__tests__/unit/LexicalListNode.test.ts @@ -334,24 +334,5 @@ describe('LexicalListNode tests', () => { expect(bulletList.__listType).toBe('bullet'); }); }); - - test('ListNode.clone() without list type (backward compatibility)', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const olNode = ListNode.clone({ - __key: '1', - __start: 1, - __tag: 'ol', - } as unknown as ListNode); - const ulNode = ListNode.clone({ - __key: '1', - __start: 1, - __tag: 'ul', - } as unknown as ListNode); - expect(olNode.__listType).toBe('number'); - expect(ulNode.__listType).toBe('bullet'); - }); - }); }); }); diff --git a/packages/lexical-overflow/src/index.ts b/packages/lexical-overflow/src/index.ts index c6284e3fd95..6c54e266faf 100644 --- a/packages/lexical-overflow/src/index.ts +++ b/packages/lexical-overflow/src/index.ts @@ -14,26 +14,20 @@ import type { } from 'lexical'; import {$applyNodeReplacement, ElementNode} from 'lexical'; -import invariant from 'shared/invariant'; export type SerializedOverflowNode = SerializedElementNode; /** @noInheritDoc */ export class OverflowNode extends ElementNode { - static getType(): string { - return 'overflow'; - } - - static clone(node: OverflowNode): OverflowNode { - return new OverflowNode(node.__key); - } - - static importJSON(serializedNode: SerializedOverflowNode): OverflowNode { - return $createOverflowNode().updateFromJSON(serializedNode); - } - - static importDOM(): null { - return null; + /** @internal */ + $config() { + return this.config('overflow', { + $transform(node: OverflowNode) { + if (node.isEmpty()) { + node.remove(); + } + }, + }); } createDOM(config: EditorConfig): HTMLElement { @@ -60,15 +54,6 @@ export class OverflowNode extends ElementNode { excludeFromCopy(): boolean { return true; } - - static transform(): (node: LexicalNode) => void { - return (node: LexicalNode) => { - invariant($isOverflowNode(node), 'node is not a OverflowNode'); - if (node.isEmpty()) { - node.remove(); - } - }; - } } export function $createOverflowNode(): OverflowNode { diff --git a/packages/lexical-playground/src/nodes/PollNode.tsx b/packages/lexical-playground/src/nodes/PollNode.tsx index 3e066b4f5f8..4b5d2e6e18d 100644 --- a/packages/lexical-playground/src/nodes/PollNode.tsx +++ b/packages/lexical-playground/src/nodes/PollNode.tsx @@ -8,16 +8,19 @@ import type {JSX} from 'react'; -import {makeStateWrapper} from '@lexical/utils'; import { + $getState, + $setState, + buildImportMap, createState, DecoratorNode, - DOMConversionMap, DOMConversionOutput, DOMExportOutput, LexicalNode, SerializedLexicalNode, Spread, + StateConfigValue, + type StateValueOrUpdater, } from 'lexical'; import * as React from 'react'; @@ -66,7 +69,9 @@ export type SerializedPollNode = Spread< SerializedLexicalNode >; -function $convertPollElement(domNode: HTMLElement): DOMConversionOutput | null { +function $convertPollElement( + domNode: HTMLSpanElement, +): DOMConversionOutput | null { const question = domNode.getAttribute('data-lexical-poll-question'); const options = domNode.getAttribute('data-lexical-poll-options'); if (question !== null && options !== null) { @@ -94,39 +99,46 @@ function parseOptions(json: unknown): Options { return options; } -const questionState = makeStateWrapper( - createState('question', { - parse: (v) => (typeof v === 'string' ? v : ''), - }), -); -const optionsState = makeStateWrapper( - createState('options', { - isEqual: (a, b) => - a.length === b.length && JSON.stringify(a) === JSON.stringify(b), - parse: parseOptions, - }), -); +const questionState = createState('question', { + parse: (v) => (typeof v === 'string' ? v : ''), +}); +const optionsState = createState('options', { + isEqual: (a, b) => + a.length === b.length && JSON.stringify(a) === JSON.stringify(b), + parse: parseOptions, +}); export class PollNode extends DecoratorNode { - static getType(): string { - return 'poll'; + $config() { + return this.config('poll', { + importDOM: buildImportMap({ + span: (domNode) => + domNode.getAttribute('data-lexical-poll-question') !== null + ? { + conversion: $convertPollElement, + priority: 2, + } + : null, + }), + stateConfigs: [ + {flat: true, stateConfig: questionState}, + {flat: true, stateConfig: optionsState}, + ], + }); } - static clone(node: PollNode): PollNode { - return new PollNode(node.__key); + getQuestion(): StateConfigValue { + return $getState(this, questionState); } - - static importJSON(serializedNode: SerializedPollNode): PollNode { - return $createPollNode( - serializedNode.question, - serializedNode.options, - ).updateFromJSON(serializedNode); + setQuestion(valueOrUpdater: StateValueOrUpdater): this { + return $setState(this, questionState, valueOrUpdater); + } + getOptions(): StateConfigValue { + return $getState(this, optionsState); + } + setOptions(valueOrUpdater: StateValueOrUpdater): this { + return $setState(this, optionsState, valueOrUpdater); } - - getQuestion = questionState.makeGetterMethod(); - setQuestion = questionState.makeSetterMethod(); - getOptions = optionsState.makeGetterMethod(); - setOptions = optionsState.makeSetterMethod(); addOption(option: Option): this { return this.setOptions((options) => [...options, option]); @@ -175,20 +187,6 @@ export class PollNode extends DecoratorNode { }); } - static importDOM(): DOMConversionMap | null { - return { - span: (domNode: HTMLElement) => { - if (!domNode.hasAttribute('data-lexical-poll-question')) { - return null; - } - return { - conversion: $convertPollElement, - priority: 2, - }; - }, - }; - } - exportDOM(): DOMExportOutput { const element = document.createElement('span'); element.setAttribute('data-lexical-poll-question', this.getQuestion()); diff --git a/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts b/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts index 0a4a54e97c9..849926f38d1 100644 --- a/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts +++ b/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts @@ -10,21 +10,18 @@ import {IS_CHROME} from '@lexical/utils'; import { $createParagraphNode, $isElementNode, - DOMConversionMap, + buildImportMap, DOMConversionOutput, EditorConfig, ElementNode, LexicalEditor, LexicalNode, RangeSelection, - SerializedElementNode, } from 'lexical'; import {$isCollapsibleContainerNode} from './CollapsibleContainerNode'; import {$isCollapsibleContentNode} from './CollapsibleContentNode'; -type SerializedCollapsibleTitleNode = SerializedElementNode; - export function $convertSummaryElement( domNode: HTMLElement, ): DOMConversionOutput | null { @@ -34,13 +31,23 @@ export function $convertSummaryElement( }; } +/** @noInheritDoc */ export class CollapsibleTitleNode extends ElementNode { - static getType(): string { - return 'collapsible-title'; - } - - static clone(node: CollapsibleTitleNode): CollapsibleTitleNode { - return new CollapsibleTitleNode(node.__key); + /** @internal */ + $config() { + return this.config('collapsible-title', { + $transform(node: CollapsibleTitleNode) { + if (node.isEmpty()) { + node.remove(); + } + }, + importDOM: buildImportMap({ + summary: () => ({ + conversion: $convertSummaryElement, + priority: 1, + }), + }), + }); } createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement { @@ -66,34 +73,6 @@ export class CollapsibleTitleNode extends ElementNode { return false; } - static importDOM(): DOMConversionMap | null { - return { - summary: (domNode: HTMLElement) => { - return { - conversion: $convertSummaryElement, - priority: 1, - }; - }, - }; - } - - static importJSON( - serializedNode: SerializedCollapsibleTitleNode, - ): CollapsibleTitleNode { - return $createCollapsibleTitleNode().updateFromJSON(serializedNode); - } - - static transform(): (node: LexicalNode) => void { - return (node: LexicalNode) => { - if (!$isCollapsibleTitleNode(node)) { - throw new Error('node is not a CollapsibleTitleNode'); - } - if (node.isEmpty()) { - node.remove(); - } - }; - } - insertNewAfter(_: RangeSelection, restoreSelection = true): ElementNode { const containerNode = this.getParentOrThrow(); diff --git a/packages/lexical-react/src/LexicalNestedComposer.tsx b/packages/lexical-react/src/LexicalNestedComposer.tsx index 4e49b2fec9a..d46aee63e2c 100644 --- a/packages/lexical-react/src/LexicalNestedComposer.tsx +++ b/packages/lexical-react/src/LexicalNestedComposer.tsx @@ -16,7 +16,9 @@ import { LexicalComposerContext, } from '@lexical/react/LexicalComposerContext'; import { + createSharedNodeState, EditorThemeClasses, + getRegisteredNode, Klass, LexicalEditor, LexicalNode, @@ -138,6 +140,7 @@ export function LexicalNestedComposer({ klass: entry.klass, replace: entry.replace, replaceWithKlass: entry.replaceWithKlass, + sharedNodeState: createSharedNodeState(entry.klass), transforms: getTransformSetFromKlass(entry.klass), }); } @@ -161,13 +164,17 @@ export function LexicalNestedComposer({ replace = options.with; replaceWithKlass = options.withKlass || null; } - const registeredKlass = initialEditor._nodes.get(klass.getType()); + const registeredKlass = getRegisteredNode( + initialEditor, + klass.getType(), + ); initialEditor._nodes.set(klass.getType(), { exportDOM: registeredKlass ? registeredKlass.exportDOM : undefined, klass, replace, replaceWithKlass, + sharedNodeState: createSharedNodeState(klass), transforms: getTransformSetFromKlass(klass), }); } diff --git a/packages/lexical/src/LexicalConstants.ts b/packages/lexical/src/LexicalConstants.ts index f943b1b8713..e6d2a2716c2 100644 --- a/packages/lexical/src/LexicalConstants.ts +++ b/packages/lexical/src/LexicalConstants.ts @@ -156,3 +156,4 @@ export const TEXT_TYPE_TO_MODE: Record = { }; export const NODE_STATE_KEY = '$'; +export const PROTOTYPE_CONFIG_METHOD = '$config'; diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index f0b3ef52ade..f5a84dc7f84 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -23,6 +23,7 @@ import {cloneEditorState, createEmptyEditorState} from './LexicalEditorState'; import {addRootElementEvents, removeRootElementEvents} from './LexicalEvents'; import {flushRootMutations, initMutationObserver} from './LexicalMutations'; import {LexicalNode} from './LexicalNode'; +import {createSharedNodeState, SharedNodeState} from './LexicalNodeState'; import { $commitPendingUpdates, internalGetActiveEditor, @@ -42,6 +43,8 @@ import { getCachedTypeToNodeMap, getDefaultView, getDOMSelection, + getStaticNodeConfig, + hasOwn, markNodesWithTypesAsDirty, } from './LexicalUtils'; import {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; @@ -86,9 +89,25 @@ export type TextNodeThemeClasses = { }; export type EditorUpdateOptions = { + /** + * A function to run once the update is complete. See also {@link $onUpdate}. + */ onUpdate?: () => void; + /** + * Setting this to true will suppress all node + * transforms for this update cycle. + * Useful for synchronizing updates in some cases. + */ skipTransforms?: true; + /** + * A tag to identify this update, in an update listener, for instance. + * See also {@link $addUpdateTag}. + */ tag?: UpdateTag | UpdateTag[]; + /** + * If true, prevents this update from being batched, forcing it to + * run synchronously. + */ discrete?: true; /** @internal */ event?: undefined | UIEvent | Event | null; @@ -98,9 +117,13 @@ export type EditorSetOptions = { tag?: string; }; -export type EditorFocusOptions = { +export interface EditorFocusOptions { + /** + * Where to move selection when the editor is + * focused. Can be rootStart, rootEnd, or undefined. Defaults to rootEnd. + */ defaultSelection?: 'rootStart' | 'rootEnd'; -}; +} export type EditorThemeClasses = { blockCursor?: EditorThemeClassName; @@ -186,11 +209,16 @@ export type HTMLConfig = { import?: DOMConversionMap; }; +/** + * A LexicalNode class or LexicalNodeReplacement configuration + */ +export type LexicalNodeConfig = Klass | LexicalNodeReplacement; + export type CreateEditorArgs = { disableEvents?: boolean; editorState?: EditorState; namespace?: string; - nodes?: ReadonlyArray | LexicalNodeReplacement>; + nodes?: ReadonlyArray; onError?: ErrorHandler; parentEditor?: LexicalEditor; editable?: boolean; @@ -209,6 +237,7 @@ export type RegisteredNode = { editor: LexicalEditor, targetNode: LexicalNode, ) => DOMExportOutput; + sharedNodeState: SharedNodeState; }; export type Transform = (node: T) => void; @@ -513,13 +542,12 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor { replace = options.with; replaceWithKlass = options.withKlass || null; } + const {ownNodeConfig} = getStaticNodeConfig(klass); // Ensure custom nodes implement required methods and replaceWithKlass is instance of base klass. if (__DEV__) { // ArtificialNode__DO_NOT_USE can get renamed, so we use the type - const nodeType = - Object.prototype.hasOwnProperty.call(klass, 'getType') && - klass.getType(); const name = klass.name; + const nodeType = hasOwn(klass, 'getType') && klass.getType(); if (replaceWithKlass) { invariant( @@ -528,8 +556,11 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor { replaceWithKlass.name, name, ); + } else if (replace) { + console.warn( + `Override for ${name} specifies 'replace' without 'withKlass'. 'withKlass' will be required in a future version.`, + ); } - if ( name !== 'RootNode' && nodeType !== 'root' && @@ -537,33 +568,23 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor { ) { const proto = klass.prototype; ['getType', 'clone'].forEach((method) => { - // eslint-disable-next-line no-prototype-builtins - if (!klass.hasOwnProperty(method)) { + if (!hasOwn(klass, method)) { console.warn(`${name} must implement static "${method}" method`); } }); - if ( - // eslint-disable-next-line no-prototype-builtins - !klass.hasOwnProperty('importDOM') && - // eslint-disable-next-line no-prototype-builtins - klass.hasOwnProperty('exportDOM') - ) { + if (!hasOwn(klass, 'importDOM') && hasOwn(klass, 'exportDOM')) { console.warn( `${name} should implement "importDOM" if using a custom "exportDOM" method to ensure HTML serialization (important for copy & paste) works as expected`, ); } if ($isDecoratorNode(proto)) { - // eslint-disable-next-line no-prototype-builtins - if (!proto.hasOwnProperty('decorate')) { + if (!hasOwn(proto, 'decorate')) { console.warn( `${proto.constructor.name} must implement "decorate" method`, ); } } - if ( - // eslint-disable-next-line no-prototype-builtins - !klass.hasOwnProperty('importJSON') - ) { + if (!hasOwn(klass, 'importJSON')) { console.warn( `${name} should implement "importJSON" method to ensure JSON and default HTML serialization works as expected`, ); @@ -573,6 +594,9 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor { const type = klass.getType(); const transform = klass.transform(); const transforms = new Set>(); + if (ownNodeConfig && ownNodeConfig.$transform) { + transforms.add(ownNodeConfig.$transform); + } if (transform !== null) { transforms.add(transform); } @@ -581,6 +605,7 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor { klass, replace, replaceWithKlass, + sharedNodeState: createSharedNodeState(nodes[i]), transforms, }); } @@ -607,6 +632,7 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor { return editor; } + export class LexicalEditor { ['constructor']!: KlassConstructor; @@ -938,7 +964,7 @@ export class LexicalEditor { } /** @internal */ - private getRegisteredNode(klass: Klass): RegisteredNode { + getRegisteredNode(klass: Klass): RegisteredNode { const registeredNode = this._nodes.get(klass.getType()); if (registeredNode === undefined) { @@ -953,7 +979,7 @@ export class LexicalEditor { } /** @internal */ - private resolveRegisteredNodeAfterReplacements( + resolveRegisteredNodeAfterReplacements( registeredNode: RegisteredNode, ): RegisteredNode { while (registeredNode.replaceWithKlass) { @@ -1276,14 +1302,6 @@ export class LexicalEditor { * where Lexical editor state can be safely mutated. * @param updateFn - A function that has access to writable editor state. * @param options - A bag of options to control the behavior of the update. - * @param options.onUpdate - A function to run once the update is complete. - * Useful for synchronizing updates in some cases. - * @param options.skipTransforms - Setting this to true will suppress all node - * transforms for this update cycle. - * @param options.tag - A tag to identify this update, in an update listener, for instance. - * Some tags are reserved by the core and control update behavior in different ways. - * @param options.discrete - If true, prevents this update from being batched, forcing it to - * run synchronously. */ update(updateFn: () => void, options?: EditorUpdateOptions): void { updateEditor(this, updateFn, options); @@ -1297,8 +1315,6 @@ export class LexicalEditor { * * @param callbackFn - A function to run after the editor is focused. * @param options - A bag of options - * @param options.defaultSelection - Where to move selection when the editor is - * focused. Can be rootStart, rootEnd, or undefined. Defaults to rootEnd. */ focus(callbackFn?: () => void, options: EditorFocusOptions = {}): void { const rootElement = this._rootElement; diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index bd332889869..21e9cd83598 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -26,10 +26,17 @@ import { $isRootNode, $isTextNode, type DecoratorNode, - ElementNode, + type ElementNode, NODE_STATE_KEY, } from '.'; -import {$updateStateFromJSON, type NodeState} from './LexicalNodeState'; +import {PROTOTYPE_CONFIG_METHOD} from './LexicalConstants'; +import { + $updateStateFromJSON, + type NodeState, + type NodeStateJSON, + type Prettify, + type RequiredNodeStateConfig, +} from './LexicalNodeState'; import { $getSelection, $isNodeSelection, @@ -53,6 +60,7 @@ import { $setNodeKey, $setSelection, errorOnInsertTextNodeOnRoot, + getRegisteredNode, internalMarkNodeAsDirty, removeFromParent, } from './LexicalUtils'; @@ -67,9 +75,158 @@ export type SerializedLexicalNode = { type: string; /** A numeric version for this schema, defaulting to 1, but not generally recommended for use */ version: number; + /** + * Any state persisted with the NodeState API that is not + * configured for flat storage + */ [NODE_STATE_KEY]?: Record; }; +/** + * EXPERIMENTAL + * The configuration of a node returned by LexicalNode.$config() + * + * @example + * ```ts + * class CustomText extends TextNode { + * $config() { + * return this.config('custom-text', {extends: TextNode}}; + * } + * } + * ``` + */ +export interface StaticNodeConfigValue< + T extends LexicalNode, + Type extends string, +> { + /** + * The exact type of T.getType(), e.g. 'text' - the method itself must + * have a more generic 'string' type to be compatible wtih subclassing. + */ + readonly type?: Type; + /** + * An alternative to the internal static transform() method + * that provides better type inference. + */ + readonly $transform?: (node: T) => void; + /** + * An alternative to the static importJSON() method + * that provides better type inference. + */ + readonly $importJSON?: (serializedNode: SerializedLexicalNode) => T; + /** + * An alternative to the static importDOM() method + */ + readonly importDOM?: DOMConversionMap; + /** + * EXPERIMENTAL + * + * An array of RequiredNodeStateConfig to initialize your node with + * its state requirements. This may be used to configure serialization of + * that state. + * + * This function will be called (at most) once per editor initialization, + * directly on your node's prototype. It must not depend on any state + * initialized in the constructor. + * + * @example + * ```ts + * const flatState = createState("flat", {parse: parseNumber}); + * const nestedState = createState("nested", {parse: parseNumber}); + * class MyNode extends TextNode { + * $config() { + * return this.config( + * 'my-node', + * { + * extends: TextNode, + * stateConfigs: [ + * { stateConfig: flatState, flat: true}, + * nestedState, + * ] + * }, + * ); + * } + * } + * ``` + */ + readonly stateConfigs?: readonly RequiredNodeStateConfig[]; + /** + * If specified, this must be the exact superclass of the node. It is not + * checked at compile time and it is provided automatically at runtime. + * + * You would want to specify this when you are extending a node that + * has non-trivial configuration in its $config such + * as required state. If you do not specify this, the inferred + * types for your node class might be missing some of that. + */ + readonly extends?: Klass; +} + +/** + * This is the type of LexicalNode.$config() that can be + * overridden by subclasses. + */ +export type BaseStaticNodeConfig = { + readonly [K in string]?: StaticNodeConfigValue; +}; + +/** + * Used to extract the node and type from a StaticNodeConfigRecord + */ +export type StaticNodeConfig< + T extends LexicalNode, + Type extends string, +> = BaseStaticNodeConfig & { + readonly [K in Type]?: StaticNodeConfigValue; +}; + +/** + * Any StaticNodeConfigValue (for generics and collections) + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyStaticNodeConfigValue = StaticNodeConfigValue; + +/** + * @internal + * + * This is the more specific type than BaseStaticNodeConfig that a subclass + * should return from $config() + */ +export type StaticNodeConfigRecord< + Type extends string, + Config extends AnyStaticNodeConfigValue, +> = BaseStaticNodeConfig & { + readonly [K in Type]?: Config; +}; + +/** + * Extract the type from a node based on its $config + * + * @example + * ```ts + * type TextNodeType = GetStaticNodeType; + * // ? 'text' + * ``` + */ +export type GetStaticNodeType = ReturnType< + T[typeof PROTOTYPE_CONFIG_METHOD] +> extends StaticNodeConfig + ? Type + : string; + +/** + * The most precise type we can infer for the JSON that will + * be produced by T.exportJSON(). + * + * Do not use this for the return type of T.exportJSON()! It must be + * a more generic type to be compatible with subclassing. + */ +export type LexicalExportJSON = Prettify< + Omit, 'type'> & { + type: GetStaticNodeType; + } & NodeStateJSON +>; + /** * Omit the children, type, and version properties from the given SerializedLexicalNode definition. */ @@ -158,6 +315,29 @@ export function $removeNode( } } +export type DOMConversionProp = ( + node: T, +) => DOMConversion | null; + +export type DOMConversionPropByTagName = DOMConversionProp< + K extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[K] : HTMLElement +>; + +export type DOMConversionTagNameMap = { + [NodeName in K]?: DOMConversionPropByTagName; +}; + +/** + * An identity function that will infer the type of DOM nodes + * based on tag names to make it easier to construct a + * DOMConversionMap. + */ +export function buildImportMap(importMap: { + [NodeName in K]: DOMConversionPropByTagName; +}): DOMConversionMap { + return importMap as unknown as DOMConversionMap; +} + export type DOMConversion = { conversion: DOMConversionFn; priority?: 0 | 1 | 2 | 3 | 4; @@ -174,7 +354,7 @@ export type DOMChildConversion = ( export type DOMConversionMap = Record< NodeName, - (node: T) => DOMConversion | null + DOMConversionProp >; type NodeName = string; @@ -248,6 +428,40 @@ export class LexicalNode { ); } + /** + * Override this to implement the new static node configuration protocol, + * this method is called directly on the prototype and must not depend + * on anything initialized in the constructor. Generally it should be + * a trivial implementation. + * + * @example + * ```ts + * class MyNode extends TextNode { + * $config() { + * return this.config('my-node', {extends: TextNode}); + * } + * } + * ``` + */ + $config(): BaseStaticNodeConfig { + return {}; + } + + /** + * This is a convenience method for $config that + * aids in type inference. See {@link LexicalNode.$config} + * for example usage. + */ + config>( + type: Type, + config: Config, + ): StaticNodeConfigRecord { + const parentKlass = + config.extends || Object.getPrototypeOf(this.constructor); + Object.assign(config, {extends: parentKlass, type}); + return {[type]: config} as StaticNodeConfigRecord; + } + /** * Perform any state updates on the clone of prevNode that are not already * handled by the constructor call in the static clone method. If you have @@ -299,10 +513,14 @@ export class LexicalNode { * */ afterCloneFrom(prevNode: this): void { - this.__parent = prevNode.__parent; - this.__next = prevNode.__next; - this.__prev = prevNode.__prev; - this.__state = prevNode.__state; + if (this.__key === prevNode.__key) { + this.__parent = prevNode.__parent; + this.__next = prevNode.__next; + this.__prev = prevNode.__prev; + this.__state = prevNode.__state; + } else if (prevNode.__state) { + this.__state = prevNode.__state.getWritable(this); + } } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1228,7 +1446,7 @@ function errorOnTypeKlassMismatch( type: string, klass: Klass, ): void { - const registeredNode = getActiveEditor()._nodes.get(type); + const registeredNode = getRegisteredNode(getActiveEditor(), type); // Common error - split in its own invariant if (registeredNode === undefined) { invariant( diff --git a/packages/lexical/src/LexicalNodeState.ts b/packages/lexical/src/LexicalNodeState.ts index cc250e929b2..32f730ddc54 100644 --- a/packages/lexical/src/LexicalNodeState.ts +++ b/packages/lexical/src/LexicalNodeState.ts @@ -6,71 +6,20 @@ * */ -import type {LexicalNode} from './LexicalNode'; - import invariant from 'shared/invariant'; -import {NODE_STATE_KEY} from './LexicalConstants'; +import { + $getEditor, + type Klass, + type LexicalNode, + type LexicalNodeConfig, + NODE_STATE_KEY, + type Spread, + type StaticNodeConfigRecord, +} from '.'; +import {PROTOTYPE_CONFIG_METHOD} from './LexicalConstants'; import {errorOnReadOnly} from './LexicalUpdates'; - -function coerceToJSON(v: unknown): unknown { - return v; -} - -/** - * The return value of {@link createState}, for use with - * {@link $getState} and {@link $setState}. - */ -export class StateConfig { - /** The string key used when serializing this state to JSON */ - readonly key: K; - /** The parse function from the StateValueConfig passed to createState */ - readonly parse: (value?: unknown) => V; - /** - * The unparse function from the StateValueConfig passed to createState, - * with a default that is simply a pass-through that assumes the value is - * JSON serializable. - */ - readonly unparse: (value: V) => unknown; - /** - * An equality function from the StateValueConfig, with a default of - * Object.is. - */ - readonly isEqual: (a: V, b: V) => boolean; - /** - * The result of `stateValueConfig.parse(undefined)`, which is computed only - * once and used as the default value. When the current value `isEqual` to - * the `defaultValue`, it will not be serialized to JSON. - */ - readonly defaultValue: V; - constructor(key: K, stateValueConfig: StateValueConfig) { - this.key = key; - this.parse = stateValueConfig.parse.bind(stateValueConfig); - this.unparse = (stateValueConfig.unparse || coerceToJSON).bind( - stateValueConfig, - ); - this.isEqual = (stateValueConfig.isEqual || Object.is).bind( - stateValueConfig, - ); - this.defaultValue = this.parse(undefined); - } -} - -/** - * For advanced use cases, using this type is not recommended unless - * it is required (due to TypeScript's lack of features like - * higher-kinded types). - * - * A {@link StateConfig} type with any key and any value that can be - * used in situations where the key and value type can not be known, - * such as in a generic constraint when working with a collection of - * StateConfig. - * - * {@link StateConfigKey} and {@link StateConfigValue} will be - * useful when this is used as a generic constraint. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type AnyStateConfig = StateConfig; +import {getRegisteredNodeOrThrow, getStaticNodeConfig} from './LexicalUtils'; /** * Get the value type (V) from a StateConfig @@ -90,12 +39,119 @@ export type StateConfigKey = S extends StateConfig< > ? K : never; + /** * A value type, or an updater for that value type. For use with * {@link $setState} or any user-defined wrappers around it. */ export type ValueOrUpdater = V | ((prevValue: V) => V); +/** + * A type alias to make it easier to define setter methods on your node class + * + * @example + * ```ts + * const fooState = createState("foo", { parse: ... }); + * class MyClass extends TextNode { + * // ... + * setFoo(valueOrUpdater: StateValueOrUpdater): this { + * return $setState(this, fooState, valueOrUpdater); + * } + * } + * ``` + */ +export type StateValueOrUpdater = ValueOrUpdater< + StateConfigValue +>; + +export interface NodeStateConfig { + stateConfig: S; + flat?: boolean; +} + +export type RequiredNodeStateConfig = + | NodeStateConfig + | AnyStateConfig; + +export type StateConfigJSON = S extends StateConfig + ? {[Key in K]?: V} + : Record; + +export type RequiredNodeStateConfigJSON< + Config extends RequiredNodeStateConfig, + Flat extends boolean, +> = StateConfigJSON< + Config extends NodeStateConfig + ? Spread extends {flat: Flat} + ? S + : never + : false extends Flat + ? Config + : never +>; + +// eslint-disable-next-line @typescript-eslint/ban-types +export type Prettify = {[K in keyof T]: T[K]} & {}; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export type UnionToIntersection = ( + T extends any ? (x: T) => any : never +) extends (x: infer R) => any + ? R + : never; +/* eslint-enable @typescript-eslint/no-explicit-any */ + +export type CollectStateJSON< + Tuple extends readonly RequiredNodeStateConfig[], + Flat extends boolean, +> = UnionToIntersection< + {[K in keyof Tuple]: RequiredNodeStateConfigJSON}[number] +>; + +type GetStaticNodeConfig = ReturnType< + T[typeof PROTOTYPE_CONFIG_METHOD] +> extends infer Record + ? Record extends StaticNodeConfigRecord + ? Config & {readonly type: Type} + : never + : never; +type GetStaticNodeConfigs = + GetStaticNodeConfig extends infer OwnConfig + ? OwnConfig extends never + ? [] + : OwnConfig extends {extends: Klass} + ? GetStaticNodeConfig extends infer ParentNodeConfig + ? ParentNodeConfig extends never + ? [OwnConfig] + : [OwnConfig, ...GetStaticNodeConfigs] + : OwnConfig + : [OwnConfig] + : []; + +type CollectStateConfigs = Configs extends [ + infer OwnConfig, + ...infer ParentConfigs, +] + ? OwnConfig extends {stateConfigs: infer StateConfigs} + ? StateConfigs extends readonly RequiredNodeStateConfig[] + ? [...StateConfigs, ...CollectStateConfigs] + : CollectStateConfigs + : CollectStateConfigs + : []; + +export type GetNodeStateConfig = CollectStateConfigs< + GetStaticNodeConfigs +>; + +/** + * The NodeState JSON produced by this LexicalNode + */ +export type NodeStateJSON = Prettify< + { + [NODE_STATE_KEY]?: Prettify, false>>; + } & CollectStateJSON, true> +>; + /** * Configure a value to be used with StateConfig. * @@ -176,6 +232,61 @@ export interface StateValueConfig { isEqual?: (a: V, b: V) => boolean; } +/** + * The return value of {@link createState}, for use with + * {@link $getState} and {@link $setState}. + */ +export class StateConfig { + /** The string key used when serializing this state to JSON */ + readonly key: K; + /** The parse function from the StateValueConfig passed to createState */ + readonly parse: (value?: unknown) => V; + /** + * The unparse function from the StateValueConfig passed to createState, + * with a default that is simply a pass-through that assumes the value is + * JSON serializable. + */ + readonly unparse: (value: V) => unknown; + /** + * An equality function from the StateValueConfig, with a default of + * Object.is. + */ + readonly isEqual: (a: V, b: V) => boolean; + /** + * The result of `stateValueConfig.parse(undefined)`, which is computed only + * once and used as the default value. When the current value `isEqual` to + * the `defaultValue`, it will not be serialized to JSON. + */ + readonly defaultValue: V; + constructor(key: K, stateValueConfig: StateValueConfig) { + this.key = key; + this.parse = stateValueConfig.parse.bind(stateValueConfig); + this.unparse = (stateValueConfig.unparse || coerceToJSON).bind( + stateValueConfig, + ); + this.isEqual = (stateValueConfig.isEqual || Object.is).bind( + stateValueConfig, + ); + this.defaultValue = this.parse(undefined); + } +} + +/** + * For advanced use cases, using this type is not recommended unless + * it is required (due to TypeScript's lack of features like + * higher-kinded types). + * + * A {@link StateConfig} type with any key and any value that can be + * used in situations where the key and value type can not be known, + * such as in a generic constraint when working with a collection of + * StateConfig. + * + * {@link StateConfigKey} and {@link StateConfigValue} will be + * useful when this is used as a generic constraint. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyStateConfig = StateConfig; + /** * Create a StateConfig for the given string key and StateValueConfig. * @@ -196,30 +307,6 @@ export function createState( return new StateConfig(key, valueConfig); } -/** - * Given two versions of a node and a stateConfig, compare their state values - * using `$getState(nodeVersion, stateConfig, 'direct')`. - * If the values are equal according to `stateConfig.isEqual`, return `null`, - * otherwise return `[value, prevValue]`. - * - * This is useful for implementing updateDOM. Note that the `'direct'` - * version argument is used for both nodes. - * - * @param node Any LexicalNode - * @param prevNode A previous version of node - * @param stateConfig The configuration of the state to read - * @returns `[value, prevValue]` if changed, otherwise `null` - */ -export function $getStateChange( - node: T, - prevNode: T, - stateConfig: StateConfig, -): null | [value: V, prevValue: V] { - const value = $getState(node, stateConfig, 'direct'); - const prevValue = $getState(prevNode, stateConfig, 'direct'); - return stateConfig.isEqual(value, prevValue) ? null : [value, prevValue]; -} - /** * The accessor for working with node state. This will read the value for the * state on the given node, and will return `stateConfig.defaultValue` if the @@ -254,29 +341,27 @@ export function $getState( } /** - * @internal + * Given two versions of a node and a stateConfig, compare their state values + * using `$getState(nodeVersion, stateConfig, 'direct')`. + * If the values are equal according to `stateConfig.isEqual`, return `null`, + * otherwise return `[value, prevValue]`. * - * Register the config to this node's sharedConfigMap and throw an exception in - * `__DEV__` when a collision is detected. + * This is useful for implementing updateDOM. Note that the `'direct'` + * version argument is used for both nodes. + * + * @param node Any LexicalNode + * @param prevNode A previous version of node + * @param stateConfig The configuration of the state to read + * @returns `[value, prevValue]` if changed, otherwise `null` */ -function $checkCollision( - node: Node, +export function $getStateChange( + node: T, + prevNode: T, stateConfig: StateConfig, - state: NodeState, -): void { - if (__DEV__) { - const collision = state.sharedConfigMap.get(stateConfig.key); - if (collision !== undefined && collision !== stateConfig) { - invariant( - false, - '$setState: State key collision %s detected in %s node with type %s and key %s. Only one StateConfig with a given key should be used on a node.', - JSON.stringify(stateConfig.key), - node.constructor.name, - node.getType(), - node.getKey(), - ); - } - } +): null | [value: V, prevValue: V] { + const value = $getState(node, stateConfig, 'direct'); + const prevValue = $getState(prevNode, stateConfig, 'direct'); + return stateConfig.isEqual(value, prevValue) ? null : [value, prevValue]; } /** @@ -326,8 +411,87 @@ export function $setState( return writable; } +/** + * @internal + * + * Register the config to this node's sharedConfigMap and throw an exception in + * `__DEV__` when a collision is detected. + */ +function $checkCollision( + node: Node, + stateConfig: StateConfig, + state: NodeState, +): void { + if (__DEV__) { + const collision = state.sharedNodeState.sharedConfigMap.get( + stateConfig.key, + ); + if (collision !== undefined && collision !== stateConfig) { + invariant( + false, + '$setState: State key collision %s detected in %s node with type %s and key %s. Only one StateConfig with a given key should be used on a node.', + JSON.stringify(stateConfig.key), + node.constructor.name, + node.getType(), + node.getKey(), + ); + } + } +} + +/** + * @internal + * + * Opaque state to be stored on the editor's RegisterNode for use by NodeState + */ +export type SharedNodeState = { + sharedConfigMap: SharedConfigMap; + flatKeys: Set; +}; + +/** + * @internal + * + * Create the state to store on RegisteredNode + */ +export function createSharedNodeState( + nodeConfig: LexicalNodeConfig, +): SharedNodeState { + const sharedConfigMap = new Map(); + const flatKeys = new Set(); + for ( + let klass = + typeof nodeConfig === 'function' ? nodeConfig : nodeConfig.replace; + klass.prototype && klass.prototype.getType !== undefined; + klass = Object.getPrototypeOf(klass) + ) { + const {ownNodeConfig} = getStaticNodeConfig(klass); + if (ownNodeConfig && ownNodeConfig.stateConfigs) { + for (const requiredStateConfig of ownNodeConfig.stateConfigs) { + let stateConfig: AnyStateConfig; + if ('stateConfig' in requiredStateConfig) { + stateConfig = requiredStateConfig.stateConfig; + if (requiredStateConfig.flat) { + flatKeys.add(stateConfig.key); + } + } else { + stateConfig = requiredStateConfig; + } + sharedConfigMap.set(stateConfig.key, stateConfig); + } + } + } + return {flatKeys, sharedConfigMap}; +} + type KnownStateMap = Map; type UnknownStateRecord = Record; +/** + * @internal + * + * A Map of string keys to state configurations to be shared across nodes + * and/or node versions. + */ type SharedConfigMap = Map; /** @@ -364,8 +528,7 @@ export class NodeState { * imported but has not been parsed yet. * * It stays here until a get state requires us to parse it, and since we - * then know the value is safe we move it to knownState and garbage collect - * it at the next version. + * then know the value is safe we move it to knownState. * * Note that since only string keys are used here, we can only allow this * state to pass-through on export or on the next version since there is @@ -379,10 +542,11 @@ export class NodeState { /** * @internal * - * This sharedConfigMap is preserved across all versions of a given node and - * remains writable. It is how keys are resolved to configuration. + * This sharedNodeState is preserved across all instances of a given + * node type in an editor and remains writable. It is how keys are resolved + * to configuration. */ - readonly sharedConfigMap: SharedConfigMap; + readonly sharedNodeState: SharedNodeState; /** * @internal * @@ -396,15 +560,16 @@ export class NodeState { */ constructor( node: T, - sharedConfigMap: SharedConfigMap = new Map(), + sharedNodeState: SharedNodeState, unknownState: undefined | UnknownStateRecord = undefined, knownState: KnownStateMap = new Map(), size: number | undefined = undefined, ) { this.node = node; - this.sharedConfigMap = sharedConfigMap; + this.sharedNodeState = sharedNodeState; this.unknownState = unknownState; this.knownState = knownState; + const {sharedConfigMap} = this.sharedNodeState; const computedSize = size !== undefined ? size @@ -427,13 +592,21 @@ export class NodeState { this.size = computedSize; } - /** @internal */ + /** + * @internal + * + * Get the value from knownState, or parse it from unknownState + * if it contains the given key. + * + * Updates the sharedConfigMap when no known state is found. + * Updates unknownState and knownState when an unknownState is parsed. + */ getValue(stateConfig: StateConfig): V { const known = this.knownState.get(stateConfig) as V | undefined; if (known !== undefined) { return known; } - this.sharedConfigMap.set(stateConfig.key, stateConfig); + this.sharedNodeState.sharedConfigMap.set(stateConfig.key, stateConfig); let parsed = stateConfig.defaultValue; if (this.unknownState && stateConfig.key in this.unknownState) { const jsonValue = this.unknownState[stateConfig.key]; @@ -467,8 +640,9 @@ export class NodeState { * specific entries in the future when nodes can declare what * their required StateConfigs are. */ - toJSON(): {[NODE_STATE_KEY]?: UnknownStateRecord} { + toJSON(): NodeStateJSON { const state = {...this.unknownState}; + const flatState: Record = {}; for (const [stateConfig, v] of this.knownState) { if (stateConfig.isEqual(v, stateConfig.defaultValue)) { delete state[stateConfig.key]; @@ -476,7 +650,16 @@ export class NodeState { state[stateConfig.key] = stateConfig.unparse(v); } } - return undefinedIfEmpty(state) ? {[NODE_STATE_KEY]: state} : {}; + for (const key of this.sharedNodeState.flatKeys) { + if (key in state) { + flatState[key] = state[key]; + delete state[key]; + } + } + if (undefinedIfEmpty(state)) { + flatState[NODE_STATE_KEY] = state; + } + return flatState as NodeStateJSON; } /** @@ -499,18 +682,16 @@ export class NodeState { if (this.node === node) { return this; } + const {sharedNodeState, unknownState} = this; const nextKnownState = new Map(this.knownState); - const nextUnknownState = cloneUnknownState(this.unknownState); - if (nextUnknownState) { - // Garbage collection - for (const stateConfig of nextKnownState.keys()) { - delete nextUnknownState[stateConfig.key]; - } - } return new NodeState( node, - this.sharedConfigMap, - undefinedIfEmpty(nextUnknownState), + sharedNodeState, + parseAndPruneNextUnknownState( + sharedNodeState.sharedConfigMap, + nextKnownState, + unknownState, + ), nextKnownState, this.size, ); @@ -522,11 +703,15 @@ export class NodeState { value: V, ): void { const key = stateConfig.key; - this.sharedConfigMap.set(key, stateConfig); + this.sharedNodeState.sharedConfigMap.set(key, stateConfig); const {knownState, unknownState} = this; if ( !(knownState.has(stateConfig) || (unknownState && key in unknownState)) ) { + if (unknownState) { + delete unknownState[key]; + this.unknownState = undefinedIfEmpty(unknownState); + } this.size++; } knownState.set(stateConfig, value); @@ -547,7 +732,7 @@ export class NodeState { * @param v The unknown value from an UnknownStateRecord */ updateFromUnknown(k: string, v: unknown): void { - const stateConfig = this.sharedConfigMap.get(k); + const stateConfig = this.sharedNodeState.sharedConfigMap.get(k); if (stateConfig) { this.updateFromKnown(stateConfig, stateConfig.parse(v)); } else { @@ -580,54 +765,13 @@ export class NodeState { // the size starts at the number of known keys // and will be updated as we traverse the new state this.size = knownState.size; - this.unknownState = {}; + this.unknownState = undefined; if (unknownState) { for (const [k, v] of Object.entries(unknownState)) { this.updateFromUnknown(k, v); } } - this.unknownState = undefinedIfEmpty(this.unknownState); - } -} - -function computeSize( - sharedConfigMap: SharedConfigMap, - unknownState: UnknownStateRecord | undefined, - knownState: KnownStateMap, -): number { - let size = knownState.size; - if (unknownState) { - for (const k in unknownState) { - const sharedConfig = sharedConfigMap.get(k); - if (!sharedConfig || !knownState.has(sharedConfig)) { - size++; - } - } - } - return size; -} - -/** - * Return obj if it is an object with at least one property, otherwise - * return undefined. - */ -function undefinedIfEmpty(obj: undefined | T): undefined | T { - if (obj) { - for (const key in obj) { - return obj; - } } - return undefined; -} - -/** - * Return undefined if unknownState is undefined or an empty object, - * otherwise return a shallow clone of it. - */ -function cloneUnknownState( - unknownState: undefined | UnknownStateRecord, -): undefined | UnknownStateRecord { - return undefinedIfEmpty(unknownState) && {...unknownState}; } /** @@ -643,11 +787,22 @@ export function $getWritableNodeState( const writable = node.getWritable(); const state = writable.__state ? writable.__state.getWritable(writable) - : new NodeState(writable); + : new NodeState(writable, $getSharedNodeState(writable)); writable.__state = state; return state; } +/** + * @internal + * + * Get the SharedNodeState for a node on this editor + */ +export function $getSharedNodeState( + node: T, +): SharedNodeState { + return getRegisteredNodeOrThrow($getEditor(), node.getType()).sharedNodeState; +} + /** * @internal * @@ -676,7 +831,7 @@ export function $updateStateFromJSON( * to determine when TextNode are being merged, not a lot of use cases * otherwise. */ -export function $nodeStatesAreEquivalent( +export function nodeStatesAreEquivalent( a: undefined | NodeState, b: undefined | NodeState, ): boolean { @@ -687,50 +842,147 @@ export function $nodeStatesAreEquivalent( return false; } const keys = new Set(); - const hasUnequalMapEntry = ( - sourceState: NodeState, - otherState?: NodeState, - ): boolean => { - for (const [stateConfig, value] of sourceState.knownState) { - if (keys.has(stateConfig.key)) { - continue; - } - keys.add(stateConfig.key); - const otherValue = otherState - ? otherState.getValue(stateConfig) - : stateConfig.defaultValue; - if (otherValue !== value && !stateConfig.isEqual(otherValue, value)) { - return true; + return !( + (a && hasUnequalMapEntry(keys, a, b)) || + (b && hasUnequalMapEntry(keys, b, a)) || + (a && hasUnequalRecordEntry(keys, a, b)) || + (b && hasUnequalRecordEntry(keys, b, a)) + ); +} + +/** + * Compute the number of distinct keys that will be in a NodeState + */ +function computeSize( + sharedConfigMap: SharedConfigMap, + unknownState: UnknownStateRecord | undefined, + knownState: KnownStateMap, +): number { + let size = knownState.size; + if (unknownState) { + for (const k in unknownState) { + const sharedConfig = sharedConfigMap.get(k); + if (!sharedConfig || !knownState.has(sharedConfig)) { + size++; } } - return false; - }; - const hasUnequalRecordEntry = ( - sourceState: NodeState, - otherState?: NodeState, - ): boolean => { - const {unknownState} = sourceState; - const otherUnknownState = otherState ? otherState.unknownState : undefined; - if (unknownState) { - for (const [key, value] of Object.entries(unknownState)) { - if (keys.has(key)) { - continue; - } - keys.add(key); - const otherValue = otherUnknownState - ? otherUnknownState[key] - : undefined; - if (value !== otherValue) { - return true; + } + return size; +} + +/** + * @internal + * + * Return obj if it is an object with at least one property, otherwise + * return undefined. + */ +function undefinedIfEmpty(obj: undefined | T): undefined | T { + if (obj) { + for (const key in obj) { + return obj; + } + } + return undefined; +} + +/** + * @internal + * + * Cast the given v to unknown + */ +function coerceToJSON(v: unknown): unknown { + return v; +} + +/** + * @internal + * + * Parse all knowable values in an UnknownStateRecord into nextKnownState + * and return the unparsed values in a new UnknownStateRecord. Returns + * undefined if no unknown values remain. + */ +function parseAndPruneNextUnknownState( + sharedConfigMap: SharedConfigMap, + nextKnownState: KnownStateMap, + unknownState: undefined | UnknownStateRecord, +): undefined | UnknownStateRecord { + let nextUnknownState: undefined | UnknownStateRecord = undefined; + if (unknownState) { + for (const [k, v] of Object.entries(unknownState)) { + const stateConfig = sharedConfigMap.get(k); + if (stateConfig) { + if (!nextKnownState.has(stateConfig)) { + nextKnownState.set(stateConfig, stateConfig.parse(v)); } + } else { + nextUnknownState = nextUnknownState || {}; + nextUnknownState[k] = v; } } - return false; - }; - return !( - (a && hasUnequalMapEntry(a, b)) || - (b && hasUnequalMapEntry(b, a)) || - (a && hasUnequalRecordEntry(a, b)) || - (b && hasUnequalRecordEntry(b, a)) - ); + } + return nextUnknownState; +} + +/** + * @internal + * + * Compare each entry of sourceState.knownState that is not in keys to + * otherState (or the default value if otherState is undefined. + * Note that otherState will return the defaultValue as well if it + * has never been set. Any checked entry's key will be added to keys. + * + * @returns true if any difference is found, false otherwise + */ +function hasUnequalMapEntry( + keys: Set, + sourceState: NodeState, + otherState?: NodeState, +): boolean { + for (const [stateConfig, value] of sourceState.knownState) { + if (keys.has(stateConfig.key)) { + continue; + } + keys.add(stateConfig.key); + const otherValue = otherState + ? otherState.getValue(stateConfig) + : stateConfig.defaultValue; + if (otherValue !== value && !stateConfig.isEqual(otherValue, value)) { + return true; + } + } + return false; +} + +/** + * @internal + * + * Compare each entry of sourceState.unknownState that is not in keys to + * otherState.unknownState (or undefined if otherState is undefined). + * Any checked entry's key will be added to keys. + * + * Notably since we have already checked hasUnequalMapEntry on both sides, + * we do not do any parsing or checking of knownState. + * + * @returns true if any difference is found, false otherwise + */ +function hasUnequalRecordEntry( + keys: Set, + sourceState: NodeState, + otherState?: NodeState, +): boolean { + const {unknownState} = sourceState; + const otherUnknownState = otherState ? otherState.unknownState : undefined; + if (unknownState) { + for (const [key, value] of Object.entries(unknownState)) { + if (keys.has(key)) { + continue; + } + keys.add(key); + const otherValue = otherUnknownState ? otherUnknownState[key] : undefined; + if (value !== otherValue) { + return true; + } + } + } + return false; } diff --git a/packages/lexical/src/LexicalNormalization.ts b/packages/lexical/src/LexicalNormalization.ts index c47c998a541..259b4faba17 100644 --- a/packages/lexical/src/LexicalNormalization.ts +++ b/packages/lexical/src/LexicalNormalization.ts @@ -10,7 +10,7 @@ import type {RangeSelection, TextNode} from '.'; import type {PointType} from './LexicalSelection'; import {$isElementNode, $isTextNode} from '.'; -import {$nodeStatesAreEquivalent} from './LexicalNodeState'; +import {nodeStatesAreEquivalent} from './LexicalNodeState'; import {getActiveEditor} from './LexicalUpdates'; function $canSimpleTextNodesBeMerged( @@ -31,7 +31,7 @@ function $canSimpleTextNodesBeMerged( (node1Style === null || node1Style === node2Style) && (node1.__state === null || node1State === node2State || - $nodeStatesAreEquivalent(node1State, node2State)) + nodeStatesAreEquivalent(node1State, node2State)) ); } diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index 41b32e7229e..106fa1c3d2f 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -60,6 +60,7 @@ import { isLexicalEditor, removeDOMBlockCursorElement, scheduleMicroTask, + setPendingNodeToClone, updateDOMBlockCursorElement, } from './LexicalUtils'; @@ -422,6 +423,7 @@ export function parseEditorState( activeEditorState = editorState; isReadOnlyMode = false; activeEditor = editor; + setPendingNodeToClone(null); try { const registeredNodes = editor._nodes; @@ -925,6 +927,7 @@ function $beginUpdate( editor._updating = true; activeEditor = editor; const headless = editor._headless || editor.getRootElement() === null; + setPendingNodeToClone(null); try { if (editorStateWasCloned) { diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index cd3bcf4bc2c..4a7bf484d39 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -25,6 +25,7 @@ import type { LexicalPrivateDOM, NodeKey, NodeMap, + StaticNodeConfigValue, } from './LexicalNode'; import type { BaseSelection, @@ -64,6 +65,7 @@ import { DOM_TEXT_TYPE, HAS_DIRTY_NODES, LTR_REGEX, + PROTOTYPE_CONFIG_METHOD, RTL_REGEX, TEXT_TYPE_TO_FORMAT, } from './LexicalConstants'; @@ -84,6 +86,16 @@ export const emptyFunction = () => { return; }; +let pendingNodeToClone: null | LexicalNode = null; +export function setPendingNodeToClone(pendingNode: null | LexicalNode): void { + pendingNodeToClone = pendingNode; +} +export function getPendingNodeToClone(): null | LexicalNode { + const node = pendingNodeToClone; + pendingNodeToClone = null; + return node; +} + let keyCounter = 1; export function resetRandomKey(): void { @@ -94,17 +106,30 @@ export function generateRandomKey(): string { return '' + keyCounter++; } +/** + * @internal + */ export function getRegisteredNodeOrThrow( editor: LexicalEditor, nodeType: string, ): RegisteredNode { - const registeredNode = editor._nodes.get(nodeType); + const registeredNode = getRegisteredNode(editor, nodeType); if (registeredNode === undefined) { invariant(false, 'registeredNode: Type %s not found', nodeType); } return registeredNode; } +/** + * @internal + */ +export function getRegisteredNode( + editor: LexicalEditor, + nodeType: string, +): undefined | RegisteredNode { + return editor._nodes.get(nodeType); +} + export const isArray = Array.isArray; export const scheduleMicroTask: (fn: () => void) => void = @@ -276,9 +301,11 @@ export function $setNodeKey( node: LexicalNode, existingKey: NodeKey | null | undefined, ): void { + const pendingNode = getPendingNodeToClone(); + existingKey = existingKey || (pendingNode && pendingNode.__key); if (existingKey != null) { if (__DEV__) { - errorOnNodeKeyConstructorMismatch(node, existingKey); + errorOnNodeKeyConstructorMismatch(node, existingKey, pendingNode); } node.__key = existingKey; return; @@ -303,6 +330,7 @@ export function $setNodeKey( function errorOnNodeKeyConstructorMismatch( node: LexicalNode, existingKey: NodeKey, + pendingNode: null | LexicalNode, ) { const editorState = internalGetActiveEditorState(); if (!editorState) { @@ -310,6 +338,16 @@ function errorOnNodeKeyConstructorMismatch( return; } const existingNode = editorState._nodeMap.get(existingKey); + if (pendingNode) { + invariant( + existingKey === pendingNode.__key, + 'Lexical node with constructor %s (type %s) has an incorrect clone implementation, got %s for nodeKey when expecting %s', + node.constructor.name, + node.getType(), + String(existingKey), + pendingNode.__key, + ); + } if (existingNode && existingNode.constructor !== node.constructor) { // Lifted condition to if statement because the inverted logic is a bit confusing if (node.constructor.name !== existingNode.constructor.name) { @@ -1460,13 +1498,14 @@ export function $isRootOrShadowRoot( export function $copyNode(node: T): T { const copy = node.constructor.clone(node) as T; $setNodeKey(copy, null); + copy.afterCloneFrom(node); return copy; } export function $applyNodeReplacement(node: N): N { const editor = getActiveEditor(); - const nodeType = node.constructor.getType(); - const registeredNode = editor._nodes.get(nodeType); + const nodeType = node.getType(); + const registeredNode = getRegisteredNode(editor, nodeType); invariant( registeredNode !== undefined, '$applyNodeReplacement node %s with type %s must be registered to the editor. You can do this by passing the node class via the "nodes" array in the editor config.', @@ -1898,7 +1937,7 @@ function computeTypeToNodeMap(editorState: EditorState): TypeToNodeMap { * do not try and use this function to duplicate or copy an existing node. * * Does not mutate the EditorState. - * @param node - The node to be cloned. + * @param latestNode - The node to be cloned. * @returns The clone of the node. */ export function $cloneWithProperties(latestNode: T): T { @@ -1953,3 +1992,117 @@ export function isDOMUnmanaged(elementDom: Node): boolean { const el: Node & LexicalPrivateDOM = elementDom; return el.__lexicalUnmanaged === true; } + +/** + * @internal + * + * Object.hasOwn ponyfill + */ +export function hasOwn(o: object, k: string): boolean { + return Object.prototype.hasOwnProperty.call(o, k); +} + +/** @internal */ +function isAbstractNodeClass(klass: Klass): boolean { + return ( + klass === DecoratorNode || + klass === ElementNode || + klass === Object.getPrototypeOf(ElementNode) + ); +} + +/** @internal */ +export function getStaticNodeConfig(klass: Klass): { + ownNodeType: undefined | string; + ownNodeConfig: undefined | StaticNodeConfigValue; +} { + const nodeConfigRecord = + PROTOTYPE_CONFIG_METHOD in klass.prototype + ? klass.prototype[PROTOTYPE_CONFIG_METHOD]() + : undefined; + const isAbstract = isAbstractNodeClass(klass); + const nodeType = + !isAbstract && hasOwn(klass, 'getType') ? klass.getType() : undefined; + let ownNodeConfig: undefined | StaticNodeConfigValue; + let ownNodeType = nodeType; + if (nodeConfigRecord) { + if (nodeType) { + ownNodeConfig = nodeConfigRecord[nodeType]; + } else { + for (const [k, v] of Object.entries(nodeConfigRecord)) { + ownNodeType = k; + ownNodeConfig = v; + } + } + } + if (!isAbstract && ownNodeType) { + if (!hasOwn(klass, 'getType')) { + klass.getType = () => ownNodeType; + } + if (!hasOwn(klass, 'clone')) { + if (__DEV__) { + invariant( + klass.length === 0, + '%s (type %s) must implement a static clone method since its constructor has %s required arguments (expecting 0). Use an explicit default in the first argument of your constructor(prop: T=X, nodeKey?: NodeKey).', + klass.name, + ownNodeType, + String(klass.length), + ); + } + klass.clone = (prevNode: LexicalNode) => { + setPendingNodeToClone(prevNode); + return new klass(); + }; + } + if (!hasOwn(klass, 'importJSON')) { + if (__DEV__) { + invariant( + klass.length === 0, + '%s (type %s) must implement a static importJSON method since its constructor has %s required arguments (expecting 0). Use an explicit default in the first argument of your constructor(prop: T=X, nodeKey?: NodeKey).', + klass.name, + ownNodeType, + String(klass.length), + ); + } + klass.importJSON = + (ownNodeConfig && ownNodeConfig.$importJSON) || + ((serializedNode) => new klass().updateFromJSON(serializedNode)); + } + if (!hasOwn(klass, 'importDOM') && ownNodeConfig) { + const {importDOM} = ownNodeConfig; + if (importDOM) { + klass.importDOM = () => importDOM; + } + } + } + return {ownNodeConfig, ownNodeType}; +} + +/** + * Create an node from its class. + * + * Note that this will directly construct the final `withKlass` node type, + * and will ignore the deprecated `with` functions. This allows $create to skip + * any intermediate steps where the replaced node would be created and then + * immediately discarded (once per configured replacement of that node). + * + * This does not support any arguments to the constructor. + * Setters which can be used to initialize your node, and they can + * be chained. You can of course write your own mutliple-argument functions + * to wrap that. + * + * @example + * ```ts + * function $createTokenText(text: string): TextNode { + * return $create(TextNode).setTextContent(text).setMode('token'); + * } + * ``` + */ +export function $create(klass: Klass): T { + const editor = $getEditor(); + errorOnReadOnly(); + const registeredNode = editor.resolveRegisteredNodeAfterReplacements( + editor.getRegisteredNode(klass), + ); + return new registeredNode.klass() as T; +} diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index edfc7060a1d..a32f9ed9e63 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -2901,6 +2901,7 @@ describe('LexicalEditor tests', () => { { replace: TextNode, with: (node: TextNode) => new TestTextNode(node.getTextContent()), + withKlass: TestTextNode, }, ], onError: onError, @@ -2938,6 +2939,7 @@ describe('LexicalEditor tests', () => { replace: TextNode, with: (node: TextNode) => new TestTextNode(node.getTextContent(), node.getKey()), + withKlass: TestTextNode, }, ], onError: onError, @@ -2967,7 +2969,9 @@ describe('LexicalEditor tests', () => { it('node transform to the nodes specified by "replace" should not be applied to the nodes specified by "with" when "withKlass" is not specified', async () => { const onError = jest.fn(); - + const mockWarning = jest + .spyOn(console, 'warn') + .mockImplementationOnce(() => {}); const newEditor = createTestEditor({ nodes: [ TestTextNode, @@ -2985,6 +2989,10 @@ describe('LexicalEditor tests', () => { }, }, }); + expect(mockWarning).toHaveBeenCalledWith( + `Override for TextNode specifies 'replace' without 'withKlass'. 'withKlass' will be required in a future version.`, + ); + mockWarning.mockRestore(); newEditor.setRootElement(container); diff --git a/packages/lexical/src/__tests__/unit/LexicalNode.test.ts b/packages/lexical/src/__tests__/unit/LexicalNode.test.ts index d6fe4b4a6df..43eedb6fb68 100644 --- a/packages/lexical/src/__tests__/unit/LexicalNode.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalNode.test.ts @@ -7,6 +7,7 @@ */ import { + $create, $createRangeSelection, $getRoot, $getSelection, @@ -1493,6 +1494,107 @@ describe('LexicalNode tests', () => { expect(selection.anchor.offset).toBe(1); }); }); + describe('LexicalNode.$config()', () => { + test('importJSON() with no boilerplate', () => { + class CustomTextNode extends TextNode { + $config() { + return this.config('custom-text', {extends: TextNode}); + } + } + const editor = createEditor({ + nodes: [CustomTextNode], + onError(err) { + throw err; + }, + }); + editor.update( + () => { + const node = CustomTextNode.importJSON({ + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'codegen!', + type: 'custom-text', + version: 1, + }); + expect(node).toBeInstanceOf(CustomTextNode); + expect(node.getType()).toBe('custom-text'); + expect(node.getTextContent()).toBe('codegen!'); + }, + {discrete: true}, + ); + }); + test('clone() with no boilerplate', () => { + class SNCVersionedTextNode extends TextNode { + __version = 0; + $config() { + return this.config('snc-vtext', {}); + } + afterCloneFrom(node: this): void { + super.afterCloneFrom(node); + this.__version = node.__version + 1; + } + } + const editor = createEditor({ + nodes: [SNCVersionedTextNode], + onError(err) { + throw err; + }, + }); + let versionedTextNode: SNCVersionedTextNode; + + editor.update( + () => { + versionedTextNode = + $create(SNCVersionedTextNode).setTextContent('test'); + $getRoot().append( + $createParagraphNode().append(versionedTextNode), + ); + expect(versionedTextNode.__version).toEqual(0); + }, + {discrete: true}, + ); + editor.update( + () => { + expect(versionedTextNode.getLatest().__version).toEqual(0); + const latest = versionedTextNode + .setTextContent('update') + .setMode('token'); + expect(latest).toMatchObject({ + __text: 'update', + __version: 1, + }); + expect(versionedTextNode).toMatchObject({ + __text: 'test', + __version: 0, + }); + }, + {discrete: true}, + ); + editor.update( + () => { + let latest = versionedTextNode.getLatest(); + expect(versionedTextNode.__version).toEqual(0); + expect(versionedTextNode.__mode).toEqual(0); + expect(versionedTextNode.getMode()).toEqual('token'); + expect(latest.__version).toEqual(1); + expect(latest.__mode).toEqual(1); + latest = latest.setTextContent('another update'); + expect(latest.__version).toEqual(2); + expect(latest.getWritable().__version).toEqual(2); + expect( + versionedTextNode.getLatest().getWritable().__version, + ).toEqual(2); + expect(versionedTextNode.getLatest().__version).toEqual(2); + expect(versionedTextNode.__mode).toEqual(0); + expect(versionedTextNode.getLatest().__mode).toEqual(1); + expect(versionedTextNode.getMode()).toEqual('token'); + }, + {discrete: true}, + ); + }); + }); }, { namespace: '', diff --git a/packages/lexical/src/__tests__/unit/LexicalNodeState.test.ts b/packages/lexical/src/__tests__/unit/LexicalNodeState.test.ts index 0a47cd24477..9688b52a876 100644 --- a/packages/lexical/src/__tests__/unit/LexicalNodeState.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalNodeState.test.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. * */ -import {makeStateWrapper} from '@lexical/utils'; + import { $createParagraphNode, $createTextNode, @@ -15,13 +15,15 @@ import { $isParagraphNode, $setState, createState, + LexicalExportJSON, NODE_STATE_KEY, + type NodeStateJSON, ParagraphNode, RootNode, - SerializedLexicalNode, + StateValueOrUpdater, } from 'lexical'; -import {$nodeStatesAreEquivalent} from '../../LexicalNodeState'; +import {nodeStatesAreEquivalent} from '../../LexicalNodeState'; import {initializeUnitTest, invariant} from '../utils'; import {TestNode} from './LexicalNode.test'; @@ -36,20 +38,77 @@ type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y const numberState = createState('numberState', { parse: (v) => (typeof v === 'number' ? v : 0), }); -const numberStateWrapper = makeStateWrapper(numberState); +const boolState = createState('boolState', {parse: Boolean}); class StateNode extends TestNode { - static getType() { - return 'state'; + $config() { + return this.config('state', { + extends: TestNode, + stateConfigs: [{flat: true, stateConfig: numberState}, boolState], + }); } - static clone(node: StateNode) { - return new StateNode(node.__key); + getNumber() { + return $getState(this, numberState); } - static importJSON(serializedNode: SerializedLexicalNode): TestNode { - return new StateNode().updateFromJSON(serializedNode); + setNumber(valueOrUpdater: StateValueOrUpdater): this { + return $setState(this, numberState, valueOrUpdater); } - getNumber = numberStateWrapper.makeGetterMethod(); - setNumber = numberStateWrapper.makeSetterMethod(); } + +const extraState = createState('extra', {parse: String}); +class ExtraStateNode extends StateNode { + $config() { + return this.config('extra-state', { + extends: StateNode, + stateConfigs: [{flat: true, stateConfig: extraState}], + }); + } +} + +type StateNodeStateJSON = NodeStateJSON; +type _TestNodeStateJSON = Expect< + Equal< + StateNodeStateJSON, + { + [NODE_STATE_KEY]?: {boolState?: boolean | undefined} | undefined; + numberState?: number | undefined; + } + > +>; +type StateNodeExportJSON = LexicalExportJSON; +type _TestStateNodeExportJSON = Expect< + Equal< + StateNodeExportJSON, + { + [NODE_STATE_KEY]?: + | (Record & { + boolState?: boolean | undefined; + }) + | undefined; + version: number; + type: 'state'; + numberState?: number | undefined; + } + > +>; + +type ExtraStateNodeExportJSON = LexicalExportJSON; +type _TestExtraStateNodeExportJSON = Expect< + Equal< + ExtraStateNodeExportJSON, + { + [NODE_STATE_KEY]?: + | (Record & { + boolState?: boolean | undefined; + }) + | undefined; + version: number; + type: 'extra-state'; + numberState?: number | undefined; + extra?: string | undefined; + } + > +>; + function $createStateNode() { return new StateNode(); } @@ -168,8 +227,11 @@ describe('LexicalNode state', () => { expect(stateNode.getNumber()).toBe(1); stateNode.setNumber((n) => n + 1); expect(stateNode.getNumber()).toBe(2); - expect(stateNode.exportJSON()[NODE_STATE_KEY]).toStrictEqual({ + $setState(stateNode, boolState, true); + expect(stateNode.exportJSON()).toMatchObject({ + [NODE_STATE_KEY]: {boolState: true}, numberState: 2, + type: 'state', }); }); }); @@ -291,9 +353,9 @@ describe('LexicalNode state', () => { expect(noState.read(() => $getState(v0, vk, 'direct'))).toBe(0); expect(noState.read(() => $getState(v1, vk, 'direct'))).toBe(1); }); - describe('$nodeStatesAreEquivalent', () => { + describe('nodeStatesAreEquivalent', () => { test('undefined states are equivalent', () => { - expect($nodeStatesAreEquivalent(undefined, undefined)).toBe(true); + expect(nodeStatesAreEquivalent(undefined, undefined)).toBe(true); }); test('TextNode merging only with equivalent state', () => { const {editor} = testEnv; @@ -437,8 +499,8 @@ describe('LexicalNode state', () => { const bSet = equivalences[j]; for (const a of aSet) { for (const b of bSet) { - expect($nodeStatesAreEquivalent(a, b)).toBe(false); - expect($nodeStatesAreEquivalent(b, a)).toBe(false); + expect(nodeStatesAreEquivalent(a, b)).toBe(false); + expect(nodeStatesAreEquivalent(b, a)).toBe(false); } } } @@ -446,8 +508,8 @@ describe('LexicalNode state', () => { const a0 = aSet[j]; for (let k = j + 1; k < aSet.length; k++) { const a1 = aSet[k]; - expect($nodeStatesAreEquivalent(a0, a1)).toBe(true); - expect($nodeStatesAreEquivalent(a1, a0)).toBe(true); + expect(nodeStatesAreEquivalent(a0, a1)).toBe(true); + expect(nodeStatesAreEquivalent(a1, a0)).toBe(true); } } } diff --git a/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts b/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts index 7e99ff29e27..c6164cb7dbf 100644 --- a/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts @@ -409,6 +409,7 @@ describe('$applyNodeReplacement', () => { { replace: TextNode, with: (node) => $createExtendedTextNode().initWithTextNode(node), + withKlass: ExtendedExtendedTextNode, }, ], onError(err) { @@ -455,6 +456,9 @@ describe('$applyNodeReplacement', () => { ); }); test('validates replace node type change', () => { + const mockWarning = jest + .spyOn(console, 'warn') + .mockImplementationOnce(() => {}); const editor = createEditor({ nodes: [ { @@ -466,6 +470,10 @@ describe('$applyNodeReplacement', () => { throw err; }, }); + expect(mockWarning).toHaveBeenCalledWith( + `Override for TextNode specifies 'replace' without 'withKlass'. 'withKlass' will be required in a future version.`, + ); + mockWarning.mockRestore(); expect(() => { editor.update( () => { @@ -486,6 +494,7 @@ describe('$applyNodeReplacement', () => { replace: TextNode, with: (node: TextNode) => new ExtendedTextNode(node.__text, node.getKey()), + withKlass: ExtendedTextNode, }, ], onError(err) { @@ -539,6 +548,7 @@ describe('$applyNodeReplacement', () => { replace: ExtendedTextNode, with: (node) => $createExtendedExtendedTextNode().initWithExtendedTextNode(node), + withKlass: ExtendedExtendedTextNode, }, ], onError(err) { diff --git a/packages/lexical/src/caret/LexicalCaretUtils.ts b/packages/lexical/src/caret/LexicalCaretUtils.ts index 87a227362aa..4f0593b2bed 100644 --- a/packages/lexical/src/caret/LexicalCaretUtils.ts +++ b/packages/lexical/src/caret/LexicalCaretUtils.ts @@ -225,7 +225,7 @@ function $isCaretAttached>( * blocks then the remaining contents of the later block will be merged with * the earlier block. * - * @param range The range to remove text and nodes from + * @param initialRange The range to remove text and nodes from * @param sliceMode If 'preserveEmptyTextPointCaret' it will leave an empty TextPointCaret at the anchor for insert if one exists, otherwise empty slices will be removed * @returns The new collapsed range (biased towards the earlier node) */ @@ -589,8 +589,9 @@ export function $getChildCaretAtIndex( * R -> P -> T1, T2 * -> P2 * returns T2 for node T1, P2 for node T2, and null for node P2. - * @param node LexicalNode. - * @returns An array (tuple) containing the found Lexical node and the depth difference, or null, if this node doesn't exist. + * @param startCaret The initial caret + * @param rootMode The root mode, 'root' ('default') or 'shadowRoot' + * @returns An array (tuple) containing the found caret and the depth difference, or null, if this node doesn't exist. */ export function $getAdjacentSiblingOrParentSiblingCaret< D extends CaretDirection, diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 3bffecb842a..20a5ce55b65 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -144,6 +144,7 @@ export type { KlassConstructor, LexicalCommand, LexicalEditor, + LexicalNodeConfig, LexicalNodeReplacement, MutationListener, NodeMutation, @@ -169,6 +170,7 @@ export type { } from './LexicalEditorState'; export type {EventHandler} from './LexicalEvents'; export type { + BaseStaticNodeConfig, DOMChildConversion, DOMConversion, DOMConversionFn, @@ -176,23 +178,31 @@ export type { DOMConversionOutput, DOMExportOutput, DOMExportOutputMap, + LexicalExportJSON, LexicalNode, LexicalUpdateJSON, NodeKey, NodeMap, SerializedLexicalNode, + StaticNodeConfig, + StaticNodeConfigRecord, + StaticNodeConfigValue, } from './LexicalNode'; +export {buildImportMap} from './LexicalNode'; export { $getState, $getStateChange, $getWritableNodeState, $setState, type AnyStateConfig, + createSharedNodeState, createState, + type NodeStateJSON, type StateConfig, type StateConfigKey, type StateConfigValue, type StateValueConfig, + type StateValueOrUpdater, type ValueOrUpdater, } from './LexicalNodeState'; export {$normalizeSelection as $normalizeSelection__EXPERIMENTAL} from './LexicalNormalization'; @@ -225,6 +235,7 @@ export { $applyNodeReplacement, $cloneWithProperties, $copyNode, + $create, $getAdjacentNode, $getEditor, $getNearestNodeFromDOMNode, @@ -251,6 +262,8 @@ export { getDOMTextNode, getEditorPropertyFromDOMNode, getNearestEditorFromDOMNode, + getRegisteredNode, + getRegisteredNodeOrThrow, INTERNAL_$isBlock, isBlockDomNode, isDocumentFragment, diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index 956db69f38a..2aa90fb85a6 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -342,9 +342,11 @@ export class ElementNode extends LexicalNode { afterCloneFrom(prevNode: this) { super.afterCloneFrom(prevNode); - this.__first = prevNode.__first; - this.__last = prevNode.__last; - this.__size = prevNode.__size; + if (this.__key === prevNode.__key) { + this.__first = prevNode.__first; + this.__last = prevNode.__last; + this.__size = prevNode.__size; + } this.__indent = prevNode.__indent; this.__format = prevNode.__format; this.__style = prevNode.__style;