From f5ae852608efc4c1ad9b017e8e85957389858f8d Mon Sep 17 00:00:00 2001 From: Bedru Umer <63902795+bedre7@users.noreply.github.com> Date: Tue, 5 Nov 2024 20:34:25 +0300 Subject: [PATCH 1/2] [lexical-react] Bug Fix: LexicalTypeaheadMenuPlugin SSR error: ReferenceError: document is not defined (#6794) Co-authored-by: Bob Ippolito --- .../src/LexicalContextMenuPlugin.tsx | 4 +- .../src/LexicalNodeMenuPlugin.tsx | 4 +- .../src/LexicalTypeaheadMenuPlugin.tsx | 4 +- .../__tests__/unit/useMenuAnchorRef.test.tsx | 64 +++++++++++++++++++ .../lexical-react/src/shared/LexicalMenu.ts | 14 ++-- 5 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 packages/lexical-react/src/__tests__/unit/useMenuAnchorRef.test.tsx diff --git a/packages/lexical-react/src/LexicalContextMenuPlugin.tsx b/packages/lexical-react/src/LexicalContextMenuPlugin.tsx index cc0dfcf37b0..c105d48b8f1 100644 --- a/packages/lexical-react/src/LexicalContextMenuPlugin.tsx +++ b/packages/lexical-react/src/LexicalContextMenuPlugin.tsx @@ -144,7 +144,9 @@ export function LexicalContextMenuPlugin({ return () => document.removeEventListener('click', handleClick); }, [editor, handleClick]); - return resolution === null || editor === null ? null : ( + return anchorElementRef.current === null || + resolution === null || + editor === null ? null : ( ({ } }, [editor, positionOrCloseMenu, nodeKey]); - return resolution === null || editor === null ? null : ( + return anchorElementRef.current === null || + resolution === null || + editor === null ? null : ( ({ openTypeahead, ]); - return resolution === null || editor === null ? null : ( + return resolution === null || + editor === null || + anchorElementRef.current === null ? null : ( ({ + useLexicalComposerContext: () => [createTestEditor()], +})); + +jest.mock('shared/canUseDOM', () => ({ + CAN_USE_DOM: false, +})); + +describe('useMenuAnchorRef', () => { + let container: HTMLDivElement | null = null; + let reactRoot: Root; + + beforeEach(() => { + container = document.createElement('div'); + reactRoot = createRoot(container); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return null if CAN_USE_DOM is false', async () => { + let anchorElementRef; + + function App() { + const resolution = null; + const setResolution = jest.fn(); + const anchorClassName = 'some-class'; + const parent = undefined; + const shouldIncludePageYOffset__EXPERIMENTAL = true; + + anchorElementRef = useMenuAnchorRef( + resolution, + setResolution, + anchorClassName, + parent, + shouldIncludePageYOffset__EXPERIMENTAL, + ); + + return null; + } + + await ReactTestUtils.act(async () => { + reactRoot.render(); + }); + + expect(anchorElementRef!.current).toBeNull(); + }); +}); diff --git a/packages/lexical-react/src/shared/LexicalMenu.ts b/packages/lexical-react/src/shared/LexicalMenu.ts index 42877233258..cda6ca3c5cb 100644 --- a/packages/lexical-react/src/shared/LexicalMenu.ts +++ b/packages/lexical-react/src/shared/LexicalMenu.ts @@ -32,6 +32,7 @@ import { useRef, useState, } from 'react'; +import {CAN_USE_DOM} from 'shared/canUseDOM'; import useLayoutEffect from 'shared/useLayoutEffect'; export type MenuTextMatch = { @@ -267,7 +268,7 @@ export function LexicalMenu({ }: { close: () => void; editor: LexicalEditor; - anchorElementRef: MutableRefObject; + anchorElementRef: MutableRefObject; resolution: MenuResolution; options: Array; shouldSplitNodeWithQuery?: boolean; @@ -481,12 +482,17 @@ export function useMenuAnchorRef( resolution: MenuResolution | null, setResolution: (r: MenuResolution | null) => void, className?: string, - parent: HTMLElement = document.body, + parent: HTMLElement | undefined = CAN_USE_DOM ? document.body : undefined, shouldIncludePageYOffset__EXPERIMENTAL: boolean = true, -): MutableRefObject { +): MutableRefObject { const [editor] = useLexicalComposerContext(); - const anchorElementRef = useRef(document.createElement('div')); + const anchorElementRef = useRef( + CAN_USE_DOM ? document.createElement('div') : null, + ); const positionMenu = useCallback(() => { + if (anchorElementRef.current === null || parent === undefined) { + return; + } anchorElementRef.current.style.top = anchorElementRef.current.style.bottom; const rootElement = editor.getRootElement(); const containerDiv = anchorElementRef.current; From 2c1a8f10c84444560c0982784ce3c14242f82340 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Tue, 5 Nov 2024 10:35:12 -0700 Subject: [PATCH 2/2] [lexical-markdown] Feature: add ability to control finding the end of a node matched by TextMatchTransformer (#6681) --- .../lexical-markdown/src/MarkdownImport.ts | 9 +++- .../src/MarkdownTransformers.ts | 9 ++++ .../__tests__/unit/LexicalMarkdown.test.ts | 53 ++++++++++++++++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownImport.ts b/packages/lexical-markdown/src/MarkdownImport.ts index 2f7dc27324b..47f56dd3b7e 100644 --- a/packages/lexical-markdown/src/MarkdownImport.ts +++ b/packages/lexical-markdown/src/MarkdownImport.ts @@ -383,7 +383,14 @@ function importTextMatchTransformers( } const startIndex = match.index || 0; - const endIndex = startIndex + match[0].length; + const endIndex = transformer.getEndIndex + ? transformer.getEndIndex(textNode, match) + : startIndex + match[0].length; + + if (endIndex === false) { + continue; + } + let replaceNode, newTextNode; if (startIndex === 0) { diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts index 2a335156213..2a4fa5d5cf0 100644 --- a/packages/lexical-markdown/src/MarkdownTransformers.ts +++ b/packages/lexical-markdown/src/MarkdownTransformers.ts @@ -174,6 +174,15 @@ export type TextMatchTransformer = Readonly<{ * Determines how the matched markdown text should be transformed into a node during the markdown import process */ replace?: (node: TextNode, match: RegExpMatchArray) => void; + /** + * For import operations, this function can be used to determine the end index of the match, after `importRegExp` has matched. + * Without this function, the end index will be determined by the length of the match from `importRegExp`. Manually determining the end index can be useful if + * the match from `importRegExp` is not the entire text content of the node. That way, `importRegExp` can be used to match only the start of the node, and `getEndIndex` + * can be used to match the end of the node. + * + * @returns The end index of the match, or false if the match was unsuccessful and a different transformer should be tried. + */ + getEndIndex?: (node: TextNode, match: RegExpMatchArray) => number | false; /** * Single character that allows the transformer to trigger when typed in the editor. This does not affect markdown imports outside of the markdown shortcut plugin. * If the trigger is matched, the `regExp` will be used to match the text in the second step. diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index be7199eefab..f78fc4a3056 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -9,7 +9,7 @@ import {$createCodeNode, CodeNode} from '@lexical/code'; import {createHeadlessEditor} from '@lexical/headless'; import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; -import {LinkNode} from '@lexical/link'; +import {$createLinkNode, LinkNode} from '@lexical/link'; import {ListItemNode, ListNode} from '@lexical/list'; import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import {$createTextNode, $getRoot, $insertNodes} from 'lexical'; @@ -28,6 +28,51 @@ import { normalizeMarkdown, } from '../../MarkdownTransformers'; +const SIMPLE_INLINE_JSX_MATCHER: TextMatchTransformer = { + dependencies: [LinkNode], + getEndIndex(node, match) { + // Find the closing tag. Count the number of opening and closing tags to find the correct closing tag. + // For simplicity, this will only count the opening and closing tags without checking for "MyTag" specifically. + let openedSubStartMatches = 0; + const start = (match.index ?? 0) + match[0].length; + let endIndex = start; + const line = node.getTextContent(); + + for (let i = start; i < line.length; i++) { + const char = line[i]; + if (char === '<') { + const nextChar = line[i + 1]; + if (nextChar === '/') { + if (openedSubStartMatches === 0) { + endIndex = i + ''.length; + break; + } + openedSubStartMatches--; + } else { + openedSubStartMatches++; + } + } + } + return endIndex; + }, + importRegExp: /<(MyTag)\s*>/, + regExp: /__ignore__/, + replace: (textNode, match) => { + const linkNode = $createLinkNode('simple-jsx'); + + const textStart = match[0].length + (match.index ?? 0); + const textEnd = + (match.index ?? 0) + textNode.getTextContent().length - ''.length; + const text = match.input?.slice(textStart, textEnd); + + const linkTextNode = $createTextNode(text); + linkTextNode.setFormat(textNode.getFormat()); + linkNode.append(linkTextNode); + textNode.replace(linkNode); + }, + type: 'text-match', +}; + // Matches html within a mdx file const MDX_HTML_TRANSFORMER: MultilineElementTransformer = { dependencies: [CodeNode], @@ -461,6 +506,12 @@ describe('Markdown', () => { md: '```ts\nCode\n```ts\nSub Code\n```\n```', skipExport: true, }, + { + customTransformers: [SIMPLE_INLINE_JSX_MATCHER], + html: '

Hello One <MyTag>Two</MyTag> there

', + md: 'Hello One Two there', + skipExport: true, + }, ]; const HIGHLIGHT_TEXT_MATCH_IMPORT: TextMatchTransformer = {