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;