Skip to content

Commit

Permalink
feat: support JSX expressions
Browse files Browse the repository at this point in the history
Fixes #346
  • Loading branch information
petyosi committed Feb 16, 2024
1 parent 749e559 commit 8e50015
Show file tree
Hide file tree
Showing 8 changed files with 278 additions and 3 deletions.
2 changes: 2 additions & 0 deletions docs/jsx.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ position: 0.815

The JSX plugin allows you to process and associate custom editors with the JSX components in your markdown source - a capability enabled by [MDX](https://mdxjs.com/). The package includes a generic editor component, but you can also create your custom editors. The next example includes three JSX descriptors and an example of a custom editor that uses the `NestedLexicalEditor` component to edit the markdown contents of a JSX component.

The JSX syntax also supports `{}` as a way to embed JavaScript expressions in your markdown. Out of the box, the plugin will enable a simple inline editor for the expressions, too.

```tsx
const jsxComponentDescriptors: JsxComponentDescriptor[] = [
{
Expand Down
12 changes: 12 additions & 0 deletions src/examples/jsx.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,15 @@ export const Html = () => {
</div>
)
}

export const JsxExpression = () => {
return (
<div>
<MDXEditor
onChange={(e) => console.log(e)}
markdown={`Hello {1+1} after the expression`}
plugins={[headingsPlugin(), jsxPlugin({ jsxComponentDescriptors: [] })]}
/>
</div>
)
}
191 changes: 191 additions & 0 deletions src/plugins/jsx/LexicalMdxTextExpressionNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import React from 'react'
import {
$applyNodeReplacement,
DOMConversionMap,
DOMExportOutput,
DecoratorNode,
LexicalEditor,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread
} from 'lexical'

import lexicalThemeStyles from '../../styles/lexical-theme.module.css'
import styles from '../../styles/ui.module.css'

/**
* A serialized representation of a {@link GenericHTMLNode}.
* @group HTML
*/
export type SerializedLexicalMdxTextExpressionNode = Spread<
{
type: 'mdx-text-expression'
value: string
version: 1
},
SerializedLexicalNode
>

/**
* A Lexical node that represents a generic HTML element. Use {@link $createGenericHTMLNode} to construct one.
* The generic HTML node is used as a "fallback" for HTML elements that are not explicitly supported by the editor.
* @group HTML
*/
export class LexicalMdxTextExpressionNode extends DecoratorNode<JSX.Element> {
/** @internal */
__value: string

/** @internal */
static getType(): string {
return 'mdx-text-expression'
}

/** @internal */
static clone(node: LexicalMdxTextExpressionNode): LexicalMdxTextExpressionNode {
return new LexicalMdxTextExpressionNode(node.__value, node.__key)
}

/**
* Constructs a new {@link GenericHTMLNode} with the specified MDAST HTML node as the object to edit.
*/
constructor(value: string, key?: NodeKey) {
super(key)
this.__value = value
}

getValue(): string {
return this.__value
}

// View

createDOM(): HTMLElement {
const element = document.createElement('span')
element.classList.add(lexicalThemeStyles.mdxTextExpression)
return element
}

updateDOM(): boolean {
return false
}

static importDOM(): DOMConversionMap | null {
// TODO: take the implementation of convertHeadingElement from headingsPlugin
return {}
}

exportDOM(editor: LexicalEditor): DOMExportOutput {
// TODO
const { element } = super.exportDOM(editor)

// this.getFormatType()
/*
if (element && isHTMLElement(element)) {
if (this.isEmpty()) element.append(document.createElement('br'))
const formatType = this.getFormatType()
element.style.textAlign = formatType
const direction = this.getDirection()
if (direction) {
element.dir = direction
}
}*/

return {
element
}
}

static importJSON(serializedNode: SerializedLexicalMdxTextExpressionNode): LexicalMdxTextExpressionNode {
return $createLexicalMdxTextExpressionNode(serializedNode.value)
}

exportJSON(): SerializedLexicalMdxTextExpressionNode {
return {
...super.exportJSON(),
value: this.getValue(),
type: 'mdx-text-expression',
version: 1
}
}

/*
// Mutation
insertNewAfter(selection?: RangeSelection, restoreSelection = true): ParagraphNode | GenericHTMLNode {
const anchorOffet = selection ? selection.anchor.offset : 0
const newElement =
anchorOffet > 0 && anchorOffet < this.getTextContentSize() ? $createHeadingNode(this.getTag()) : $createParagraphNode()
const direction = this.getDirection()
newElement.setDirection(direction)
this.insertAfter(newElement, restoreSelection)
return newElement
}
collapseAtStart(): true {
const newElement = !this.isEmpty() ? $createHeadingNode(this.getTag()) : $createParagraphNode()
const children = this.getChildren()
children.forEach((child) => newElement.append(child))
this.replace(newElement)
return true
}*/

extractWithChild(): boolean {
return true
}

isInline(): boolean {
return true
}

decorate(editor: LexicalEditor) {
return (
<>
{'{'}
<span className={styles.inputSizer} data-value={this.getValue()}>
<input
size={1}
onKeyDown={(e) => {
const value = (e.target as HTMLInputElement).value
if ((value === '' && e.key === 'Backspace') || e.key === 'Delete') {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
e.preventDefault()
editor.update(() => {
this.selectPrevious()
this.remove()
})
}
}}
onChange={(e) => {
e.target.parentElement!.dataset.value = e.target.value
editor.update(() => {
this.getWritable().__value = e.target.value
})
}}
type="text"
value={this.getValue()}
/>
</span>
{'}'}
</>
)
}
}

/**
* Creates a new {@link GenericHTMLNode} with the specified MDAST HTML node as the object to edit.
* @group HTML
*/
export function $createLexicalMdxTextExpressionNode(value: string): LexicalMdxTextExpressionNode {
return $applyNodeReplacement(new LexicalMdxTextExpressionNode(value))
}

/**
* Determines if the specified node is a {@link GenericHTMLNode}.
* @group HTML
*/
export function $isLexicalMdxTextExpressionNode(node: LexicalNode | null | undefined): node is LexicalMdxTextExpressionNode {
return node instanceof LexicalMdxTextExpressionNode
}
14 changes: 14 additions & 0 deletions src/plugins/jsx/LexicalMdxTextExpressionVisitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { MdxTextExpression } from 'mdast-util-mdx'
import { LexicalExportVisitor } from '../../exportMarkdownFromLexical'
import { $isLexicalMdxTextExpressionNode, LexicalMdxTextExpressionNode } from './LexicalMdxTextExpressionNode'

export const LexicalMdxTextExpressionVisitor: LexicalExportVisitor<LexicalMdxTextExpressionNode, MdxTextExpression> = {
testLexicalNode: $isLexicalMdxTextExpressionNode,
visitLexicalNode({ actions, mdastParent, lexicalNode }) {
const mdastNode: MdxTextExpression = {
type: 'mdxTextExpression',
value: lexicalNode.getValue()
}
actions.appendToParent(mdastParent, mdastNode)
}
}
12 changes: 12 additions & 0 deletions src/plugins/jsx/MdastMdxTextExpressionVisitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ElementNode } from 'lexical'
import { MdxTextExpression } from 'mdast-util-mdx'
import { MdastImportVisitor } from '../../importMarkdownToLexical'
import { $createLexicalMdxTextExpressionNode } from './LexicalMdxTextExpressionNode'

export const MdastMdxTextExpressionVisitor: MdastImportVisitor<MdxTextExpression> = {
testNode: 'mdxTextExpression',
visitNode({ lexicalParent, mdastNode }) {
;(lexicalParent as ElementNode).append($createLexicalMdxTextExpressionNode(mdastNode.value))
},
priority: -200
}
9 changes: 6 additions & 3 deletions src/plugins/jsx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import { MdastMdxJsxElementVisitor } from './MdastMdxJsxElementVisitor'
import * as Mdast from 'mdast'
import { Signal, map } from '@mdxeditor/gurx'
import { realmPlugin } from '../../RealmWithPlugins'
import { MdastMdxTextExpressionVisitor } from './MdastMdxTextExpressionVisitor'
import { LexicalMdxTextExpressionNode } from './LexicalMdxTextExpressionNode'
import { LexicalMdxTextExpressionVisitor } from './LexicalMdxTextExpressionVisitor'

/**
* An MDX JSX MDAST node.
Expand Down Expand Up @@ -173,11 +176,11 @@ export const jsxPlugin = realmPlugin<{
[jsxIsAvailable$]: true,
[addMdastExtension$]: mdxFromMarkdown(),
[addSyntaxExtension$]: mdxjs(),
[addImportVisitor$]: [MdastMdxJsxElementVisitor, MdastMdxJsEsmVisitor],
[addImportVisitor$]: [MdastMdxJsxElementVisitor, MdastMdxJsEsmVisitor, MdastMdxTextExpressionVisitor],

// export
[addLexicalNode$]: LexicalJsxNode,
[addExportVisitor$]: LexicalJsxVisitor,
[addLexicalNode$]: [LexicalJsxNode, LexicalMdxTextExpressionNode],
[addExportVisitor$]: [LexicalJsxVisitor, LexicalMdxTextExpressionVisitor],
[addToMarkdownExtension$]: mdxToMarkdown(),
[jsxComponentDescriptors$]: params?.jsxComponentDescriptors || []
})
Expand Down
10 changes: 10 additions & 0 deletions src/styles/lexical-theme.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,13 @@
--admonitionBorder: var(--admonitionNoteBorder);
--admonitionBg: var(--admonitionNoteBg);
}

.mdxTextExpression {
font-family: var(--font-mono);
font-size: 84%;
color: var(--accentText);

& input:focus-visible {
outline: none;
}
}
31 changes: 31 additions & 0 deletions src/styles/ui.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -1171,3 +1171,34 @@ form.multiFieldForm {
position: relative;
z-index: 1;
}

.inputSizer {
display: inline-grid;
vertical-align: baseline;
align-items: center;
position: relative;

&::after,
& input {
width: auto;
min-width: 1rem;
grid-area: 1 / 2;
font: inherit;
margin: 0;
padding: 0 2px;
resize: none;
background: none;
appearance: none;
border: none;
color: inherit;
}

span {
padding: 0.25em;
}

&::after {
content: attr(data-value);
white-space: pre-wrap;
}
}

0 comments on commit 8e50015

Please sign in to comment.