From 8c0a0e8a2951c511aeb36356e473032c0ffc381f Mon Sep 17 00:00:00 2001 From: duyda <34796192+duydang2311@users.noreply.github.com> Date: Mon, 18 May 2026 03:37:47 +0700 Subject: [PATCH 1/4] [lexical-website] Bug Fix: Correct links to included extensions (#8523) --- .../docs/extensions/included-extensions.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/lexical-website/docs/extensions/included-extensions.md b/packages/lexical-website/docs/extensions/included-extensions.md index 2f55ce5841b..329e7f90f8a 100644 --- a/packages/lexical-website/docs/extensions/included-extensions.md +++ b/packages/lexical-website/docs/extensions/included-extensions.md @@ -5,13 +5,13 @@ - [CodeExtension](/docs/api/modules/lexical_code#codeextension) - CodeNode (code blocks) - [CodeIndentExtension](/docs/api/modules/lexical_code#codeindentextension) - CodeNode tab key indentation (code blocks) -[@lexical/code-prism](/docs/api/modules/lexical_code_-_prism) +[@lexical/code-prism](/docs/api/modules/lexical_code-prism) -- [CodePrismExtension](/docs/api/modules/lexical_code_prism#codeprismextension) - Highlighting with Prism for CodeNode +- [CodePrismExtension](/docs/api/modules/lexical_code-prism#codeprismextension) - Highlighting with Prism for CodeNode -[@lexical/code-shiki](/docs/api/modules/lexical_code_-_shiki) +[@lexical/code-shiki](/docs/api/modules/lexical_code-shiki) -- [CodeShikiExtension](/docs/api/modules/lexical_code_shiki#codeshikiextension) - Highlighting with Shiki for CodeNode +- [CodeShikiExtension](/docs/api/modules/lexical_code-shiki#codeshikiextension) - Highlighting with Shiki for CodeNode [@lexical/dragon](/docs/api/modules/lexical_dragon) @@ -55,9 +55,9 @@ - [OverflowExtension](/docs/api/modules/lexical_overflow#overflowextension) - OverflowNode -[@lexical/plain-text](/docs/api/modules/lexical_plain_text) +[@lexical/plain-text](/docs/api/modules/lexical_plain-text) -- [PlainTextExtension](/docs/api/modules/lexical_plain_text#plaintextextension) - Plain text editor, the return key creates a LineBreakNode by default (one ParagraphNode per document) +- [PlainTextExtension](/docs/api/modules/lexical_plain-text#plaintextextension) - Plain text editor, the return key creates a LineBreakNode by default (one ParagraphNode per document) `@lexical/react` @@ -66,9 +66,9 @@ - [ReactProviderExtension](/docs/api/modules/lexical_react_ReactProviderExtension#reactproviderextension) - [TreeViewExtension](/docs/api/modules/lexical_react_TreeViewExtension#treeviewextension) -[@lexical/rich-text](/docs/api/modules/lexical_rich_text) +[@lexical/rich-text](/docs/api/modules/lexical_rich-text) -- [RichTextExtension](/docs/api/modules/lexical_rich_text#richtextextension) - Rich Text editor (QuoteNode, HeadingNode), the return key creates a ParagraphNode by default (multiple ParagraphNode per document). Includes configurable `escapeFormatTriggers` to escape text formatting (e.g. code) at text node boundaries +- [RichTextExtension](/docs/api/modules/lexical_rich-text#richtextextension) - Rich Text editor (QuoteNode, HeadingNode), the return key creates a ParagraphNode by default (multiple ParagraphNode per document). Includes configurable `escapeFormatTriggers` to escape text formatting (e.g. code) at text node boundaries [@lexical/table](/docs/api/modules/lexical_table) From 7a95e8ff71d87772ae809ed69fbfe440bd0146c7 Mon Sep 17 00:00:00 2001 From: Abhinav Gautam Date: Mon, 18 May 2026 04:46:56 +0530 Subject: [PATCH 2/4] [lexical-markdown] Bug Fix: run element markdown shortcuts on Enter (#8488) --- .../flow/LexicalMarkdown.js.flow | 1 + .../lexical-markdown/src/MarkdownShortcuts.ts | 28 ++++-- .../src/MarkdownTransformers.ts | 15 ++- .../__tests__/unit/LexicalMarkdown.test.ts | 96 +++++++++++++++++++ .../src/plugins/MarkdownTransformers/index.ts | 2 + .../src/LexicalMarkdownShortcutPlugin.tsx | 1 + 6 files changed, 134 insertions(+), 9 deletions(-) diff --git a/packages/lexical-markdown/flow/LexicalMarkdown.js.flow b/packages/lexical-markdown/flow/LexicalMarkdown.js.flow index f0c247a1900..cb5cf325ab6 100644 --- a/packages/lexical-markdown/flow/LexicalMarkdown.js.flow +++ b/packages/lexical-markdown/flow/LexicalMarkdown.js.flow @@ -36,6 +36,7 @@ export type ElementTransformer = { isImport: boolean, ) => boolean | void, type: 'element', + triggerOnEnter?: boolean, }; export type MultilineElementTransformer = { diff --git a/packages/lexical-markdown/src/MarkdownShortcuts.ts b/packages/lexical-markdown/src/MarkdownShortcuts.ts index b015aed0a48..64c3d4ac3b8 100644 --- a/packages/lexical-markdown/src/MarkdownShortcuts.ts +++ b/packages/lexical-markdown/src/MarkdownShortcuts.ts @@ -44,6 +44,7 @@ function runElementTransformers( anchorNode: TextNode, anchorOffset: number, elementTransformers: ReadonlyArray, + triggerOnEnter?: boolean, ): boolean { const grandParentNode = parentNode.getParent(); @@ -62,18 +63,21 @@ function runElementTransformers( // TODO: // Can have a quick check if caret is close enough to the beginning of the string (e.g. offset less than 10-20) // since otherwise it won't be a markdown shortcut, but tables are exception - if (textContent[anchorOffset - 1] !== ' ') { - return false; + if (!triggerOnEnter) { + if (textContent[anchorOffset - 1] !== ' ') { + return false; + } } for (const {regExp, replace} of elementTransformers) { const match = textContent.match(regExp); - if ( - match && - match[0].length === - (match[0].endsWith(' ') ? anchorOffset : anchorOffset - 1) - ) { + const expectedMatchLength = + triggerOnEnter || (match && match[0].endsWith(' ')) + ? anchorOffset + : anchorOffset - 1; + + if (match && match[0].length === expectedMatchLength) { const nextSiblings = anchorNode.getNextSiblings(); const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset); const siblings = remainderNode @@ -437,6 +441,9 @@ export function registerMarkdownShortcuts( transformers: Array = TRANSFORMERS, ): () => void { const byType = transformersByType(transformers); + const elementTransformersForEnter = byType.element.filter( + t => t.triggerOnEnter, + ); const textFormatTransformersByTrigger = indexBy( byType.textFormat, ({tag}) => tag[tag.length - 1], @@ -647,6 +654,13 @@ export function registerMarkdownShortcuts( anchorOffset, byType.multilineElement, true, + ) || + runElementTransformers( + parentNode, + anchorNode, + anchorOffset, + elementTransformersForEnter, + true, ) ) { if (event !== null) { diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts index da8e762b23a..651f710117b 100644 --- a/packages/lexical-markdown/src/MarkdownTransformers.ts +++ b/packages/lexical-markdown/src/MarkdownTransformers.ts @@ -88,6 +88,12 @@ export type ElementTransformer = { isImport: boolean, ) => boolean | void; type: 'element'; + /** + * When set, `registerMarkdownShortcuts` may run this transformer from `KEY_ENTER_COMMAND` + * at end-of-line without requiring a trailing space (the update listener still uses the + * space after the markdown token). Omit or false to disable Enter-triggered shortcuts. + */ + triggerOnEnter?: boolean; }; export type MultilineElementTransformer = { @@ -483,6 +489,7 @@ export const HEADING: ElementTransformer = { const tag = ('h' + match[1].length) as HeadingTagType; return $createHeadingNode(tag); }), + triggerOnEnter: true, type: 'element', }; @@ -521,12 +528,12 @@ export const QUOTE: ElementTransformer = { node.select(0, 0); } }, + triggerOnEnter: true, type: 'element', }; export const CODE: MultilineElementTransformer = { dependencies: [CodeNode], - export: (node: LexicalNode) => { if (!$isCodeNode(node)) { return null; @@ -616,11 +623,11 @@ export const CODE: MultilineElementTransformer = { CODE.replace(rootNode, null, startMatch, null, linesInBetween, true); return [true, lines.length - 1]; }, + regExpEnd: { optional: true, regExp: CODE_END_REGEX, }, - regExpStart: CODE_START_REGEX, replace: ( @@ -680,6 +687,7 @@ export const CODE: MultilineElementTransformer = { })(rootNode, children, startMatch, isImport); } }, + type: 'multiline-element', }; @@ -692,6 +700,7 @@ export const UNORDERED_LIST: ElementTransformer = { }, regExp: UNORDERED_LIST_REGEX, replace: listReplace('bullet'), + triggerOnEnter: true, type: 'element', }; @@ -704,6 +713,7 @@ export const CHECK_LIST: ElementTransformer = { }, regExp: CHECK_LIST_REGEX, replace: listReplace('check'), + triggerOnEnter: true, type: 'element', }; @@ -716,6 +726,7 @@ export const ORDERED_LIST: ElementTransformer = { }, regExp: ORDERED_LIST_REGEX, replace: listReplace('number'), + triggerOnEnter: true, type: 'element', }; diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index e58b84acabe..252db7ec78f 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -1397,6 +1397,102 @@ describe('Markdown', () => { '

```

', ); }); + + it('should transform element markdown on Enter when trailing space was not required (custom element transformer)', () => { + const ELEMENT_TRIGGERED_FENCE: ElementTransformer = { + dependencies: [CodeNode], + export: () => null, + regExp: /^`{3,}(\w+)?$/, + replace: (parentNode, children, match, isImport) => { + const node = $createCodeNode(match[1]); + node.append(...children); + parentNode.replace(node); + if (!isImport) { + node.select(0, 0); + } + }, + triggerOnEnter: true, + type: 'element', + }; + + const editor = createHeadlessEditor({ + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + CodeNode, + LinkNode, + ], + }); + + registerMarkdownShortcuts(editor, [ELEMENT_TRIGGERED_FENCE]); + + editor.update( + () => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + paragraph.selectEnd(); + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.insertText('```'); + } + }, + {discrete: true}, + ); + + editor.update( + () => { + editor.dispatchCommand(KEY_ENTER_COMMAND, null); + }, + {discrete: true}, + ); + + expect(editor.read(() => $generateHtmlFromNodes(editor))).toBe( + '
',
+      );
+    });
+
+    it('should transform heading on Enter when a line was inserted at once (no trailing space listener trigger)', () => {
+      const editor = createHeadlessEditor({
+        nodes: [
+          HeadingNode,
+          ListNode,
+          ListItemNode,
+          QuoteNode,
+          CodeNode,
+          LinkNode,
+        ],
+      });
+
+      registerMarkdownShortcuts(editor, [HEADING]);
+
+      editor.update(
+        () => {
+          const root = $getRoot();
+          const paragraph = $createParagraphNode();
+          root.append(paragraph);
+          paragraph.selectEnd();
+          const selection = $getSelection();
+          if ($isRangeSelection(selection)) {
+            selection.insertText('## ');
+          }
+        },
+        {discrete: true},
+      );
+
+      editor.update(
+        () => {
+          editor.dispatchCommand(KEY_ENTER_COMMAND, null);
+        },
+        {discrete: true},
+      );
+
+      expect(editor.read(() => $generateHtmlFromNodes(editor))).toBe(
+        '


', + ); + }); }); describe('composition-end trigger characters (#7026)', () => { diff --git a/packages/lexical-playground/src/plugins/MarkdownTransformers/index.ts b/packages/lexical-playground/src/plugins/MarkdownTransformers/index.ts index c7f1a689f99..664f5b9f68f 100644 --- a/packages/lexical-playground/src/plugins/MarkdownTransformers/index.ts +++ b/packages/lexical-playground/src/plugins/MarkdownTransformers/index.ts @@ -69,6 +69,7 @@ export const HR: ElementTransformer = { line.selectNext(); }, + triggerOnEnter: true, type: 'element', }; @@ -146,6 +147,7 @@ export const TWEET: ElementTransformer = { const tweetNode = $createTweetNode(id); textNode.replace(tweetNode); }, + triggerOnEnter: true, type: 'element', }; diff --git a/packages/lexical-react/src/LexicalMarkdownShortcutPlugin.tsx b/packages/lexical-react/src/LexicalMarkdownShortcutPlugin.tsx index 4551b75c675..7b7a721b8ac 100644 --- a/packages/lexical-react/src/LexicalMarkdownShortcutPlugin.tsx +++ b/packages/lexical-react/src/LexicalMarkdownShortcutPlugin.tsx @@ -36,6 +36,7 @@ const HR: ElementTransformer = { line.selectNext(); }, + triggerOnEnter: true, type: 'element', }; export const DEFAULT_TRANSFORMERS = [HR, ...TRANSFORMERS]; From 5ba845b03734ae50c87c595929580c0af521e513 Mon Sep 17 00:00:00 2001 From: Abhinav Gautam Date: Mon, 18 May 2026 04:47:05 +0530 Subject: [PATCH 3/4] [lexical-react] Feature: optional async onClose for LexicalTypeaheadMenuPlugin (#8489) Co-authored-by: Bob Ippolito --- .../src/plugins/EmojiPickerPlugin/index.tsx | 4 +- .../flow/LexicalTypeaheadMenuPlugin.js.flow | 2 +- .../src/LexicalTypeaheadMenuPlugin.tsx | 20 +- .../src/__tests__/unit/LexicalMenu.test.tsx | 65 ++++- .../unit/LexicalTypeaheadMenuPlugin.test.tsx | 276 +++++++++++++++++- .../lexical-react/src/shared/LexicalMenu.tsx | 4 +- 6 files changed, 360 insertions(+), 11 deletions(-) diff --git a/packages/lexical-playground/src/plugins/EmojiPickerPlugin/index.tsx b/packages/lexical-playground/src/plugins/EmojiPickerPlugin/index.tsx index 79371a198f1..3c8131db23c 100644 --- a/packages/lexical-playground/src/plugins/EmojiPickerPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/EmojiPickerPlugin/index.tsx @@ -75,7 +75,9 @@ export default function EmojiPickerPlugin() { ); const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(':', { - minLength: 0, + // Bare ":" must not open the menu: typeahead Enter runs before rich-text and + // would steal paragraph breaks (flaky under collab). + minLength: 1, punctuation: '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\[\\]\\\\/!%\'"~=<>:;', // allow _ and - }); diff --git a/packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow b/packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow index cc0436e7f99..78349022e3f 100644 --- a/packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow +++ b/packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow @@ -70,7 +70,7 @@ export type TypeaheadMenuPluginProps = { triggerFn: TriggerFn, menuRenderFn?: MenuRenderFn, onOpen?: (resolution: MenuResolution) => void, - onClose?: () => void, + onClose?: () => void | Promise, anchorClassName?: string, }; diff --git a/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx b/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx index 385206cd5d6..70cee96635f 100644 --- a/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx +++ b/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx @@ -204,7 +204,7 @@ export type TypeaheadMenuPluginProps = { triggerFn: TriggerFn; menuRenderFn?: MenuRenderFn; onOpen?: (resolution: MenuResolution) => void; - onClose?: () => void; + onClose?: () => void | PromiseLike; anchorClassName?: string; commandPriority?: CommandListenerPriority; parent?: HTMLElement; @@ -236,9 +236,21 @@ export function LexicalTypeaheadMenuPlugin({ ); const closeTypeahead = useCallback(() => { - setResolution(null); - if (onClose != null && resolution !== null) { - onClose(); + if (resolution === null) { + return; + } + const finish = () => { + setResolution(null); + }; + let result; + try { + result = onClose && onClose(); + } finally { + if (result) { + result.then(finish, finish); + } else { + finish(); + } } }, [onClose, resolution]); diff --git a/packages/lexical-react/src/__tests__/unit/LexicalMenu.test.tsx b/packages/lexical-react/src/__tests__/unit/LexicalMenu.test.tsx index c0746a5f466..927b809e212 100644 --- a/packages/lexical-react/src/__tests__/unit/LexicalMenu.test.tsx +++ b/packages/lexical-react/src/__tests__/unit/LexicalMenu.test.tsx @@ -6,7 +6,7 @@ * */ -import {LexicalEditor} from 'lexical'; +import {KEY_ENTER_COMMAND, LexicalEditor} from 'lexical'; import {createTestEditor} from 'lexical/src/__tests__/utils'; import * as React from 'react'; import ReactDOM from 'react-dom'; @@ -227,6 +227,69 @@ describe('LexicalMenu', () => { expect(portal).toBeNull(); }); + it('should not select an option when Enter is pressed with Shift (line break / fall-through)', async () => { + const onSelectOption = vi.fn(); + const options = [new TestOption('Option A'), new TestOption('Option B')]; + + await ReactTestUtils.act(async () => { + reactRoot.render( + + close={vi.fn()} + editor={editor} + anchorElementRef={{current: anchorElement}} + resolution={createTestResolution('test')} + options={options} + onSelectOption={onSelectOption} + preselectFirstItem={true} + />, + ); + }); + + const shiftEnter = { + preventDefault: vi.fn(), + shiftKey: true, + stopImmediatePropagation: vi.fn(), + } as unknown as KeyboardEvent; + + await ReactTestUtils.act(async () => { + editor.dispatchCommand(KEY_ENTER_COMMAND, shiftEnter); + }); + + expect(onSelectOption).not.toHaveBeenCalled(); + }); + + it('should select an option when Enter is pressed without Shift', async () => { + const onSelectOption = vi.fn(); + const options = [new TestOption('Option A'), new TestOption('Option B')]; + + await ReactTestUtils.act(async () => { + reactRoot.render( + + close={vi.fn()} + editor={editor} + anchorElementRef={{current: anchorElement}} + resolution={createTestResolution('test')} + options={options} + onSelectOption={onSelectOption} + preselectFirstItem={true} + />, + ); + }); + + const enter = { + preventDefault: vi.fn(), + shiftKey: false, + stopImmediatePropagation: vi.fn(), + } as unknown as KeyboardEvent; + + await ReactTestUtils.act(async () => { + editor.dispatchCommand(KEY_ENTER_COMMAND, enter); + }); + + expect(onSelectOption).toHaveBeenCalledTimes(1); + expect(onSelectOption.mock.calls[0][0]).toBe(options[0]); + }); + it('should render icon and title in default MenuItem', async () => { const option = new TestOption('With Icon'); option.icon = ; diff --git a/packages/lexical-react/src/__tests__/unit/LexicalTypeaheadMenuPlugin.test.tsx b/packages/lexical-react/src/__tests__/unit/LexicalTypeaheadMenuPlugin.test.tsx index 564fc48e9ae..11a0f846126 100644 --- a/packages/lexical-react/src/__tests__/unit/LexicalTypeaheadMenuPlugin.test.tsx +++ b/packages/lexical-react/src/__tests__/unit/LexicalTypeaheadMenuPlugin.test.tsx @@ -8,6 +8,7 @@ import {LexicalComposer} from '@lexical/react/LexicalComposer'; import {ContentEditable} from '@lexical/react/LexicalContentEditable'; +import {EditorRefPlugin} from '@lexical/react/LexicalEditorRefPlugin'; import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; import { @@ -16,7 +17,14 @@ import { MenuRenderFn, useBasicTypeaheadTriggerMatch, } from '@lexical/react/LexicalTypeaheadMenuPlugin'; -import {TextNode} from 'lexical'; +import { + $createParagraphNode, + $getRoot, + DELETE_CHARACTER_COMMAND, + LexicalEditor, + ParagraphNode, + TextNode, +} from 'lexical'; import * as React from 'react'; import {useCallback} from 'react'; import ReactDOM from 'react-dom'; @@ -127,13 +135,16 @@ function TypeaheadPluginWithoutMenuRenderFn({ ); } -function createApp(plugin: React.ReactNode): React.FC { +function createApp( + plugin: React.ReactNode, + nodes: Array = [], +): React.FC { return function App() { return ( { throw err; }, @@ -230,4 +241,263 @@ describe('LexicalTypeaheadMenuPlugin', () => { expect(container.querySelector('[contenteditable]')).not.toBeNull(); }); }); + + describe('onClose', () => { + let patchedSelectionModify = false; + + beforeEach(() => { + class ResizeObserverMock { + // LexicalMenu only constructs ResizeObserver and calls observe/unobserve/disconnect. + constructor(_callback: unknown) {} + observe() {} + unobserve() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + + if (typeof Selection.prototype.modify !== 'function') { + patchedSelectionModify = true; + Selection.prototype.modify = function ( + this: Selection, + alter: string, + direction: string, + granularity: string, + ): void { + const node = this.anchorNode; + if ( + node?.nodeType !== Node.TEXT_NODE || + direction !== 'backward' || + granularity !== 'character' + ) { + return; + } + const text = node as Text; + const o = this.focusOffset; + if (o <= 0) { + return; + } + if (alter === 'extend') { + this.setBaseAndExtent(text, o - 1, text, o); + } else if (alter === 'move') { + this.setBaseAndExtent(text, o - 1, text, o - 1); + } + }; + } + }); + + afterEach(() => { + vi.unstubAllGlobals(); + if (patchedSelectionModify) { + delete (Selection.prototype as {modify?: unknown}).modify; + patchedSelectionModify = false; + } + }); + + it('awaits async onClose before unmounting the menu', async () => { + const editorRef = React.createRef(); + + let resolveOnClose!: () => void; + const onClose = vi.fn( + () => + new Promise(resolve => { + resolveOnClose = resolve; + }), + ); + + const menuRenderFn: MenuRenderFn = ( + anchorElementRef, + itemProps, + matchingString, + ) => { + return anchorElementRef.current && itemProps.options.length + ? ReactDOM.createPortal( +
+
    + {itemProps.options.map((option, i) => ( +
  • + {option.title} +
  • + ))} +
+ {matchingString != null && ( + {matchingString} + )} +
, + anchorElementRef.current, + ) + : null; + }; + + function Harness() { + const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { + minLength: 0, + }); + const onSelectOption = useCallback( + ( + _option: TestMenuOption, + _nodeToRemove: TextNode | null, + closeMenu: () => void, + ) => { + closeMenu(); + }, + [], + ); + return ( + + onQueryChange={vi.fn()} + onSelectOption={onSelectOption} + triggerFn={checkForTriggerMatch} + options={TEST_OPTIONS} + menuRenderFn={menuRenderFn} + onClose={onClose} + /> + ); + } + + const App = createApp( + <> + + + , + [ParagraphNode], + ); + + await ReactTestUtils.act(async () => { + reactRoot.render(); + }); + + const editor = editorRef.current; + expect(editor).not.toBeNull(); + + await ReactTestUtils.act(async () => { + editor!.update(() => { + $getRoot() + .clear() + .append($createParagraphNode()) + .select() + .insertText('/'); + }); + }); + + expect( + document.querySelector('[data-testid="custom-typeahead"]'), + ).not.toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + + await ReactTestUtils.act(async () => { + editor!.dispatchCommand(DELETE_CHARACTER_COMMAND, true); + await Promise.resolve(); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect( + document.querySelector('[data-testid="custom-typeahead"]'), + ).not.toBeNull(); + + await ReactTestUtils.act(async () => { + resolveOnClose(); + await Promise.resolve(); + }); + + expect( + document.querySelector('[data-testid="custom-typeahead"]'), + ).toBeNull(); + }); + + it('runs synchronous onClose before clearing the menu', async () => { + const editorRef = React.createRef(); + const callOrder: string[] = []; + + const onClose = vi.fn(() => { + callOrder.push('onClose'); + }); + + const menuRenderFn: MenuRenderFn = ( + anchorElementRef, + itemProps, + matchingString, + ) => { + return anchorElementRef.current && itemProps.options.length + ? ReactDOM.createPortal( +
+
    + {matchingString != null && ( + {matchingString} + )} +
, + anchorElementRef.current, + ) + : null; + }; + + function Harness() { + const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { + minLength: 0, + }); + const onSelectOption = useCallback( + ( + _option: TestMenuOption, + _nodeToRemove: TextNode | null, + closeMenu: () => void, + ) => { + closeMenu(); + }, + [], + ); + return ( + + onQueryChange={vi.fn()} + onSelectOption={onSelectOption} + triggerFn={checkForTriggerMatch} + options={TEST_OPTIONS} + menuRenderFn={menuRenderFn} + onClose={onClose} + /> + ); + } + + const App = createApp( + <> + + + , + [ParagraphNode], + ); + + await ReactTestUtils.act(async () => { + reactRoot.render(); + }); + + const editor = editorRef.current; + expect(editor).not.toBeNull(); + + await ReactTestUtils.act(async () => { + editor!.update(() => { + $getRoot() + .clear() + .append($createParagraphNode()) + .select() + .insertText('/'); + }); + }); + + await ReactTestUtils.act(async () => { + editor!.dispatchCommand(DELETE_CHARACTER_COMMAND, true); + await Promise.resolve(); + }); + + expect(callOrder).toEqual(['onClose']); + expect(onClose).toHaveBeenCalledTimes(1); + expect( + document.querySelector('[data-testid="custom-typeahead"]'), + ).toBeNull(); + }); + }); }); diff --git a/packages/lexical-react/src/shared/LexicalMenu.tsx b/packages/lexical-react/src/shared/LexicalMenu.tsx index f39cded93b9..6e42623072a 100644 --- a/packages/lexical-react/src/shared/LexicalMenu.tsx +++ b/packages/lexical-react/src/shared/LexicalMenu.tsx @@ -546,7 +546,9 @@ export function LexicalMenu({ if ( options === null || selectedIndex === null || - options[selectedIndex] == null + options[selectedIndex] == null || + // Shift+Enter must reach rich-text line-break handling + (event && event.shiftKey) ) { return false; } From e65fcd7c83987a3bcf38b38b5968fa9d5a443ed1 Mon Sep 17 00:00:00 2001 From: Sergey Gorbachev Date: Mon, 18 May 2026 02:36:59 +0300 Subject: [PATCH 4/4] [lexical][lexical-yjs][lexical-playground] Chore: Respect browserslist (#8512) Co-authored-by: Bob Ippolito --- .prettierignore | 1 + eslint.config.mjs | 4 ++ package.json | 7 ++ .../src/plugins/AutoLinkExtension/index.ts | 2 +- .../src/plugins/AutocompletePlugin/index.tsx | 1 + .../src/plugins/ContextMenuPlugin/index.tsx | 70 +++++++++++-------- .../PagesReactExtension/PageSetupDropdown.tsx | 2 +- .../src/utils/docSerialization.ts | 31 +++++++- .../src/shared/useCharacterLimit.ts | 5 +- packages/lexical-yjs/src/Bindings.ts | 4 +- pnpm-lock.yaml | 43 ++++++++++++ 11 files changed, 130 insertions(+), 40 deletions(-) diff --git a/.prettierignore b/.prettierignore index c4637c515a4..bc2b4cfdeea 100644 --- a/.prettierignore +++ b/.prettierignore @@ -26,3 +26,4 @@ flow-typed test-results /libdefs/*.js pnpm-lock.yaml +**/.next/** diff --git a/eslint.config.mjs b/eslint.config.mjs index 818dc790b6c..ec08a4b9cf2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,7 @@ import {fixupPluginRules} from '@eslint/compat'; import js from '@eslint/js'; import lexicalInternalPlugin from '@lexical/eslint-plugin-internal'; import prettierConfig from 'eslint-config-prettier'; +import compat from 'eslint-plugin-compat'; import _headerPlugin from 'eslint-plugin-header'; import importXPlugin from 'eslint-plugin-import-x'; import jsxA11yPlugin from 'eslint-plugin-jsx-a11y'; @@ -447,4 +448,7 @@ export default [ // Prettier must be last to override formatting rules prettierConfig, + + // Compatibility with browserslist + compat.configs['flat/recommended'], ]; diff --git a/package.json b/package.json index e430ab5ef1e..6feaf6e7c69 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ "cross-env": "^10.1.0", "eslint": "^10.3.0", "eslint-config-prettier": "^10.0.0", + "eslint-plugin-compat": "^7.0.2", "eslint-plugin-header": "^3.1.1", "eslint-plugin-import-x": "^4.16.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -196,6 +197,12 @@ "semver": "^7.7.4", "yjs": "^13.6.30" }, + "browserslist": [ + "Chrome >= 86", + "Firefox >= 115", + "Safari >= 15", + "Edge >= 86" + ], "pnpm": { "peerDependencyRules": { "allowedVersions": { diff --git a/packages/lexical-playground/src/plugins/AutoLinkExtension/index.ts b/packages/lexical-playground/src/plugins/AutoLinkExtension/index.ts index c256d12c6d5..47fbfdff55e 100644 --- a/packages/lexical-playground/src/plugins/AutoLinkExtension/index.ts +++ b/packages/lexical-playground/src/plugins/AutoLinkExtension/index.ts @@ -11,7 +11,7 @@ import {AutoLinkExtension, createLinkMatcherWithRegExp} from '@lexical/link'; import {configExtension} from 'lexical'; const URL_REGEX = - /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)(?()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/; diff --git a/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx b/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx index 6d80dd66efe..dc35e58d1c7 100644 --- a/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx @@ -90,6 +90,7 @@ function useQuery(): (searchText: string) => SearchPromise { } function formatSuggestionText(suggestion: string): string { + // eslint-disable-next-line compat/compat const userAgentData = window.navigator.userAgentData; const isMobile = userAgentData !== undefined diff --git a/packages/lexical-playground/src/plugins/ContextMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/ContextMenuPlugin/index.tsx index b2afe7295eb..60a94480345 100644 --- a/packages/lexical-playground/src/plugins/ContextMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ContextMenuPlugin/index.tsx @@ -67,25 +67,29 @@ export default function ContextMenuPlugin(): JSX.Element { const readClipboardItems = await navigator.clipboard.read(); const item = readClipboardItems[0]; - const permission = await navigator.permissions.query({ - // @ts-expect-error These types are incorrect. - name: 'clipboard-read', - }); - if (permission.state === 'denied') { - alert('Not allowed to paste from clipboard.'); - return; - } + if (navigator.permissions) { + const permission = await navigator.permissions.query({ + // @ts-expect-error These types are incorrect. + name: 'clipboard-read', + }); + if (permission.state === 'denied') { + alert('Not allowed to paste from clipboard.'); + return; + } - for (const type of item.types) { - const dataString = await (await item.getType(type)).text(); - data.setData(type, dataString); - } + for (const type of item.types) { + const dataString = await (await item.getType(type)).text(); + data.setData(type, dataString); + } - const event = new ClipboardEvent('paste', { - clipboardData: data, - }); + const event = new ClipboardEvent('paste', { + clipboardData: data, + }); - editor.dispatchCommand(PASTE_COMMAND, event); + editor.dispatchCommand(PASTE_COMMAND, event); + } else { + alert('Your browser does not support navigator.permissions'); + } }); }, disabled: false, @@ -96,24 +100,28 @@ export default function ContextMenuPlugin(): JSX.Element { new NodeContextMenuOption(`Paste as Plain Text`, { $onSelect: () => { navigator.clipboard.read().then(async function (...args) { - const permission = await navigator.permissions.query({ - // @ts-expect-error These types are incorrect. - name: 'clipboard-read', - }); + if (navigator.permissions) { + const permission = await navigator.permissions.query({ + // @ts-expect-error These types are incorrect. + name: 'clipboard-read', + }); - if (permission.state === 'denied') { - alert('Not allowed to paste from clipboard.'); - return; - } + if (permission.state === 'denied') { + alert('Not allowed to paste from clipboard.'); + return; + } - const data = new DataTransfer(); - const clipboardText = await navigator.clipboard.readText(); - data.setData('text/plain', clipboardText); + const data = new DataTransfer(); + const clipboardText = await navigator.clipboard.readText(); + data.setData('text/plain', clipboardText); - const event = new ClipboardEvent('paste', { - clipboardData: data, - }); - editor.dispatchCommand(PASTE_COMMAND, event); + const event = new ClipboardEvent('paste', { + clipboardData: data, + }); + editor.dispatchCommand(PASTE_COMMAND, event); + } else { + alert('Your browser does not support navigator.permissions'); + } }); }, disabled: false, diff --git a/packages/lexical-playground/src/plugins/PagesReactExtension/PageSetupDropdown.tsx b/packages/lexical-playground/src/plugins/PagesReactExtension/PageSetupDropdown.tsx index 30ca6350298..79008ff79cd 100644 --- a/packages/lexical-playground/src/plugins/PagesReactExtension/PageSetupDropdown.tsx +++ b/packages/lexical-playground/src/plugins/PagesReactExtension/PageSetupDropdown.tsx @@ -31,7 +31,7 @@ const MARGIN_PRESETS: ReadonlyArray<{ }, { label: 'Normal (0.4")', - margins: structuredClone(DEFAULT_PAGE_SETUP.margins), + margins: {...DEFAULT_PAGE_SETUP.margins}, }, { label: 'Moderate (0.75")', diff --git a/packages/lexical-playground/src/utils/docSerialization.ts b/packages/lexical-playground/src/utils/docSerialization.ts index 17b711ef350..ae9c61a21fb 100644 --- a/packages/lexical-playground/src/utils/docSerialization.ts +++ b/packages/lexical-playground/src/utils/docSerialization.ts @@ -7,6 +7,7 @@ */ import {SerializedDocument} from '@lexical/file'; +import warnOnlyOnce from 'shared/warnOnlyOnce'; // eslint-disable-next-line @typescript-eslint/no-explicit-any async function* generateReader( @@ -36,8 +37,16 @@ async function readBytestoString( return output.join(''); } +const CompressionAPIWarning = warnOnlyOnce( + 'Your browser does not support CompressionStream/DecompressionStream', +); + export async function docToHash(doc: SerializedDocument): Promise { - const cs = new CompressionStream('gzip'); + const cs = getCompressionStream(); + if (!cs) { + CompressionAPIWarning(); + return ''; + } const writer = cs.writable.getWriter(); const [, output] = await Promise.all([ writer @@ -51,6 +60,20 @@ export async function docToHash(doc: SerializedDocument): Promise { .replace(/=+$/, '')}`; } +// Feature detection is extracted to functions as a workaround until +// https://github.com/amilajack/eslint-plugin-compat/pull/687 lands +function getCompressionStream() { + if (typeof CompressionStream !== 'undefined') { + return new CompressionStream('gzip'); + } +} + +function getDecompressionStream() { + if (typeof DecompressionStream !== 'undefined') { + return new DecompressionStream('gzip'); + } +} + export async function docFromHash( hash: string, ): Promise { @@ -58,7 +81,11 @@ export async function docFromHash( if (!m) { return null; } - const ds = new DecompressionStream('gzip'); + const ds = getDecompressionStream(); + if (!ds) { + CompressionAPIWarning(); + return null; + } const writer = ds.writable.getWriter(); const b64 = atob(m[1].replace(/_/g, '/').replace(/-/g, '+')); const array = new Uint8Array(b64.length); diff --git a/packages/lexical-react/src/shared/useCharacterLimit.ts b/packages/lexical-react/src/shared/useCharacterLimit.ts index 8e65f4063c9..266fc5c2688 100644 --- a/packages/lexical-react/src/shared/useCharacterLimit.ts +++ b/packages/lexical-react/src/shared/useCharacterLimit.ts @@ -133,12 +133,11 @@ function findOffset( maxCharacters: number, strlen: (input: string) => number, ): number { - const Segmenter = Intl.Segmenter; let offsetUtf16 = 0; let offset = 0; - if (typeof Segmenter === 'function') { - const segmenter = new Segmenter(); + if (typeof Intl.Segmenter === 'function') { + const segmenter = new Intl.Segmenter(); const graphemes = segmenter.segment(text); for (const {segment: grapheme} of graphemes) { diff --git a/packages/lexical-yjs/src/Bindings.ts b/packages/lexical-yjs/src/Bindings.ts index f29bbdbd591..a0331a07166 100644 --- a/packages/lexical-yjs/src/Bindings.ts +++ b/packages/lexical-yjs/src/Bindings.ts @@ -127,9 +127,9 @@ export function createBindingV2__EXPERIMENTAL( } export function isBindingV1(binding: BaseBinding): binding is Binding { - return Object.hasOwn(binding, 'collabNodeMap'); + return Object.prototype.hasOwnProperty.call(binding, 'collabNodeMap'); } export function isBindingV2(binding: BaseBinding): binding is BindingV2 { - return Object.hasOwn(binding, 'mapping'); + return Object.prototype.hasOwnProperty.call(binding, 'mapping'); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b69a4deef86..142fa633622 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: eslint-config-prettier: specifier: ^10.0.0 version: 10.1.8(eslint@10.3.0(jiti@2.6.1)) + eslint-plugin-compat: + specifier: ^7.0.2 + version: 7.0.2(eslint@10.3.0(jiti@2.6.1)) eslint-plugin-header: specifier: ^3.1.1 version: 3.1.1(eslint@10.3.0(jiti@2.6.1)) @@ -2984,6 +2987,12 @@ packages: '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + '@mdn/browser-compat-data@5.7.6': + resolution: {integrity: sha512-7xdrMX0Wk7grrTZQwAoy1GkvPMFoizStUoL+VmtUkAxegbCCec+3FKwOM6yc/uGU5+BEczQHXAlWiqvM8JeENg==} + + '@mdn/browser-compat-data@6.1.5': + resolution: {integrity: sha512-PzdZZzRhcXvKB0begee28n5lvwAcinGKYuLZOVxHAZm+n7y01ddEGfdS1ZXRuVcV+ndG6mSEAE8vgudom5UjYg==} + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -5760,6 +5769,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-metadata-inferer@0.8.1: + resolution: {integrity: sha512-ht3Dm6Zr7SXv6t1Ra6gFo0+kLDglHGrEbYihTkcycrbHw7WCcuhBzPlJYHEsIpycaUwzsJHje+vUcxXUX4ztTA==} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -7137,6 +7149,12 @@ packages: eslint-import-resolver-node@0.3.9: resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + eslint-plugin-compat@7.0.2: + resolution: {integrity: sha512-gN8hF+4NzMsHUbr4m/TYZK0FtW3DcV4g8rXpTsY2EV5xiRD8jsilUlB9lNSkGGX0veDCxMhKSWbSd+faJByQDA==} + engines: {node: '>=22.x'} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + eslint-plugin-header@3.1.1: resolution: {integrity: sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg==} peerDependencies: @@ -7653,6 +7671,10 @@ packages: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} engines: {node: '>=10'} + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + globals@17.6.0: resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} engines: {node: '>=18'} @@ -15467,6 +15489,10 @@ snapshots: '@leichtgewicht/ip-codec@2.0.5': {} + '@mdn/browser-compat-data@5.7.6': {} + + '@mdn/browser-compat-data@6.1.5': {} + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.8 @@ -18566,6 +18592,10 @@ snapshots: assertion-error@2.0.1: {} + ast-metadata-inferer@0.8.1: + dependencies: + '@mdn/browser-compat-data': 5.7.6 + ast-types-flow@0.0.8: {} astral-regex@2.0.0: {} @@ -20149,6 +20179,17 @@ snapshots: - supports-color optional: true + eslint-plugin-compat@7.0.2(eslint@10.3.0(jiti@2.6.1)): + dependencies: + '@mdn/browser-compat-data': 6.1.5 + ast-metadata-inferer: 0.8.1 + browserslist: 4.28.2 + eslint: 10.3.0(jiti@2.6.1) + find-up: 5.0.0 + globals: 15.15.0 + lodash.memoize: 4.1.2 + semver: 7.7.4 + eslint-plugin-header@3.1.1(eslint@10.3.0(jiti@2.6.1)): dependencies: eslint: 10.3.0(jiti@2.6.1) @@ -20785,6 +20826,8 @@ snapshots: dependencies: ini: 2.0.0 + globals@15.15.0: {} + globals@17.6.0: {} globalthis@1.0.4: