Skip to content

Commit 66972c1

Browse files
authored
[lexical][lexical-list][lexical-markdown] Feature: resetOnCopyNode configuration to NodeState and LexicalNode.resetOnCopyNodeFrom hook (facebook#8221)
1 parent 53a1dd5 commit 66972c1

8 files changed

Lines changed: 167 additions & 9 deletions

File tree

packages/lexical-list/src/LexicalListItemNode.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
} from '@lexical/utils';
3030
import {
3131
$applyNodeReplacement,
32+
$copyNode,
3233
$createParagraphNode,
3334
$isElementNode,
3435
$isParagraphNode,
@@ -300,13 +301,18 @@ export class ListItemNode extends ElementNode {
300301
}
301302
}
302303

304+
resetOnCopyNodeFrom(original: this): void {
305+
super.resetOnCopyNodeFrom(original);
306+
if (original.getChecked()) {
307+
this.setChecked(false);
308+
}
309+
}
310+
303311
insertNewAfter(
304312
_: RangeSelection,
305313
restoreSelection = true,
306314
): ListItemNode | ParagraphNode {
307-
const newElement = $createListItemNode()
308-
.updateFromJSON(this.exportJSON())
309-
.setChecked(this.getChecked() ? false : undefined);
315+
const newElement = $copyNode(this);
310316

311317
this.insertAfter(newElement, restoreSelection);
312318

packages/lexical-markdown/src/MarkdownTransformers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,15 +222,17 @@ const ENDS_WITH = (regex: RegExp) =>
222222

223223
export const listMarkerState = createState('mdListMarker', {
224224
parse: (v) => (typeof v === 'string' && /^[-*+]$/.test(v) ? v : '-'),
225+
resetOnCopyNode: true,
225226
});
226227

227-
export const codeFenceState = createState<string, string>('mdCodeFence', {
228+
export const codeFenceState = createState('mdCodeFence', {
228229
parse: (val) => {
229230
if (typeof val === 'string' && /^`{3,}$/.test(val)) {
230231
return val;
231232
}
232233
return '```';
233234
},
235+
resetOnCopyNode: true,
234236
});
235237

236238
const createBlockNode = (

packages/lexical-website/docs/concepts/node-cloning.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ function $duplicateNode(node: MyCustomNode) {
6060
### What is `$copyNode`?
6161

6262
`$copyNode` is the public API for creating a copy of a node with a new key. Use this when you need to create a duplicate node.
63+
By default, all properties and `NodeState` will be copied to the new node, and then `resetOnCopyNodeFrom` will be called to
64+
allow the node to optionally reset certain properties (and NodeState configured with `resetOnCopyNode: true`) to defaults
65+
(such as the checked state of a `ListItemNode`).
6366

6467
```typescript
6568
// ✅ Correct: Using $copyNode

packages/lexical/flow/Lexical.js.flow

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,8 @@ declare export class LexicalNode {
457457
selectNext(anchorOffset?: number, focusOffset?: number): RangeSelection;
458458
markDirty(): void;
459459
reconcileObservedMutation(dom: HTMLElement, editor: LexicalEditor): void;
460+
afterCloneFrom(prevNode: this): void;
461+
resetOnCopyNodeFrom(original: this): void;
460462
}
461463
export type NodeMap = Map<NodeKey, LexicalNode>;
462464

@@ -941,10 +943,7 @@ declare export function $hasAncestor(
941943
targetNode: LexicalNode,
942944
): boolean;
943945
declare export function $cloneWithProperties<T: LexicalNode>(node: T): T;
944-
declare export function $copyNode(
945-
node: ElementNode,
946-
offset: number,
947-
): [ElementNode, ElementNode];
946+
declare export function $copyNode<T: LexicalNode>(node: T, skipReset?: boolean): T;
948947
declare export function $getEditor(): LexicalEditor;
949948

950949
/**
@@ -1340,13 +1339,15 @@ declare export class StateConfig<K: string, V> {
13401339
+unparse: (value: V) => mixed;
13411340
+isEqual: (a: V, b: V) => boolean;
13421341
+defaultValue: V;
1342+
+resetOnCopyNode: boolean;
13431343
constructor(key: K, stateValueConfig: StateValueConfig<V>): this;
13441344
}
13451345

13461346
export type StateValueConfig<V> = {
13471347
parse: (jsonValue: mixed) => V;
13481348
unparse?: (parsed: V) => mixed;
13491349
isEqual?: (a: V, b: V) => boolean;
1350+
resetOnCopyNode?: boolean;
13501351
}
13511352

13521353
export type UnionToIntersection<T> = (

packages/lexical/src/LexicalNode.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,17 @@ export class LexicalNode {
552552
}
553553
}
554554

555+
/**
556+
* Reset state in this copy of originalNode, if necessary
557+
*
558+
* @param originalNode
559+
*/
560+
resetOnCopyNodeFrom(originalNode: this): void {
561+
if (this.__state) {
562+
this.__state = this.__state.getWritable(this).resetOnCopyNode();
563+
}
564+
}
565+
555566
// eslint-disable-next-line @typescript-eslint/no-explicit-any
556567
static importDOM?: () => DOMConversionMap<any> | null;
557568

packages/lexical/src/LexicalNodeState.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,11 @@ export interface StateValueConfig<V> {
223223
* more appropriate for your use case.
224224
*/
225225
isEqual?: (a: V, b: V) => boolean;
226+
/**
227+
* When a node is copied with {@link $copyNode} (not cloned), reset this
228+
* value to the default.
229+
*/
230+
resetOnCopyNode?: boolean;
226231
}
227232

228233
/**
@@ -251,6 +256,7 @@ export class StateConfig<K extends string, V> {
251256
* the `defaultValue`, it will not be serialized to JSON.
252257
*/
253258
readonly defaultValue: V;
259+
readonly resetOnCopyNode: boolean;
254260
constructor(key: K, stateValueConfig: StateValueConfig<V>) {
255261
this.key = key;
256262
this.parse = stateValueConfig.parse.bind(stateValueConfig);
@@ -261,6 +267,7 @@ export class StateConfig<K extends string, V> {
261267
stateValueConfig,
262268
);
263269
this.defaultValue = this.parse(undefined);
270+
this.resetOnCopyNode = stateValueConfig.resetOnCopyNode || false;
264271
}
265272
}
266273

@@ -692,6 +699,16 @@ export class NodeState<T extends LexicalNode> {
692699
);
693700
}
694701

702+
/** @internal */
703+
resetOnCopyNode(): this {
704+
for (const stateConfig of this.knownState.keys()) {
705+
if (stateConfig.resetOnCopyNode) {
706+
this.knownState.set(stateConfig, stateConfig.defaultValue);
707+
}
708+
}
709+
return this;
710+
}
711+
695712
/** @internal */
696713
updateFromKnown<K extends string, V>(
697714
stateConfig: StateConfig<K, V>,

packages/lexical/src/LexicalUtils.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1565,12 +1565,19 @@ export function $isRootOrShadowRoot(
15651565
* separately added to the document, and it will not have any children.
15661566
*
15671567
* @param node - The node to be copied.
1568+
* @param skipReset - If true (default false) skip the call to resetOnCopyNodeFrom
15681569
* @returns The copy of the node.
15691570
*/
1570-
export function $copyNode<T extends LexicalNode>(node: T): T {
1571+
export function $copyNode<T extends LexicalNode>(
1572+
node: T,
1573+
skipReset = false,
1574+
): T {
15711575
const copy = node.constructor.clone(node) as T;
15721576
$setNodeKey(copy, null);
15731577
copy.afterCloneFrom(node);
1578+
if (!skipReset) {
1579+
copy.resetOnCopyNodeFrom(node);
1580+
}
15741581
return copy;
15751582
}
15761583

packages/lexical/src/__tests__/unit/LexicalNodeState.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {
10+
$copyNode,
1011
$createParagraphNode,
1112
$createTextNode,
1213
$getRoot,
@@ -544,6 +545,116 @@ describe('LexicalNode state', () => {
544545
}
545546
});
546547
});
548+
549+
describe('resetOnCopyNode', () => {
550+
test('state with resetOnCopyNode: true is reset when using $copyNode', () => {
551+
const {editor} = testEnv;
552+
const resetState = createState('resetState', {
553+
parse: (v) => (typeof v === 'number' ? v : 0),
554+
resetOnCopyNode: true,
555+
});
556+
editor.update(
557+
() => {
558+
const node = $createStateNode();
559+
$setState(node, resetState, 42);
560+
expect($getState(node, resetState)).toBe(42);
561+
562+
const copy = $copyNode(node);
563+
expect($getState(copy, resetState)).toBe(0);
564+
expect($getState(node, resetState)).toBe(42);
565+
},
566+
{discrete: true},
567+
);
568+
});
569+
570+
test('state with resetOnCopyNode: false is preserved when using $copyNode', () => {
571+
const {editor} = testEnv;
572+
const persistState = createState('persistState', {
573+
parse: (v) => (typeof v === 'number' ? v : 0),
574+
resetOnCopyNode: false,
575+
});
576+
editor.update(
577+
() => {
578+
const node = $createStateNode();
579+
$setState(node, persistState, 42);
580+
expect($getState(node, persistState)).toBe(42);
581+
582+
const copy = $copyNode(node);
583+
expect($getState(copy, persistState)).toBe(42);
584+
expect($getState(node, persistState)).toBe(42);
585+
},
586+
{discrete: true},
587+
);
588+
});
589+
590+
test('state without resetOnCopyNode option is preserved when using $copyNode', () => {
591+
const {editor} = testEnv;
592+
const defaultState = createState('defaultState', {
593+
parse: (v) => (typeof v === 'number' ? v : 0),
594+
});
595+
editor.update(
596+
() => {
597+
const node = $createStateNode();
598+
$setState(node, defaultState, 42);
599+
expect($getState(node, defaultState)).toBe(42);
600+
601+
const copy = $copyNode(node);
602+
expect($getState(copy, defaultState)).toBe(42);
603+
expect($getState(node, defaultState)).toBe(42);
604+
},
605+
{discrete: true},
606+
);
607+
});
608+
609+
test('multiple states with different resetOnCopyNode configurations', () => {
610+
const {editor} = testEnv;
611+
const resetState = createState('resetState', {
612+
parse: (v) => (typeof v === 'number' ? v : 0),
613+
resetOnCopyNode: true,
614+
});
615+
const persistState = createState('persistState', {
616+
parse: (v) => (typeof v === 'string' ? v : ''),
617+
resetOnCopyNode: false,
618+
});
619+
const defaultState = createState('defaultState', {
620+
parse: (v) => (typeof v === 'boolean' ? v : false),
621+
});
622+
623+
editor.update(
624+
() => {
625+
const node = $createStateNode();
626+
$setState(node, resetState, 100);
627+
$setState(node, persistState, 'hello');
628+
$setState(node, defaultState, true);
629+
630+
expect($getState(node, resetState)).toBe(100);
631+
expect($getState(node, persistState)).toBe('hello');
632+
expect($getState(node, defaultState)).toBe(true);
633+
634+
const copy = $copyNode(node);
635+
expect($getState(copy, resetState)).toBe(0);
636+
expect($getState(copy, persistState)).toBe('hello');
637+
expect($getState(copy, defaultState)).toBe(true);
638+
639+
// Original node should be unchanged
640+
expect($getState(node, resetState)).toBe(100);
641+
expect($getState(node, persistState)).toBe('hello');
642+
expect($getState(node, defaultState)).toBe(true);
643+
644+
const fullCopy = $copyNode(node, true);
645+
// Original node should be unchanged
646+
expect($getState(node, resetState)).toBe(100);
647+
expect($getState(node, persistState)).toBe('hello');
648+
expect($getState(node, defaultState)).toBe(true);
649+
// Full copy should match all properties
650+
expect($getState(fullCopy, resetState)).toBe(100);
651+
expect($getState(fullCopy, persistState)).toBe('hello');
652+
expect($getState(fullCopy, defaultState)).toBe(true);
653+
},
654+
{discrete: true},
655+
);
656+
});
657+
});
547658
},
548659
{
549660
namespace: '',

0 commit comments

Comments
 (0)