Skip to content

Commit

Permalink
feat: support expression properties in JSX elements (#373)
Browse files Browse the repository at this point in the history
Co-authored-by: Stanislav Kubik <[email protected]>
  • Loading branch information
StanlieK and Stanislav Kubik authored Mar 6, 2024
1 parent 9867c6c commit 63e4dbc
Show file tree
Hide file tree
Showing 6 changed files with 277 additions and 63 deletions.
53 changes: 50 additions & 3 deletions docs/jsx.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ const jsxComponentDescriptors: JsxComponentDescriptor[] = [
// Used to construct the property popover of the generic editor
props: [
{ name: 'foo', type: 'string' },
{ name: 'bar', type: 'string' }
{ name: 'bar', type: 'string' },
{ name: 'onClick', type: 'expression' }
],
// whether the component has children or not
hasChildren: true,
Expand Down Expand Up @@ -65,7 +66,7 @@ const InsertMyLeaf = () => {
insertJsx({
name: 'MyLeaf',
kind: 'text',
props: { foo: 'bar', bar: 'baz' }
props: { foo: 'bar', bar: 'baz', onClick: { type: 'expression', value: '() => console.log("Clicked")' } }
})
}
>
Expand Down Expand Up @@ -97,11 +98,57 @@ export const Example = () => {
```md
import { MyLeaf, BlockNode } from './external';

A paragraph with inline jsx component <MyLeaf foo="fooValue">Nested _markdown_</MyLeaf> more <Marker type="warning" />.
A paragraph with inline jsx component <MyLeaf foo="bar" bar="baz" onClick={() => console.log("Clicked")}>Nested _markdown_</MyLeaf> more <Marker type="warning" />.

<BlockNode foo="fooValue">
Content *foo*

more Content
</BlockNode>
```

## Types of properties

There are two types of properties - "textual" and "expressions" in JSX. You can define type in `JsxComponentDescriptor`. `jsxPlugin` will treat the value based on this setting. For example, this code:

```tsx
const jsxComponentDescriptors: JsxComponentDescriptor[] = [
{
name: 'MyLeaf',
kind: 'text',
props: [
{ name: 'foo', type: 'string' } // Textual property type
],
hasChildren: true,
Editor: GenericJsxEditor
}
]
```

will produce component like the following:

```tsx
<MyLeaf foo="bar">Some text...</MyLeaf>
```

While this descriptor:

```tsx
const jsxComponentDescriptors: JsxComponentDescriptor[] = [
{
name: 'MyLeaf',
kind: 'text',
props: [
{ name: 'foo', type: 'expression' } // Expression property type
],
hasChildren: true,
Editor: GenericJsxEditor
}
]
```

will produce:

```tsx
<MyLeaf foo={bar}>Some text...</MyLeaf>
```
4 changes: 2 additions & 2 deletions src/examples/basics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const jsxComponentDescriptors: JsxComponentDescriptor[] = [
source: './external',
props: [
{ name: 'foo', type: 'string' },
{ name: 'bar', type: 'string' }
{ name: 'bar', type: 'expression' }
],
hasChildren: true,
Editor: GenericJsxEditor
Expand Down Expand Up @@ -105,7 +105,7 @@ export function Headings() {
return <MDXEditor markdown="# hello world" plugins={[headingsPlugin()]} />
}

const breakMarkdown = `hello
const breakMarkdown = `hello
----------------
Expand Down
68 changes: 68 additions & 0 deletions src/examples/jsx.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,71 @@ export const JsxExpression = () => {
</div>
)
}

export const JsxFragment = () => {
return (
<div>
<MDXEditor
onChange={(e) => console.log(e)}
markdown={`# Fragment
<></>
# Nested fragment
<BlockNode><><BlockNode /><BlockNode /></></BlockNode>`}
plugins={[headingsPlugin(), jsxPlugin({ jsxComponentDescriptors })]}
/>
</div>
)
}

const componentWithExpressionAttribute: JsxComponentDescriptor[] = [
{
name: 'BlockNode',
kind: 'flow',
source: './external',
props: [
{
name: 'onClick',
type: 'expression'
}
],
Editor: GenericJsxEditor,
hasChildren: true
}
]

const InsertBlockNodeWithExpressionAttribute = () => {
const insertJsx = usePublisher(insertJsx$)
return (
<Button
onClick={() =>
insertJsx({
name: 'BlockNode',
kind: 'flow',
props: { onClick: { type: 'expression', value: '() => console.log' } },
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Hello, World!' }] }]
})
}
>
BlockNode
</Button>
)
}

export const ExpressionAttributes = () => {
return (
<div>
<MDXEditor
onChange={(e) => console.log(e)}
markdown={`<BlockNode>
Hello, World!
</BlockNode>`}
plugins={[
headingsPlugin(),
jsxPlugin({ jsxComponentDescriptors: componentWithExpressionAttribute }),
toolbarPlugin({ toolbarContents: InsertBlockNodeWithExpressionAttribute })
]}
/>
</div>
)
}
117 changes: 81 additions & 36 deletions src/jsx-editors/GenericJsxEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { PhrasingContent } from 'mdast'
import { MdxJsxAttribute, MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx'
import {
MdxJsxAttribute,
MdxJsxAttributeValueExpression,
MdxJsxExpressionAttribute,
MdxJsxFlowElement,
MdxJsxTextElement
} from 'mdast-util-mdx-jsx'
import React from 'react'
import { NestedLexicalEditor, useMdastNodeUpdater } from '../plugins/core/NestedLexicalEditor'
import { PropertyPopover } from '../plugins/core/PropertyPopover'
import styles from '../styles/ui.module.css'
import { JsxEditorProps } from '../plugins/jsx'

const isExpressionValue = (value: string | MdxJsxAttributeValueExpression | null | undefined): value is MdxJsxAttributeValueExpression => {
if (
typeof value === 'object' &&
value !== null &&
'type' in value &&
value.type === 'mdxJsxAttributeValueExpression' &&
'value' in value &&
typeof value.value === 'string'
) {
return true
}

return false
}

const isStringValue = (value: string | MdxJsxAttributeValueExpression | null | undefined): value is string => typeof value === 'string'

const isMdxJsxAttribute = (value: MdxJsxAttribute | MdxJsxExpressionAttribute): value is MdxJsxAttribute => {
if (value.type === 'mdxJsxAttribute' && typeof value.name === 'string') {
return true
}

return false
}

/**
* A generic editor that can be used as an universal UI for any JSX element.
* Allows editing of the element content and properties.
Expand All @@ -16,55 +47,69 @@ import { JsxEditorProps } from '../plugins/jsx'
export const GenericJsxEditor: React.FC<JsxEditorProps> = ({ mdastNode, descriptor }) => {
const updateMdastNode = useMdastNodeUpdater()

const properties = React.useMemo(() => {
return descriptor.props.reduce(
(acc, descriptor) => {
const attribute = mdastNode.attributes.find((attr) => (attr as MdxJsxAttribute).name === descriptor.name)
const properties = React.useMemo(
() =>
descriptor.props.reduce<Record<string, string>>((acc, { name }) => {
const attribute = mdastNode.attributes.find((attr) => (isMdxJsxAttribute(attr) ? attr.name === name : false))

if (attribute) {
acc[descriptor.name] = attribute.value as string
} else {
acc[descriptor.name] = ''
if (isExpressionValue(attribute.value)) {
acc[name] = attribute.value.value
return acc
}

if (isStringValue(attribute.value)) {
acc[name] = attribute.value
return acc
}
}

acc[name] = ''
return acc
},
{} as Record<string, string>
)
}, [mdastNode, descriptor])
}, {}),
[mdastNode, descriptor]
)

const onChange = React.useCallback(
(values: Record<string, string>) => {
const newAttributes = mdastNode.attributes.slice()

Object.entries(values).forEach(([key, value]) => {
const attributeToUpdate = newAttributes.find((attr) => (attr as MdxJsxAttribute).name === key)
const updatedAttributes = Object.entries(values).reduce<typeof mdastNode.attributes>((acc, [name, value]) => {
if (value === '') {
if (attributeToUpdate) {
newAttributes.splice(newAttributes.indexOf(attributeToUpdate), 1)
}
} else {
if (attributeToUpdate) {
attributeToUpdate.value = value
} else {
newAttributes.push({
type: 'mdxJsxAttribute',
name: key,
value: value
})
}
return acc
}
})
updateMdastNode({ attributes: newAttributes })

const property = descriptor.props.find((prop) => prop.name === name)

if (property?.type === 'expression') {
acc.push({
type: 'mdxJsxAttribute',
name,
value: { type: 'mdxJsxAttributeValueExpression', value }
})
return acc
}

acc.push({
type: 'mdxJsxAttribute',
name,
value
})

return acc
}, [])

updateMdastNode({ attributes: updatedAttributes })
},
[mdastNode, updateMdastNode]
[mdastNode, updateMdastNode, descriptor]
)

const shouldRenderComponentName = descriptor.props.length == 0 && descriptor.hasChildren && descriptor.kind === 'flow'

return (
<div className={descriptor.kind === 'text' ? styles.inlineEditor : styles.blockEditor}>
{descriptor.props.length == 0 && descriptor.hasChildren && descriptor.kind === 'flow' ? (
<span className={styles.genericComponentName}>{mdastNode.name}</span>
) : null}
{shouldRenderComponentName ? <span className={styles.genericComponentName}>{mdastNode.name ?? 'Fragment'}</span> : null}

{descriptor.props.length > 0 ? <PropertyPopover properties={properties} title={mdastNode.name ?? ''} onChange={onChange} /> : null}

{descriptor.props.length > 0 ? <PropertyPopover properties={properties} title={mdastNode.name || ''} onChange={onChange} /> : null}
{descriptor.hasChildren ? (
<NestedLexicalEditor<MdxJsxTextElement | MdxJsxFlowElement>
block={descriptor.kind === 'flow'}
Expand Down
11 changes: 8 additions & 3 deletions src/plugins/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,16 @@ export const toMarkdownExtensions$ = Cell<NonNullable<LexicalConvertOptions['toM
/** @internal */
export const toMarkdownOptions$ = Cell<NonNullable<LexicalConvertOptions['toMarkdownOptions']>>({})

// the JSX plugin will fill in these
/** @internal */
/**
* This JSX plugin will fill this value.
* @group JSX
*/
export const jsxIsAvailable$ = Cell(false)

/** @internal */
/**
* Contains the currently registered JSX component descriptors.
* @group JSX
*/
export const jsxComponentDescriptors$ = Cell<JsxComponentDescriptor[]>([])

/**
Expand Down
Loading

0 comments on commit 63e4dbc

Please sign in to comment.