diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index b31c22bc299..eaecb8075e8 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -113,10 +113,13 @@ export function createSelectionMarkdownExport( return selection => { const output = []; - const children = $getRoot().getChildren(); + // The separator depends on the previously *emitted* block, not on the + // previous root child: unselected children produce no output, so keying + // off the child index would prefix a newline to the first emitted block + // whenever the selection starts below the top of the document. + let previousExported: LexicalNode | null = null; - for (let i = 0; i < children.length; i++) { - const child = children[i]; + for (const child of $getRoot().getChildren()) { const {shouldInclude, markdown} = $processNodeForSelection( child, selection, @@ -129,12 +132,13 @@ export function createSelectionMarkdownExport( if (shouldInclude && markdown != null) { output.push( isNewlineDelimited && - i > 0 && + previousExported !== null && !isEmptyParagraph(child) && - !isEmptyParagraph(children[i - 1]) + !isEmptyParagraph(previousExported) ? '\n'.concat(markdown) : markdown, ); + previousExported = child; } } return output.join('\n'); diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 7bf22aaf8c2..2bd8bdbf5f5 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -2704,6 +2704,34 @@ describe('$convertSelectionToMarkdownString', () => { expect(result).toBe('Hello **Bold**'); }); + it('does not prefix a newline when the selection starts after the first block', () => { + const editor = createTestEditor(); + editor.update( + () => { + const root = $getRoot(); + const firstText = $createTextNode('First'); + const secondText = $createTextNode('Second'); + const thirdText = $createTextNode('Third'); + root.append( + $createParagraphNode().append(firstText), + $createParagraphNode().append(secondText), + $createParagraphNode().append(thirdText), + ); + $setSelectionFromCaretRange( + $getCaretRange( + $getTextPointCaret(secondText, 'next', 0), + $getTextPointCaret(thirdText, 'next', 5), + ), + ); + }, + {discrete: true}, + ); + const result = editor.read('latest', () => + $convertSelectionToMarkdownString(TRANSFORMERS, $getSelection()), + ); + expect(result).toBe('Second\n\nThird'); + }); + it('returns empty string for null selection', () => { const result = $convertSelectionToMarkdownString(TRANSFORMERS, null); expect(result).toBe(''); diff --git a/packages/lexical-table/src/LexicalTableObserver.ts b/packages/lexical-table/src/LexicalTableObserver.ts index 92fe5f08c68..bc492d21192 100644 --- a/packages/lexical-table/src/LexicalTableObserver.ts +++ b/packages/lexical-table/src/LexicalTableObserver.ts @@ -308,6 +308,7 @@ export class TableObserver { this.table = getTable(tableNode, tableElement); }); }); + this.listenersToRemove.add(() => observer.disconnect()); this.editor.read('latest', () => { const {tableNode, tableElement} = this.$lookup(); this.table = getTable(tableNode, tableElement); diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableObserver.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableObserver.test.ts new file mode 100644 index 00000000000..e1e2a337ee3 --- /dev/null +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableObserver.test.ts @@ -0,0 +1,141 @@ +/** + * 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 {buildEditorFromExtensions} from '@lexical/extension'; +import { + $createTableNodeWithDimensions, + getTableObserverFromTableElement, + type HTMLTableElementWithWithTableSelectionState, + TableExtension, +} from '@lexical/table'; +import { + $getRoot, + defineExtension, + type LexicalEditorWithDispose, +} from 'lexical'; +import {afterEach, assert, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface TrackedObserver { + disconnectCount: number; + targets: Node[]; +} + +describe('TableObserver tracking MutationObserver teardown (#9073)', () => { + let editor: LexicalEditorWithDispose; + let container: HTMLDivElement; + let trackedObservers: TrackedObserver[]; + let RealMutationObserver: typeof MutationObserver; + + beforeEach(() => { + // Instrument MutationObserver so the test can see the observer that + // TableObserver.trackTable() creates (it is not otherwise reachable). + trackedObservers = []; + RealMutationObserver = globalThis.MutationObserver; + globalThis.MutationObserver = class extends RealMutationObserver { + tracked: TrackedObserver = {disconnectCount: 0, targets: []}; + constructor(callback: MutationCallback) { + super(callback); + trackedObservers.push(this.tracked); + } + observe(target: Node, options?: MutationObserverInit) { + this.tracked.targets.push(target); + super.observe(target, options); + } + disconnect() { + this.tracked.disconnectCount++; + super.disconnect(); + } + }; + + container = document.createElement('div'); + document.body.appendChild(container); + editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [TableExtension], + name: 'table-observer-test', + }), + ); + editor.setRootElement(container); + editor.update( + () => { + $getRoot() + .clear() + .append($createTableNodeWithDimensions(2, 2, false)); + }, + {discrete: true}, + ); + }); + + afterEach(() => { + editor.dispose(); + document.body.removeChild(container); + globalThis.MutationObserver = RealMutationObserver; + }); + + function getTableElement(): HTMLTableElementWithWithTableSelectionState { + const tableElement = container.querySelector('table'); + assert(tableElement !== null, 'Expected table element'); + return tableElement as HTMLTableElementWithWithTableSelectionState; + } + + function getTrackingObserver( + tableElement: HTMLTableElement, + ): TrackedObserver { + const tracking = trackedObservers.filter(tracked => + tracked.targets.includes(tableElement), + ); + expect(tracking.length).toBe(1); + return tracking[0]; + } + + it('disconnects the tracking MutationObserver in removeListeners()', () => { + const tableElement = getTableElement(); + const trackingObserver = getTrackingObserver(tableElement); + expect(trackingObserver.disconnectCount).toBe(0); + + const tableObserver = getTableObserverFromTableElement(tableElement); + assert(tableObserver !== null, 'Expected TableObserver on table element'); + tableObserver.removeListeners(); + + expect(trackingObserver.disconnectCount).toBe(1); + }); + + it('does not fire the tracking MutationObserver for mutations of the detached table after editor teardown', async () => { + const tableElement = getTableElement(); + const trackingObserver = getTrackingObserver(tableElement); + + // Swallow errors reported from MutationObserver microtasks (they bypass + // editor onError) so a regression fails this test's assertions instead + // of crashing the run with an unhandled error. + const uncaughtErrors: string[] = []; + const onWindowError = (event: ErrorEvent) => { + uncaughtErrors.push(event.message); + event.preventDefault(); + }; + window.addEventListener('error', onWindowError); + try { + // Queue a mutation record in the same task as teardown; disconnect() + // must also clear the record queue so it is never delivered. + tableElement.classList.add('queued-before-teardown'); + editor.dispose(); + expect(trackingObserver.disconnectCount).toBe(1); + + // The leaked-observer callback ran through editor.read(); after + // teardown it must not run at all. + const readSpy = vi.spyOn(editor, 'read'); + tableElement.classList.add('mutated-after-teardown'); + // Flush the MutationObserver microtask checkpoint. + await Promise.resolve(); + await Promise.resolve(); + + expect(readSpy).not.toHaveBeenCalled(); + expect(uncaughtErrors).toEqual([]); + } finally { + window.removeEventListener('error', onWindowError); + } + }); +}); diff --git a/packages/lexical-website/docs/concepts/decorators.mdx b/packages/lexical-website/docs/concepts/decorators.mdx new file mode 100644 index 00000000000..7eebceb744a --- /dev/null +++ b/packages/lexical-website/docs/concepts/decorators.mdx @@ -0,0 +1,204 @@ +# Decorators + +Decorator nodes are used when a piece of editor content needs a custom view +instead of a text-only or element-only DOM representation. Common examples are +media embeds, mentions, cards, horizontal rules, or interactive controls that +need application UI inside the editor. + +A `DecoratorNode` still belongs to the `EditorState`, but its visible UI comes +from `decorate()`. The DOM element returned by `createDOM()` is the host that +Lexical reconciles. The decorated value returned by `decorate()` is rendered +into that host by the framework integration, such as React's decorators in +`@lexical/react`. + +## When to use a decorator + +Use a decorator node when: + +- The content is represented by structured data rather than editable text. +- The rendered UI is owned by your application or framework. +- Selection should treat the content as a single node or custom interactive + region. +- The node needs a separate DOM import, DOM export, or JSON serialization shape. + +Prefer `TextNode` or `ElementNode` when the content can be edited directly as +normal text or nested editor content. For example, a custom paragraph style is +usually an `ElementNode`, while a video embed is a decorator. + +## Inline and block decorators + +`DecoratorNode` is inline by default. Override `isInline()` to return `false` +for block-level content such as horizontal rules, video embeds, or cards. + +```ts +class VideoNode extends DecoratorNode { + isInline(): false { + return false; + } + + createDOM(): HTMLElement { + return document.createElement('div'); + } + + updateDOM(): false { + return false; + } + + decorate(): ReactNode { + return ; + } +} +``` + +Block decorators often also need custom selection behavior. The built-in +`HorizontalRuleNode` is a useful reference because it is a block decorator that +registers click handling and node selection support. + +## Registering decorators + +Keep a decorator's node registration and behavior in one extension instead of +splitting them across an editor config and a framework plug-in. The extension +can provide the node and register commands, transforms, or listeners that +belong to it: + +```ts +export const INSERT_VIDEO_COMMAND = /* @__PURE__ */ createCommand( + 'INSERT_VIDEO_COMMAND', +); + +export const VideoExtension = /* @__PURE__ */ defineExtension({ + name: 'example/Video', + nodes: () => [VideoNode], + register(editor) { + return editor.registerCommand( + INSERT_VIDEO_COMMAND, + videoID => { + $insertNodes([$createVideoNode(videoID)]); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ); + }, +}); +``` + +Add that extension to the root editor extension. React applications mount the +result with +[`LexicalExtensionComposer`](/docs/api/modules/lexical_react_LexicalExtensionComposer#lexicalextensioncomposer): + +```tsx +const editorExtension = /* @__PURE__ */ defineExtension({ + name: 'ExampleEditor', + namespace: 'Example', + dependencies: [VideoExtension], +}); + + + {/* toolbar and other editor UI */} +; +``` + +The command can then be dispatched from a toolbar, menu, or any other UI: + +```ts +editor.dispatchCommand(INSERT_VIDEO_COMMAND, videoID); +``` + +This keeps the configuration and registration together. A framework-independent +decorator whose `decorate()` returns `null` can use the same extension shape with +[`buildEditorFromExtensions`](/docs/api/modules/lexical_extension#buildeditorfromextensions) +outside React. + +## State and serialization + +Store only serializable data on the node or in [NodeState](/docs/concepts/node-state). +The decorated UI should be derived from that data. Do not store React elements, +DOM nodes, functions, `Map`, `Set`, or other non-serializable values in node +state. + +The example below stores a video id with NodeState and renders a React +component from that id: + +```tsx +const videoIDState = createState('videoID', { + parse: value => (typeof value === 'string' ? value : ''), +}); + +class VideoNode extends DecoratorNode { + $config() { + return this.config('video', { + extends: DecoratorNode, + stateConfigs: [{flat: true, stateConfig: videoIDState}], + }); + } + + createDOM(): HTMLElement { + return document.createElement('div'); + } + + updateDOM(): false { + return false; + } + + decorate(): ReactNode { + return ; + } +} + +export function $createVideoNode(videoID: string): VideoNode { + return $setState($create(VideoNode), videoIDState, videoID); +} +``` + +NodeState can hold richer serializable shapes as long as the state config +knows how to parse them. Use a +[`StateValueConfig`](/docs/api/modules/lexical#statevalueconfig) when a +decorator needs structured data such as ids, dimensions, captions, or embed +metadata. + +If the node needs to survive copy and paste outside Lexical, implement +`exportDOM()` in addition to JSON serialization. For importing HTML, prefer +[`DOMImportExtension`](/docs/serialization/dom-import) for new code. + +Static `importDOM()` remains the legacy alternative, but it is a separate +import mechanism and cannot be combined with `DOMImportExtension`. Choose one +pipeline; new extension-based editors should contribute their rules through +`DOMImportExtension`. + +## Rendering model + +`createDOM()` should create the stable host element for the node. `updateDOM()` +returns whether Lexical should replace that host when the node changes. Most +decorator nodes return `false` because the host can stay in place while the +decorated value changes. + +`decorate()` returns the framework-specific rendered value. In React, the +React plugin renders this value with a portal into the host element created by +`createDOM()`. Non-React integrations can use the same node data and provide +their own rendering layer. + +Keep editor data and UI state separate. The node should store the content that +belongs in the editor state, while transient UI details such as hover state, +loading state, or local component state should live inside the decorated +component. + +Decorators do not have to be tied to a UI framework. A `DecoratorNode` can +return `null` from `decorate()` and do its visible work in `createDOM()` and +`updateDOM()`, or delegate behavior to extension hooks such as +[`DOMRenderExtension`](/docs/serialization/dom-render) or a mutation listener. +`HorizontalRuleNode` from `@lexical/extension` is an example of a decorator +node whose behavior can be managed without rendering a React component from +`decorate()`. + +Decorator nodes also combine well with +[named slots](/docs/concepts/named-slots) when surrounding +application UI needs to be mounted in predictable places around the editor. For +advanced DOM ownership, `DOMSlot` and `ElementNode` patterns let an extension +place arbitrary DOM inside or around lexical content while still letting +Lexical manage the actual editor children. Use those patterns when the content +inside the custom UI should remain editable by Lexical. The playground's +`ReviewExtension` and `ReviewNode` on main are a useful example: the node uses +`getDOMSlot()` for its editable body, while the extension tracks live review +nodes with a mutation listener and renders the surrounding review UI. Use these +patterns only when a plain `ElementNode`, framework decorator, or portal would +not give enough control. diff --git a/packages/lexical-website/docs/concepts/nodes.mdx b/packages/lexical-website/docs/concepts/nodes.mdx index 324585c3dc8..d6cf2e60739 100644 --- a/packages/lexical-website/docs/concepts/nodes.mdx +++ b/packages/lexical-website/docs/concepts/nodes.mdx @@ -72,6 +72,7 @@ Leaf type of node that contains text. It also includes few text-specific propert Wrapper node to insert arbitrary view (component) inside the editor. Decorator node rendering is framework-agnostic and can output components from React, vanilla js or other frameworks. +See [Decorators](/docs/concepts/decorators) for guidance on when and how to use them. ## Node Properties diff --git a/packages/lexical-website/sidebars.js b/packages/lexical-website/sidebars.js index 88497e5658d..5e896d4161c 100644 --- a/packages/lexical-website/sidebars.js +++ b/packages/lexical-website/sidebars.js @@ -41,6 +41,7 @@ const sidebars = { items: [ 'concepts/editor-state', 'concepts/nodes', + 'concepts/decorators', 'concepts/node-replacement', 'concepts/node-state', 'concepts/named-slots',