Skip to content

Commit

Permalink
feat: html tag support
Browse files Browse the repository at this point in the history
Fixes #92
  • Loading branch information
petyosi committed Nov 23, 2023
1 parent 52ac68b commit 51a8612
Show file tree
Hide file tree
Showing 14 changed files with 693 additions and 2 deletions.
2 changes: 1 addition & 1 deletion docs/admonitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ position: 0.815

Admonitions (also known as callouts or tips) are a common way to highlight some text in a markdown document. [Docusaurus uses them extensively](https://docusaurus.io/docs/markdown-features/admonitions) in its documentation, and provides a pre-made styling (icons, colors, etc).

The admonitions are, in fact, just [conventional container directives](./directives). The MDXEditor package ships a pre-made directive `AdmonitionDirectiveDescriptor` that enables the usage of admonitions in your markdown document.
The admonitions are, in fact, just [conventional container directives](./custom-directive-editors). The MDXEditor package ships a pre-made directive `AdmonitionDirectiveDescriptor` that enables the usage of admonitions in your markdown document.

```tsx
const admonitionMarkdown = `
Expand Down
59 changes: 59 additions & 0 deletions docs/html.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
title: HTML Support
slug: html-support
position: 98
---

# HTML Support

Markdown documents can occasionally include additional HTML elements. Out of the box, MDXEditor converts those into
generic HTML nodes, which extend [Lexical's Element nodes](https://lexical.dev/docs/concepts/nodes#elementnode). This allows the user to edit the HTML content (i.e. the nested markdown inside those elements).

**Note:** while using HTML can be tempting (and easy when it comes to parsing/rendering afterwards), it goes against the principles of markdown being human-readable, limited by intention format. If you need to extend the tooling available, it's better to consider directives and custom JSX components instead.

Out of the box, the editor does not include UI that allows the user to add, remove or configure the HTML elements' properties. You can, however use the Lexical API to build toolbar components that do so. Below is a simple example of a toolbar component that lets the user change the CSS class of the element under the cursor. You can replace the input with a dropdown or an UI of your choice.


```tsx
const HTMLToolbarComponent = () => {
const [currentSelection, activeEditor] = corePluginHooks.useEmitterValues('currentSelection', 'activeEditor')

const currentHTMLNode = React.useMemo(() => {
return (
activeEditor?.getEditorState().read(() => {
const selectedNodes = currentSelection?.getNodes() || []
if (selectedNodes.length === 1) {
return $getNearestNodeOfType(selectedNodes[0], GenericHTMLNode)
} else {
return null
}
}) || null
)
}, [currentSelection, activeEditor])

return (
<>
<input
disabled={currentHTMLNode === null}
value={getCssClass(currentHTMLNode)}
onChange={(e) => {
activeEditor?.update(
() => {
const attributesWithoutClass = currentHTMLNode?.getAttributes().filter((attr) => attr.name !== 'class') || []
const newClassAttr: MdxJsxAttribute = { type: 'mdxJsxAttribute', name: 'class', value: e.target.value }
currentHTMLNode?.updateAttributes([...attributesWithoutClass, newClassAttr])
},
{ discrete: true }
)
e.target.focus()
}}
/>
</>
)
}

function getCssClass(node: GenericHTMLNode | null) {
return (node?.getAttributes().find((attr) => attr.name === 'class')?.value as string) ?? ''
}
```

1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"micromark-extension-frontmatter": "1.1.0",
"micromark-extension-gfm-table": "^1.0.6",
"micromark-extension-gfm-task-list-item": "1.0.5",
"micromark-extension-mdx-jsx": "^1.0.5",
"micromark-extension-mdxjs": "1.0.1",
"react-hook-form": "^7.44.2",
"unidiff": "^1.0.2"
Expand Down
181 changes: 181 additions & 0 deletions src/examples/html.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import React from 'react'
import {
DiffSourceToggleWrapper,
MDXEditor,
corePluginHooks,
diffSourcePlugin,
headingsPlugin,
toolbarPlugin,
$createGenericHTMLNode
} from '../'
import { $patchStyleText } from '@lexical/selection'
import { $getRoot, $getSelection, $isRangeSelection, $isTextNode, ElementNode, LexicalNode } from 'lexical'
import { $isGenericHTMLNode } from '@/plugins/core/GenericHTMLNode'
import { GenericHTMLNode } from '@/plugins/core/GenericHTMLNode'
import { MdxJsxAttribute } from 'mdast-util-mdx'
import { $getNearestNodeOfType } from '@lexical/utils'

const markdownWithSpan = `
# Hello World
A paragraph with <span style="color: red" class="some">some red text <span style="color: blue">with some blue nesting.</span> in here.</span> in it.
`

export function SpanWithColor() {
return (
<>
<MDXEditor
markdown={markdownWithSpan}
plugins={[
headingsPlugin(),
diffSourcePlugin(),
toolbarPlugin({
toolbarContents: () => (
<DiffSourceToggleWrapper>
<HTMLToolbarComponent />
</DiffSourceToggleWrapper>
)
})
]}
onChange={(md) => {
console.log('change', md)
}}
/>
</>
)
}

const HTMLToolbarComponent = () => {
const [currentSelection, activeEditor] = corePluginHooks.useEmitterValues('currentSelection', 'activeEditor')

const currentStyle = React.useMemo(() => {
return (
activeEditor?.getEditorState().read(() => {
const selectedNodes = currentSelection?.getNodes() || []
if (selectedNodes.length === 1) {
let node: ElementNode | LexicalNode | null | undefined = selectedNodes[0]
let style = ''
while (!style && node && node !== $getRoot()) {
if ($isTextNode(node) || $isGenericHTMLNode(node)) {
style = node.getStyle()
}
node = node?.getParent()
}
return style
} else {
return ''
}
}) || ''
)
}, [currentSelection, activeEditor])

const currentHTMLNode = React.useMemo(() => {
return (
activeEditor?.getEditorState().read(() => {
const selectedNodes = currentSelection?.getNodes() || []
if (selectedNodes.length === 1) {
return $getNearestNodeOfType(selectedNodes[0], GenericHTMLNode)
} else {
return null
}
}) || null
)
}, [currentSelection, activeEditor])

return (
<>
<button
onClick={() => {
if (activeEditor !== null && currentSelection !== null) {
activeEditor.update(() => {
$patchStyleText(currentSelection, { color: 'orange' })
})
}
}}
>
Make selection orange
</button>
<button
onClick={() => {
if (activeEditor !== null && currentSelection !== null) {
activeEditor.update(() => {
$patchStyleText(currentSelection, { 'font-size': '20px' })
})
}
}}
>
Big font size
</button>
{currentStyle && <div>Current style: {currentStyle}</div>}
current css class:{' '}
<input
disabled={currentHTMLNode === null}
value={getCssClass(currentHTMLNode)}
onChange={(e) => {
activeEditor?.update(
() => {
const attributesWithoutClass = currentHTMLNode?.getAttributes().filter((attr) => attr.name !== 'class') || []
const newClassAttr: MdxJsxAttribute = { type: 'mdxJsxAttribute', name: 'class', value: e.target.value }
currentHTMLNode?.updateAttributes([...attributesWithoutClass, newClassAttr])
},
{ discrete: true }
)
e.target.focus()
}}
/>
<button
disabled={currentHTMLNode === null}
onClick={() => {
if (activeEditor !== null && currentSelection !== null) {
activeEditor.update(() => {
// const children = currentHTMLNode?.getChildren() || []
currentHTMLNode?.remove()
const selection = $getSelection()
selection?.insertNodes(currentHTMLNode?.getChildren() || [])
})
}
}}
>
remove HTML node
</button>
<button
onClick={() => {
if (activeEditor !== null && currentSelection !== null) {
activeEditor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const selectedNodes = selection.getNodes()
const currentTextNode = selectedNodes.length === 1 && $isTextNode(selectedNodes[0]) ? selectedNodes[0] : null
if (currentTextNode) {
const attributes = Object.entries({ style: 'color: green' }).map(([name, value]) => ({
type: 'mdxJsxAttribute' as const,
name,
value
}))

const newNode = $createGenericHTMLNode('span', 'mdxJsxTextElement', attributes)
selection?.insertNodes([newNode])

// newNode.insertAfter(slicedPortion)
// newNode.append(slicedPortion)

/*
$wrapNodeInElement(slicedPortion, () => )
*/
}
}
})
}
}}
>
wrap in a red span
</button>
</>
)
}

function getCssClass(node: GenericHTMLNode | null) {
return (node?.getAttributes().find((attr) => attr.name === 'class')?.value as string) ?? ''
}
42 changes: 42 additions & 0 deletions src/exportMarkdownFromLexical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import * as Mdast from 'mdast'
import type { MdxjsEsm } from 'mdast-util-mdx'
import { Options as ToMarkdownOptions, toMarkdown } from 'mdast-util-to-markdown'
import type { JsxComponentDescriptor } from './plugins/jsx'
import { isMdastHTMLNode } from './plugins/core/MdastHTMLNode'
import { mergeStyleAttributes } from './utils/mergeStyleAttributes'

export type { Options as ToMarkdownOptions } from 'mdast-util-to-markdown'

Expand Down Expand Up @@ -80,6 +82,11 @@ export interface LexicalExportVisitor<LN extends LexicalNode, UN extends Mdast.C
* For this to be called by the tree walk, shouldJoin must return true.
*/
join?<T extends Mdast.Content>(prevNode: T, currentNode: T): T

/**
* Default 0, optional, sets the priority of the visitor. The higher the number, the earlier it will be called.
*/
priority?: number
}

/**
Expand Down Expand Up @@ -113,6 +120,9 @@ export function exportLexicalTreeToMdast({
}: ExportLexicalTreeOptions): Mdast.Root {
let unistRoot: Mdast.Root | null = null
const referredComponents = new Set<string>()

visitors = visitors.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))

visit(root, null)

function registerReferredComponent(componentName: string) {
Expand Down Expand Up @@ -237,6 +247,7 @@ export function exportLexicalTreeToMdast({
}

fixWrappingWhitespace(typedRoot, [])
collapseNestedHtmlTags(typedRoot)

if (!jsxIsAvailable) {
convertUnderlineJsxToHtml(typedRoot)
Expand All @@ -245,6 +256,37 @@ export function exportLexicalTreeToMdast({
return typedRoot
}

function collapseNestedHtmlTags(node: Mdast.Parent | Mdast.Content) {
if ('children' in node) {
if (isMdastHTMLNode(node) && node.children.length === 1) {
const onlyChild = node.children[0]
if (onlyChild.type === 'mdxJsxTextElement' && onlyChild.name === 'span') {
;(onlyChild.attributes || []).forEach((attribute) => {
if (attribute.type === 'mdxJsxAttribute') {
const parentAttribute = node.attributes?.find((attr) => attr.type === 'mdxJsxAttribute' && attr.name === attribute.name)
if (parentAttribute) {
if (attribute.name === 'className') {
const mergedClassesSet = new Set([
...(parentAttribute.value as string).split(' '),
...(attribute.value as string).split(' ')
])
parentAttribute.value = Array.from(mergedClassesSet).join(' ')
} else if (attribute.name === 'style') {
parentAttribute.value = mergeStyleAttributes(parentAttribute.value as string, attribute.value as string)
}
} else {
node.attributes.push(attribute)
}
}
})
node.children = onlyChild.children
}
}

node.children.forEach((child) => collapseNestedHtmlTags(child))
}
}

function convertUnderlineJsxToHtml(node: Mdast.Parent | Mdast.Content) {
if (Object.hasOwn(node, 'children')) {
const nodeAsParent = node as Mdast.Parent
Expand Down
6 changes: 6 additions & 0 deletions src/importMarkdownToLexical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export interface MdastImportVisitor<UN extends Mdast.Content> {
* The function that is called when the node is visited. See {@link MdastVisitParams} for details.
*/
visitNode(params: MdastVisitParams<UN>): void
/**
* Default 0, optional, sets the priority of the visitor. The higher the number, the earlier it will be called.
*/
priority?: number
}

function isParent(node: unknown): node is Mdast.Parent {
Expand Down Expand Up @@ -124,6 +128,8 @@ export function importMarkdownToLexical({ root, markdown, visitors, syntaxExtens
export function importMdastTreeToLexical({ root, mdastRoot, visitors }: MdastTreeImportOptions): void {
const formattingMap = new WeakMap<object, number>()

visitors = visitors.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))

function visitChildren(mdastNode: Mdast.Parent, lexicalParent: LexicalNode) {
if (!isParent(mdastNode)) {
throw new Error('Attempting to visit children of a non-parent')
Expand Down
Loading

0 comments on commit 51a8612

Please sign in to comment.