From 54d7282f4cfe7e594ee51ebe0a6d96276eb9d292 Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Sat, 4 May 2024 12:53:22 -0400 Subject: [PATCH] LexicalBlockNormalizer --- .flowconfig | 1 + packages/lexical-devtools/tsconfig.json | 3 + packages/lexical-playground/src/App.tsx | 10 +- packages/lexical-playground/src/Editor.tsx | 8 +- .../src/nodes/PlaygroundNodes.ts | 19 +- .../lexical-playground/src/utils/onError.ts | 14 + ...NodeNormalizerPlugin__EXPERIMENTAL.js.flow | 29 + packages/lexical-react/package.json | 30 + ...lockNodeNormalizerPlugin__EXPERIMENTAL.tsx | 201 +++++++ ...odeNormalizerPlugin__EXPERIMENTAL.test.tsx | 546 ++++++++++++++++++ packages/lexical/src/LexicalUtils.ts | 4 +- tsconfig.build.json | 3 + tsconfig.json | 3 + 13 files changed, 859 insertions(+), 12 deletions(-) create mode 100644 packages/lexical-playground/src/utils/onError.ts create mode 100644 packages/lexical-react/flow/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.js.flow create mode 100644 packages/lexical-react/src/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.tsx create mode 100644 packages/lexical-react/src/__tests__/unit/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.test.tsx diff --git a/.flowconfig b/.flowconfig index cb6c16f3d35..feb8516f723 100644 --- a/.flowconfig +++ b/.flowconfig @@ -39,6 +39,7 @@ module.name_mapper='^@lexical/plain-text$' -> '/packages/lexical-p module.name_mapper='^@lexical/react/LexicalAutoEmbedPlugin$' -> '/packages/lexical-react/flow/LexicalAutoEmbedPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalAutoFocusPlugin$' -> '/packages/lexical-react/flow/LexicalAutoFocusPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalAutoLinkPlugin$' -> '/packages/lexical-react/flow/LexicalAutoLinkPlugin.js.flow' +module.name_mapper='^@lexical/react/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL$' -> '/packages/lexical-react/flow/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.js.flow' module.name_mapper='^@lexical/react/LexicalBlockWithAlignableContents$' -> '/packages/lexical-react/flow/LexicalBlockWithAlignableContents.js.flow' module.name_mapper='^@lexical/react/LexicalCharacterLimitPlugin$' -> '/packages/lexical-react/flow/LexicalCharacterLimitPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalCheckListPlugin$' -> '/packages/lexical-react/flow/LexicalCheckListPlugin.js.flow' diff --git a/packages/lexical-devtools/tsconfig.json b/packages/lexical-devtools/tsconfig.json index 350081311a6..a6f7e6d64a9 100644 --- a/packages/lexical-devtools/tsconfig.json +++ b/packages/lexical-devtools/tsconfig.json @@ -35,6 +35,9 @@ "@lexical/react/LexicalAutoLinkPlugin": [ "../lexical-react/src/LexicalAutoLinkPlugin.ts" ], + "@lexical/react/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL": [ + "../lexical-react/src/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.tsx" + ], "@lexical/react/LexicalBlockWithAlignableContents": [ "../lexical-react/src/LexicalBlockWithAlignableContents.tsx" ], diff --git a/packages/lexical-playground/src/App.tsx b/packages/lexical-playground/src/App.tsx index 334773dec2c..1d8539c1536 100644 --- a/packages/lexical-playground/src/App.tsx +++ b/packages/lexical-playground/src/App.tsx @@ -19,7 +19,7 @@ import {SharedAutocompleteContext} from './context/SharedAutocompleteContext'; import {SharedHistoryContext} from './context/SharedHistoryContext'; import Editor from './Editor'; import logo from './images/logo.svg'; -import PlaygroundNodes from './nodes/PlaygroundNodes'; +import {NODES} from './nodes/PlaygroundNodes'; import DocsPlugin from './plugins/DocsPlugin'; import PasteLogPlugin from './plugins/PasteLogPlugin'; import {TableContext} from './plugins/TablePlugin'; @@ -27,6 +27,7 @@ import TestRecorderPlugin from './plugins/TestRecorderPlugin'; import TypingPerfPlugin from './plugins/TypingPerfPlugin'; import Settings from './Settings'; import PlaygroundEditorTheme from './themes/PlaygroundEditorTheme'; +import onError from './utils/onError'; console.warn( 'If you are profiling the playground app, please ensure you turn off the debug view. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.', @@ -124,10 +125,8 @@ function App(): JSX.Element { ? undefined : prepopulatedRichText, namespace: 'Playground', - nodes: [...PlaygroundNodes], - onError: (error: Error) => { - throw error; - }, + nodes: [...NODES], + onError, theme: PlaygroundEditorTheme, }; @@ -148,7 +147,6 @@ function App(): JSX.Element { {isDevPlayground ? : null} {isDevPlayground ? : null} {isDevPlayground ? : null} - {measureTypingPerf ? : null} diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 60530512725..194e08f1fa6 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -7,6 +7,7 @@ */ import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin'; +import {LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL} from '@lexical/react/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL'; import {CharacterLimitPlugin} from '@lexical/react/LexicalCharacterLimitPlugin'; import {CheckListPlugin} from '@lexical/react/LexicalCheckListPlugin'; import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin'; @@ -29,6 +30,7 @@ import {CAN_USE_DOM} from 'shared/canUseDOM'; import {createWebsocketProvider} from './collaboration'; import {useSettings} from './context/SettingsContext'; import {useSharedHistoryContext} from './context/SharedHistoryContext'; +import {BLOCK_NODES} from './nodes/PlaygroundNodes'; import ActionsPlugin from './plugins/ActionsPlugin'; import AutocompletePlugin from './plugins/AutocompletePlugin'; import AutoEmbedPlugin from './plugins/AutoEmbedPlugin'; @@ -70,6 +72,7 @@ import TwitterPlugin from './plugins/TwitterPlugin'; import YouTubePlugin from './plugins/YouTubePlugin'; import ContentEditable from './ui/ContentEditable'; import Placeholder from './ui/Placeholder'; +import onError from './utils/onError'; const skipCollaborationInit = // @ts-expect-error @@ -142,7 +145,6 @@ export default function Editor(): JSX.Element { - @@ -152,6 +154,10 @@ export default function Editor(): JSX.Element { + {isRichText ? ( <> {isCollab ? ( diff --git a/packages/lexical-playground/src/nodes/PlaygroundNodes.ts b/packages/lexical-playground/src/nodes/PlaygroundNodes.ts index a35f56222cd..4b3ff76c249 100644 --- a/packages/lexical-playground/src/nodes/PlaygroundNodes.ts +++ b/packages/lexical-playground/src/nodes/PlaygroundNodes.ts @@ -14,9 +14,11 @@ import {AutoLinkNode, LinkNode} from '@lexical/link'; import {ListItemNode, ListNode} from '@lexical/list'; import {MarkNode} from '@lexical/mark'; import {OverflowNode} from '@lexical/overflow'; +import {BlockNodeKlass} from '@lexical/react/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL'; import {HorizontalRuleNode} from '@lexical/react/LexicalHorizontalRuleNode'; import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; +import {DecoratorNode, ElementNode} from 'lexical'; import {CollapsibleContainerNode} from '../plugins/CollapsiblePlugin/CollapsibleContainerNode'; import {CollapsibleContentNode} from '../plugins/CollapsiblePlugin/CollapsibleContentNode'; @@ -38,7 +40,7 @@ import {StickyNode} from './StickyNode'; import {TweetNode} from './TweetNode'; import {YouTubeNode} from './YouTubeNode'; -const PlaygroundNodes: Array> = [ +export const NODES: Array> = [ HeadingNode, ListNode, ListItemNode, @@ -75,4 +77,17 @@ const PlaygroundNodes: Array> = [ LayoutItemNode, ]; -export default PlaygroundNodes; +export const BLOCK_NODES: Array> = []; +// The below snippet assumes that for every node, node.isInline() is hardcoded to either true or +// false, Lexical's recommendation. +for (const Node of NODES) { + if ( + (Node === ElementNode || + Node.prototype instanceof ElementNode || + Node === DecoratorNode || + Node.prototype instanceof DecoratorNode) && + !Node.prototype.isInline() + ) { + BLOCK_NODES.push(Node as BlockNodeKlass); + } +} diff --git a/packages/lexical-playground/src/utils/onError.ts b/packages/lexical-playground/src/utils/onError.ts new file mode 100644 index 00000000000..a9f1a142a50 --- /dev/null +++ b/packages/lexical-playground/src/utils/onError.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export default function onError(error: Error | string) { + if (typeof error === 'string') { + throw new Error(error); + } + throw error; +} diff --git a/packages/lexical-react/flow/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.js.flow b/packages/lexical-react/flow/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.js.flow new file mode 100644 index 00000000000..68b7fa97b33 --- /dev/null +++ b/packages/lexical-react/flow/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.js.flow @@ -0,0 +1,29 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +import type {ElementNode, DecoratorNode} from 'lexical'; +import {LexicalEditor} from 'lexical'; +import * as React from 'react'; + +export type BlockNode = ElementNode | DecoratorNode; +export type BlockNodeKlass = Class>; + +declare export function LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL(props: { + onError?: (message: string) => void; + onInfo?: (message: string) => void; + blockNodes: Array>; +}): React$Node; + +declare export function registerBlockNodeNormalizerPlugin__EXPERIMENTAL( + editor: LexicalEditor, + nodes: Array>, { + onError?: (message: string) => void; + onInfo?: (message: string) => void; + }, +): () => void; diff --git a/packages/lexical-react/package.json b/packages/lexical-react/package.json index 3f7a1ee4ff2..61bb0db3ba4 100644 --- a/packages/lexical-react/package.json +++ b/packages/lexical-react/package.json @@ -131,6 +131,36 @@ "default": "./LexicalAutoLinkPlugin.js" } }, + "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL": { + "import": { + "types": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.d.ts", + "development": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.dev.mjs", + "production": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.prod.mjs", + "node": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.node.mjs", + "default": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.mjs" + }, + "require": { + "types": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.d.ts", + "development": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.dev.js", + "production": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.prod.js", + "default": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.js" + } + }, + "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.js": { + "import": { + "types": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.d.ts", + "development": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.dev.mjs", + "production": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.prod.mjs", + "node": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.node.mjs", + "default": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.mjs" + }, + "require": { + "types": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.d.ts", + "development": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.dev.js", + "production": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.prod.js", + "default": "./LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.js" + } + }, "./LexicalBlockWithAlignableContents": { "import": { "types": "./LexicalBlockWithAlignableContents.d.ts", diff --git a/packages/lexical-react/src/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.tsx b/packages/lexical-react/src/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.tsx new file mode 100644 index 00000000000..c95a5cfd20c --- /dev/null +++ b/packages/lexical-react/src/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.tsx @@ -0,0 +1,201 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + + + +import type { + DecoratorNode, + ElementNode, + Klass, + LexicalEditor, + LexicalNode, +} from 'lexical'; + +import {$isListItemNode, $isListNode} from '@lexical/list'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {mergeRegister} from '@lexical/utils'; +import { + $copyNode, + $isDecoratorNode, + $isElementNode, + $isParagraphNode, + $isRootOrShadowRoot, + COMMAND_PRIORITY_HIGH, + PASTE_COMMAND, +} from 'lexical'; +import {useEffect} from 'react'; + +export type BlockNode = ElementNode | DecoratorNode; +export type BlockNodeKlass = Klass>; + +const emptyFunction = () => {}; + +/** + * Ensures that block nodes live at the very top of the tree. + * + * Experimental: we're exploring the idea of moving normalization into specific plugins, and having + * the normalization basic built-in into the Lexical package. When this happens, this plugin will + * be removed. + * + * @param blockNodes array of blockNode (aka elementNode.isInline() === false) + * @param onError won't be fixed + * @param onInfo + */ +export function LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL({ + blockNodes, + onError, + onInfo, +}: { + onError?: (message: string) => void; + onInfo?: (message: string) => void; + blockNodes: Array>; +}): null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return registerBlockNodeNormalizerPlugin__EXPERIMENTAL(editor, blockNodes, { + onError, + onInfo, + }); + }, [editor, blockNodes, onError, onInfo]); + + return null; +} + +export function registerBlockNodeNormalizerPlugin__EXPERIMENTAL( + editor: LexicalEditor, + nodes: Array>, + optional: { + onError?: (message: string) => void; + onInfo?: (message: string) => void; + } = {}, +): () => void { + const {onError = emptyFunction, onInfo = emptyFunction} = optional; + let lastPasteCommand = 0; + + const nodeTransform = (node: BlockNode) => { + const parent = node.getParent(); + + if (parent === null || $isRootOrShadowRoot(parent)) { + return; + } + + if (node.isInline()) { + // TODO throw an invariant (check internal) + onError( + `Node ${node.getKey()} ${ + node.constructor.name + } is not a top level node (isInline() === true). Revise the BlockNodeNormalizer configuration`, + ); + return; + } + + // Valid list nesting + if ( + ($isListItemNode(node) && $isListNode(parent)) || + ($isListNode(node) && $isListItemNode(parent)) + ) { + return; + } + + // Log structural issues only once, and then separate events if it's + // related to copy-pasting + const isPasting = lastPasteCommand + 250 > Date.now(); + onInfo( + `Found top level node ${node.getKey()} ${ + node.constructor.name + } inside ${parent.getKey()} ${parent.constructor.name}${ + isPasting ? ' (paste event)' : '' + }`, + ); + + // For unexpected nesting within list items flatten its content by appending + // all children of current node. It's different from other element nodes, + // where we try to preserve nested node (e.g., p > h1 > text would unwrap + // into h1 > text), since unwrapping lists likely will break list structure + // itself + if ($isListItemNode(parent)) { + if ($isElementNode(node)) { + for (const child of node.getChildren()) { + node.insertBefore(child); + } + node.remove(); + } + + return; + } + + // For elements other then lists it unflattens one level at a time, + // since transformers will be called recursively to handle + // multiple nested levels like p > p > p > text + let lastElement = null; + + for (const child of parent.getChildren()) { + if ( + ($isElementNode(child) || $isDecoratorNode(child)) && + !child.isInline() + ) { + // If nested nodes are mixed paragraph/non-paragraphs then prefer to keep + // non-paragraphs to preserve blocks structure. E.g., both h1 > p > text + // and p > h1 > text should unwrap into h1 > text. But if both parent + // and child nodes are non-paragraphs, then keep child node, meaning + // h1 > h2 > text would unwrap into h2 > text + if ($isParagraphNode(child) && !$isParagraphNode(parent)) { + const newChild = $copyNode(parent); + parent.insertBefore(newChild); + newChild.append(...child.getChildren()); + child.remove(); + } else { + parent.insertBefore(child); + } + + lastElement = null; + } else { + if (lastElement == null) { + lastElement = $copyNode(parent); + parent.insertBefore(lastElement); + } + lastElement.append(child); + } + } + + parent.remove(); + }; + + let unregisterListeners = emptyFunction; + function registerListeners() { + unregisterListeners = mergeRegister( + editor.registerCommand( + PASTE_COMMAND, + () => { + lastPasteCommand = Date.now(); + return false; + }, + COMMAND_PRIORITY_HIGH, + ), + ...nodes.map((nodeClass) => + editor.registerNodeTransform(nodeClass, nodeTransform), + ), + ); + } + if (editor.isEditable()) { + registerListeners(); + } + + return mergeRegister( + editor.registerEditableListener((editable) => { + if (editable) { + registerListeners(); + } else { + unregisterListeners(); + unregisterListeners = emptyFunction; + } + }), + unregisterListeners, + ); +} diff --git a/packages/lexical-react/src/__tests__/unit/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.test.tsx b/packages/lexical-react/src/__tests__/unit/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.test.tsx new file mode 100644 index 00000000000..e4e77da1e8d --- /dev/null +++ b/packages/lexical-react/src/__tests__/unit/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.test.tsx @@ -0,0 +1,546 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + + + +import type {LexicalEditor, SerializedLexicalNode} from 'lexical'; + +import {createHeadlessEditor} from '@lexical/headless'; +import {$createLinkNode, LinkNode} from '@lexical/link'; +import { + $createListItemNode, + $createListNode, + ListItemNode, + ListNode, +} from '@lexical/list'; +import {registerBlockNodeNormalizerPlugin__EXPERIMENTAL} from '@lexical/react/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL'; +import { + $createHeadingNode, + $createQuoteNode, + HeadingNode, + QuoteNode, +} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + DecoratorNode, + ParagraphNode, +} from 'lexical'; + +class BlockDecorator extends DecoratorNode { + static getType(): string { + return 'block-decorator'; + } + + isInline(): false { + return false; + } + + exportJSON(): SerializedLexicalNode { + return {type: this.getType(), version: 1}; + } + + decorate(): null | JSX.Element { + return null; + } +} + +class InlineDecorator extends DecoratorNode { + static getType(): string { + return 'inline-decorator'; + } + + isInline(): true { + return true; + } + + exportJSON(): SerializedLexicalNode { + return {type: this.getType(), version: 1}; + } + + decorate(): null | JSX.Element { + return null; + } +} + +describe('NestingEnforcementPlugin', () => { + let editor: LexicalEditor; + + beforeEach(() => { + editor = createHeadlessEditor({ + namespace: 'headless', + nodes: [ + HeadingNode, + QuoteNode, + ListNode, + ListItemNode, + LinkNode, + BlockDecorator, + InlineDecorator, + ], + onError: (error) => { + throw error; + }, + }); + registerBlockNodeNormalizerPlugin__EXPERIMENTAL( + editor, + [ + HeadingNode, + QuoteNode, + ListNode, + ListItemNode, + BlockDecorator, + ParagraphNode, + ], + { + onError: (errorMessage) => { + throw new Error(errorMessage); + }, + }, + ); + }); + + test('p > h1 > text unwraps into h1 > text', () => { + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append( + $createHeadingNode('h1').append($createTextNode('Heading H1')), + ), + ); + }, + { + discrete: true, + }, + ); + expect(toRootChildren(editor)).toEqual([ + expect.objectContaining({ + children: [expect.objectContaining({text: 'Heading H1'})], + tag: 'h1', + type: 'heading', + }), + ]); + }); + + test('h1 > p > text unwraps into h1 > text', () => { + editor.update( + () => { + $getRoot().append( + $createHeadingNode('h1').append( + $createParagraphNode().append($createTextNode('Text inside')), + ), + ); + }, + { + discrete: true, + }, + ); + expect(toRootChildren(editor)).toEqual([ + expect.objectContaining({ + children: [ + expect.objectContaining({text: 'Text inside', type: 'text'}), + ], + type: 'heading', + }), + ]); + }); + + test('h1 > h2 > text unwraps into h2 > text', () => { + editor.update( + () => { + $getRoot().append( + $createHeadingNode('h1').append( + $createHeadingNode('h2').append( + $createParagraphNode().append($createTextNode('Text inside')), + ), + ), + ); + }, + { + discrete: true, + }, + ); + expect(toRootChildren(editor)).toEqual([ + expect.objectContaining({ + children: [ + expect.objectContaining({text: 'Text inside', type: 'text'}), + ], + tag: 'h2', + type: 'heading', + }), + ]); + }); + + test('p > [text, h1, text] unwraps into p > text, h1 > text, p > text', () => { + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append( + $createLinkNode('https://test.com').append($createTextNode('Link')), + $createTextNode('Text before'), + $createHeadingNode('h1').append($createTextNode('Heading H1')), + $createTextNode('Text after'), + ), + ); + }, + { + discrete: true, + }, + ); + expect(toRootChildren(editor)).toEqual([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + children: [expect.objectContaining({text: 'Link', type: 'text'})], + type: 'link', + }), + expect.objectContaining({text: 'Text before', type: 'text'}), + ], + type: 'paragraph', + }), + expect.objectContaining({ + children: [expect.objectContaining({text: 'Heading H1', type: 'text'})], + type: 'heading', + }), + expect.objectContaining({ + children: [expect.objectContaining({text: 'Text after', type: 'text'})], + type: 'paragraph', + }), + ]); + }); + + test('p > p > [text, p > h1 > text] unwraps into p > text, h1 > text', () => { + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append( + $createParagraphNode().append( + $createTextNode('Text before'), + $createParagraphNode().append( + $createParagraphNode().append( + $createHeadingNode('h1').append( + $createTextNode('Heading H1'), + ), + ), + ), + $createTextNode('Text after'), + ), + ), + ); + }, + { + discrete: true, + }, + ); + expect(toRootChildren(editor)).toEqual([ + expect.objectContaining({ + children: [ + expect.objectContaining({text: 'Text before', type: 'text'}), + ], + type: 'paragraph', + }), + expect.objectContaining({ + children: [expect.objectContaining({text: 'Heading H1', type: 'text'})], + type: 'heading', + }), + expect.objectContaining({ + children: [expect.objectContaining({text: 'Text after', type: 'text'})], + type: 'paragraph', + }), + ]); + }); + + test('p > p > [block-decorator, inline-decorator, text] unwraps into block-decorator, p > [inline-decorator, text]', () => { + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append( + $createParagraphNode().append( + $createBlockDecoratorNode(), + $createInlineDecoratorNode(), + $createTextNode('Text after'), + ), + ), + ); + }, + { + discrete: true, + }, + ); + + expect(toRootChildren(editor)).toEqual([ + expect.objectContaining({ + type: 'block-decorator', + }), + expect.objectContaining({ + children: [ + expect.objectContaining({type: 'inline-decorator'}), + expect.objectContaining({text: 'Text after', type: 'text'}), + ], + type: 'paragraph', + }), + ]); + }); + + test('ul > li > [h1 > text 1 + text 2] unwrapps into ul > li > [text 1 + text 2]', () => { + editor.update( + () => { + $getRoot().append( + $createListNode('bullet').append( + $createListItemNode().append( + $createHeadingNode('h1').append( + $createTextNode('Left'), + $createLinkNode('https://lexical.dev').append( + $createTextNode('Right'), + ), + ), + ), + ), + ); + }, + { + discrete: true, + }, + ); + + expect(toRootChildren(editor)).toEqual([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + children: [ + expect.objectContaining({ + text: 'Left', + type: 'text', + }), + expect.objectContaining({ + children: [ + expect.objectContaining({ + text: 'Right', + type: 'text', + }), + ], + type: 'link', + }), + ], + type: 'listitem', + }), + ], + type: 'list', + }), + ]); + }); + + test('ul > li > [h1 > text, text] unwrapps into ul > li > [text, text]', () => { + editor.update( + () => { + $getRoot().append( + $createListNode('bullet').append( + $createListItemNode().append( + $createHeadingNode('h1').append($createTextNode('Heading H1')), + $createTextNode('Text After'), + ), + $createListItemNode().append($createTextNode('Text After #2')), + ), + ); + }, + { + discrete: true, + }, + ); + expect(toRootChildren(editor)).toEqual([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + children: [ + expect.objectContaining({ + text: 'Heading H1Text After', + type: 'text', + }), + ], + type: 'listitem', + }), + expect.objectContaining({ + children: [ + expect.objectContaining({ + text: 'Text After #2', + type: 'text', + }), + ], + type: 'listitem', + }), + ], + type: 'list', + }), + ]); + }); + + test('quote > [text, quote > text] unwraps into [quote > text, quote > text]', () => { + editor.update( + () => { + $getRoot().append( + $createQuoteNode().append( + $createTextNode('Quote #1'), + $createQuoteNode().append($createTextNode('Quote #2')), + ), + ); + }, + { + discrete: true, + }, + ); + expect(toRootChildren(editor)).toEqual([ + expect.objectContaining({ + children: [expect.objectContaining({text: 'Quote #1'})], + type: 'quote', + }), + expect.objectContaining({ + children: [expect.objectContaining({text: 'Quote #2'})], + type: 'quote', + }), + ]); + }); + + test('h1 > ul > li > text unwraps into ul > li > text', () => { + editor.update( + () => { + $getRoot().append( + $createHeadingNode('h1').append( + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('bullet #1')), + $createListItemNode().append($createTextNode('bullet #2')), + ), + ), + ); + }, + { + discrete: true, + }, + ); + + expect(toRootChildren(editor)).toEqual([ + expect.objectContaining({ + children: [ + expect.objectContaining({ + children: [ + expect.objectContaining({ + text: 'bullet #1', + type: 'text', + }), + ], + type: 'listitem', + }), + expect.objectContaining({ + children: [ + expect.objectContaining({ + text: 'bullet #2', + type: 'text', + }), + ], + type: 'listitem', + }), + ], + type: 'list', + }), + ]); + }); + + test('ul > li > text does not unwrap, same as other valid nestings', () => { + editor.update( + () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('paragraph #1')), + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('bullet #1')), + $createListItemNode().append($createTextNode('bullet #2')), + $createListItemNode().append( + $createListNode('bullet').append( + $createListItemNode().append($createTextNode('bullet #3')), + $createListItemNode().append($createTextNode('bullet #4')), + ), + ), + ), + $createParagraphNode().append($createTextNode('paragraph #2')), + ); + }, + { + discrete: true, + }, + ); + + expect(toRootChildren(editor)).toEqual([ + expect.objectContaining({ + children: [expect.objectContaining({text: 'paragraph #1'})], + type: 'paragraph', + }), + expect.objectContaining({ + children: [ + expect.objectContaining({ + children: [ + expect.objectContaining({ + text: 'bullet #1', + }), + ], + type: 'listitem', + }), + expect.objectContaining({ + children: [ + expect.objectContaining({ + text: 'bullet #2', + }), + ], + type: 'listitem', + }), + expect.objectContaining({ + children: [ + expect.objectContaining({ + children: [ + expect.objectContaining({ + children: [ + expect.objectContaining({ + text: 'bullet #3', + }), + ], + type: 'listitem', + }), + expect.objectContaining({ + children: [ + expect.objectContaining({ + text: 'bullet #4', + }), + ], + type: 'listitem', + }), + ], + type: 'list', + }), + ], + type: 'listitem', + }), + ], + type: 'list', + }), + expect.objectContaining({ + children: [expect.objectContaining({text: 'paragraph #2'})], + type: 'paragraph', + }), + ]); + }); +}); + +function toRootChildren(editor: LexicalEditor): Array { + return editor.getEditorState().toJSON().root.children; +} + +function $createBlockDecoratorNode(): BlockDecorator { + return new BlockDecorator(); +} + +function $createInlineDecoratorNode(): InlineDecorator { + return new InlineDecorator(); +} diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 52d78f07f36..2337b7ceeb6 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -69,9 +69,7 @@ import { updateEditor, } from './LexicalUpdates'; -export const emptyFunction = () => { - return; -}; +export const emptyFunction = () => {}; let keyCounter = 1; diff --git a/tsconfig.build.json b/tsconfig.build.json index dc17b674c79..c897c88e5ab 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -34,6 +34,9 @@ "@lexical/react/LexicalAutoLinkPlugin": [ "./packages/lexical-react/src/LexicalAutoLinkPlugin.ts" ], + "@lexical/react/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL": [ + "./packages/lexical-react/src/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.tsx" + ], "@lexical/react/LexicalBlockWithAlignableContents": [ "./packages/lexical-react/src/LexicalBlockWithAlignableContents.tsx" ], diff --git a/tsconfig.json b/tsconfig.json index 4a04ae1a0a9..5af0bdb7cb4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,6 +42,9 @@ "@lexical/react/LexicalAutoLinkPlugin": [ "./packages/lexical-react/src/LexicalAutoLinkPlugin.ts" ], + "@lexical/react/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL": [ + "./packages/lexical-react/src/LexicalBlockNodeNormalizerPlugin__EXPERIMENTAL.tsx" + ], "@lexical/react/LexicalBlockWithAlignableContents": [ "./packages/lexical-react/src/LexicalBlockWithAlignableContents.tsx" ],