Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions src/examples/bug-733.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React from 'react'
import { MDXEditor, type MDXEditorMethods } from '../MDXEditor'
import { AdmonitionDirectiveDescriptor } from '../directive-editors/AdmonitionDirectiveDescriptor'
import { directivesPlugin } from '../plugins/directives'
import { GenericJsxEditor } from '../jsx-editors/GenericJsxEditor'
import { insertJsx$, jsxPlugin } from '../plugins/jsx'
import { tablePlugin } from '../plugins/table'
import { toolbarPlugin } from '../plugins/toolbar'
import { Button } from '../plugins/toolbar/primitives/toolbar'
import { usePublisher } from '@mdxeditor/gurx'

const initialMarkdown = `Root content

:::tip
import Existing from '@existing'

Admonition content <Existing />
:::

| Header |
| ------ |
| Table content |
`

function InsertComponents() {
const insertJsx = usePublisher(insertJsx$)
return (
<>
<Button
onClick={() => {
insertJsx({ name: 'Zazz', kind: 'flow', props: {} })
}}
>
Insert Zazz
</Button>
<Button
onClick={() => {
insertJsx({ name: 'Badge', kind: 'text', props: {} })
}}
>
Insert Badge
</Button>
</>
)
}

export const NestedJsxImports = () => {
const ref = React.useRef<MDXEditorMethods>(null)
const [markdown, setMarkdown] = React.useState(initialMarkdown)
const [exported, setExported] = React.useState('')
const [revision, setRevision] = React.useState(0)
return (
<>
<MDXEditor
key={revision}
ref={ref}
markdown={markdown}
plugins={[
directivesPlugin({ directiveDescriptors: [AdmonitionDirectiveDescriptor] }),
tablePlugin(),
jsxPlugin({
jsxComponentDescriptors: [
{ name: 'Zazz', kind: 'flow', source: '@zazz', defaultExport: true, props: [], hasChildren: false, Editor: GenericJsxEditor },
{ name: 'Badge', kind: 'text', source: '@components', props: [], hasChildren: false, Editor: GenericJsxEditor },
{ name: '*', kind: 'text', props: [], hasChildren: false, Editor: GenericJsxEditor }
]
}),
toolbarPlugin({ toolbarContents: InsertComponents })
]}
/>
<button
type="button"
onClick={() => {
setExported(ref.current!.getMarkdown())
}}
>
Get Markdown
</button>
<button
type="button"
onClick={() => {
const saved = ref.current!.getMarkdown()
setMarkdown(saved)
setExported(saved)
setRevision((value) => value + 1)
}}
>
Reload saved Markdown
</button>
<pre aria-label="Exported markdown">{exported}</pre>
</>
)
}
23 changes: 21 additions & 2 deletions src/exportMarkdownFromLexical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,27 @@ export function exportLexicalTreeToMdast({
throw new Error('traversal ended with no root element')
}

const typedRoot = unistRoot as Mdast.Root

// Decorators such as directives and tables contain MDAST subtrees rather than
// Lexical children, so their JSX references never reach the JSX visitor.
if (addImportStatements) {
const registerNestedComponents = (node: Mdast.Nodes) => {
if ((node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') && node.name !== null && !isMdastHTMLNode(node)) {
const descriptor =
jsxComponentDescriptors.find((descriptor) => descriptor.name === node.name) ??
jsxComponentDescriptors.find((descriptor) => descriptor.name === '*')
if (descriptor?.source) {
registerReferredComponent(node.name)
Comment on lines +220 to +221
}
}
if ('children' in node) {
node.children.forEach(registerNestedComponents)
}
}
registerNestedComponents(typedRoot)
}

// iterate over all referred components and construct import statements, then append them to the root
const importsMap = new Map<string, string[]>()
const defaultImportsMap = new Map<string, string>()
Expand Down Expand Up @@ -271,8 +292,6 @@ export function exportLexicalTreeToMdast({
})
)

const typedRoot = unistRoot as Mdast.Root

const frontmatter = typedRoot.children.find((child) => child.type === 'yaml')
if (frontmatter) {
typedRoot.children.splice(typedRoot.children.indexOf(frontmatter) + 1, 0, ...imports)
Expand Down
3 changes: 2 additions & 1 deletion src/plugins/table/TableEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,8 @@ const CellEditor: React.FC<CellProps> = ({ focus, setActiveCell, parentEditor, l
root: $getRoot(),
jsxComponentDescriptors,
visitors: exportVisitors,
jsxIsAvailable
jsxIsAvailable,
addImportStatements: false
})
parentEditor.update(
() => {
Expand Down
177 changes: 177 additions & 0 deletions src/test/nested-jsx-imports-integration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import React from 'react'
import { act, fireEvent, render, waitFor } from '@testing-library/react'
import { expect, it } from 'vitest'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $getRoot, $isElementNode, type LexicalEditor } from 'lexical'
import { addNestedEditorChild$, addTableCellEditorChild$ } from '../plugins/core'
import { AdmonitionDirectiveDescriptor } from '../directive-editors/AdmonitionDirectiveDescriptor'
import { directivesPlugin } from '../plugins/directives'
import { tablePlugin } from '../plugins/table'
import { GenericJsxEditor } from '../jsx-editors/GenericJsxEditor'
import { jsxPlugin } from '../plugins/jsx'
import { MDXEditor, type MDXEditorMethods } from '../MDXEditor'
import { realmPlugin } from '../RealmWithPlugins'
import { $createLexicalJsxNode } from '../plugins/jsx/LexicalJsxNode'

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true

it('imports a JSX component inserted into an admonition at the document root', async () => {
let nestedEditor: LexicalEditor | null = null
function CaptureNestedEditor() {
const [editor] = useLexicalComposerContext()
nestedEditor ??= editor
return null
}
const captureNestedEditorPlugin = realmPlugin({
init(realm) {
realm.pub(addNestedEditorChild$, CaptureNestedEditor)
}
})
const ref = React.createRef<MDXEditorMethods>()
const { container } = render(
<MDXEditor
ref={ref}
markdown={':::tip\nInside the admonition\n:::'}
plugins={[
captureNestedEditorPlugin(),
directivesPlugin({ directiveDescriptors: [AdmonitionDirectiveDescriptor] }),
jsxPlugin({
jsxComponentDescriptors: [
{
name: 'Zazz',
kind: 'flow',
source: '@zazz',
defaultExport: true,
props: [],
hasChildren: false,
Editor: GenericJsxEditor
}
]
})
]}
/>
)
await waitFor(() => {
expect(nestedEditor).not.toBeNull()
expect(container.querySelectorAll('[contenteditable="true"]')).toHaveLength(2)
})
act(() => {
nestedEditor!.update(
() => {
$getRoot().append($createLexicalJsxNode({ type: 'mdxJsxFlowElement', name: 'Zazz', attributes: [], children: [] }))
},
{ discrete: true }
)
})
fireEvent.blur(container.querySelectorAll('[contenteditable="true"]')[1])
await waitFor(() => {
expect(ref.current?.getMarkdown()).toContain('<Zazz />')
})
expect(ref.current?.getMarkdown()).toMatch(/^import Zazz from '@zazz'/)
})

it('preserves existing nested imports when a new component is inserted and the document is reloaded', async () => {
let nestedEditor: LexicalEditor | null = null
function CaptureNestedEditor() {
const [editor] = useLexicalComposerContext()
nestedEditor ??= editor
return null
}
const capture = realmPlugin({
init: (realm) => {
realm.pub(addNestedEditorChild$, CaptureNestedEditor)
}
})
const ref = React.createRef<MDXEditorMethods>()
const { container } = render(
<MDXEditor
ref={ref}
markdown={":::tip\nimport Existing from '@existing'\n\n<Existing />\n:::"}
plugins={[
capture(),
directivesPlugin({ directiveDescriptors: [AdmonitionDirectiveDescriptor] }),
jsxPlugin({
jsxComponentDescriptors: [
{ name: 'Zazz', kind: 'flow', source: '@zazz', defaultExport: true, props: [], hasChildren: false, Editor: GenericJsxEditor },
{ name: '*', kind: 'flow', props: [], hasChildren: false, Editor: GenericJsxEditor }
]
})
]}
/>
)
await waitFor(() => {
expect(nestedEditor).not.toBeNull()
})
act(() => {
nestedEditor!.update(
() => {
$getRoot().append($createLexicalJsxNode({ type: 'mdxJsxFlowElement', name: 'Zazz', attributes: [], children: [] }))
},
{ discrete: true }
)
})
fireEvent.blur(container.querySelectorAll('[contenteditable="true"]')[1])
await waitFor(() => {
expect(ref.current?.getMarkdown()).toContain('<Zazz />')
})
const markdown = ref.current!.getMarkdown()
expect(markdown).toMatch(/^import Zazz from '@zazz'/)
expect(markdown).toContain(":::tip\nimport Existing from '@existing'")
expect(markdown.match(/import Existing/g)).toHaveLength(1)
act(() => {
ref.current!.setMarkdown(markdown)
})
await waitFor(() => {
expect(ref.current!.getMarkdown()).toBe(markdown)
})
})

it('exports a component inserted into a table cell without losing the cell content', async () => {
const tableEditors: LexicalEditor[] = []
function CaptureTableEditor() {
const [editor] = useLexicalComposerContext()
if (!tableEditors.includes(editor)) tableEditors.push(editor)
return null
}
const capture = realmPlugin({
init: (realm) => {
realm.pub(addTableCellEditorChild$, CaptureTableEditor)
}
})
const ref = React.createRef<MDXEditorMethods>()
const { container } = render(
<MDXEditor
ref={ref}
markdown={'| Header |\n| --- |\n| Cell |'}
plugins={[
capture(),
tablePlugin(),
jsxPlugin({
jsxComponentDescriptors: [
{ name: 'Badge', kind: 'text', source: '@components', props: [], hasChildren: false, Editor: GenericJsxEditor }
]
})
]}
/>
)
await waitFor(() => {
expect(tableEditors).toHaveLength(2)
})
act(() => {
tableEditors[1].update(
() => {
const paragraph = $getRoot().getFirstChildOrThrow()
if (!$isElementNode(paragraph)) throw new Error('Expected a table-cell paragraph')
paragraph.append($createLexicalJsxNode({ type: 'mdxJsxTextElement', name: 'Badge', attributes: [], children: [] }))
},
{ discrete: true }
)
})
fireEvent.blur(container.querySelector('td [contenteditable="true"]')!)
await waitFor(() => {
expect(ref.current?.getMarkdown()).toContain('<Badge />')
})
expect(ref.current?.getMarkdown()).toMatch(/^import \{ Badge \} from '@components'/)
expect(ref.current?.getMarkdown()).toContain('Cell<Badge />')
})
Loading