@nuasite/cms-mdx-editor 0.43.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/types/component-picker.d.ts +15 -0
- package/dist/types/component-picker.d.ts.map +1 -0
- package/dist/types/format-toolbar.d.ts +22 -0
- package/dist/types/format-toolbar.d.ts.map +1 -0
- package/dist/types/index.d.ts +21 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/link-popover.d.ts +9 -0
- package/dist/types/link-popover.d.ts.map +1 -0
- package/dist/types/mdx-block-card.d.ts +28 -0
- package/dist/types/mdx-block-card.d.ts.map +1 -0
- package/dist/types/mdx-body-editor.d.ts +17 -0
- package/dist/types/mdx-body-editor.d.ts.map +1 -0
- package/dist/types/mdx-plugin.d.ts +16 -0
- package/dist/types/mdx-plugin.d.ts.map +1 -0
- package/dist/types/mdx-view.d.ts +20 -0
- package/dist/types/mdx-view.d.ts.map +1 -0
- package/dist/types/media-library.d.ts +13 -0
- package/dist/types/media-library.d.ts.map +1 -0
- package/dist/types/media-source.d.ts +44 -0
- package/dist/types/media-source.d.ts.map +1 -0
- package/dist/types/milkdown-utils.d.ts +37 -0
- package/dist/types/milkdown-utils.d.ts.map +1 -0
- package/dist/types/slot-editor.d.ts +5 -0
- package/dist/types/slot-editor.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +63 -0
- package/src/component-picker.tsx +82 -0
- package/src/format-toolbar.tsx +197 -0
- package/src/index.ts +20 -0
- package/src/link-popover.tsx +64 -0
- package/src/mdx-block-card.tsx +227 -0
- package/src/mdx-body-editor.tsx +146 -0
- package/src/mdx-plugin.ts +270 -0
- package/src/mdx-view.tsx +116 -0
- package/src/media-library.tsx +377 -0
- package/src/media-source.ts +45 -0
- package/src/milkdown-utils.ts +182 -0
- package/src/slot-editor.tsx +92 -0
- package/src/tsconfig.json +11 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MdxBodyEditor` — a Milkdown rich-text editor for a collection entry's markdown/
|
|
3
|
+
* MDX body. Component blocks round-trip via the MDX plugin (`mdx-plugin` + the
|
|
4
|
+
* React node-view in `mdx-view`); a picker inserts new ones. String in / string
|
|
5
|
+
* out (`value` / `onChange`) so it drops into any host's save flow.
|
|
6
|
+
*
|
|
7
|
+
* Pass `components` (from `cmsClient.getComponents()`) to drive the picker and the
|
|
8
|
+
* block-card prop labels; without it blocks still render and round-trip, just
|
|
9
|
+
* without rich labels.
|
|
10
|
+
*/
|
|
11
|
+
import { defaultValueCtx, Editor, rootCtx } from '@milkdown/core'
|
|
12
|
+
import { listener, listenerCtx } from '@milkdown/plugin-listener'
|
|
13
|
+
import { commonmark } from '@milkdown/preset-commonmark'
|
|
14
|
+
import { gfm } from '@milkdown/preset-gfm'
|
|
15
|
+
import { callCommand, replaceAll } from '@milkdown/utils'
|
|
16
|
+
import type { ComponentDefinition } from '@nuasite/cms-types'
|
|
17
|
+
import { useEffect, useRef, useState } from 'react'
|
|
18
|
+
import { ComponentPicker } from './component-picker'
|
|
19
|
+
import { FormatToolbar } from './format-toolbar'
|
|
20
|
+
import type { MediaContext, MediaSource } from './media-source'
|
|
21
|
+
import { insertMdxComponentCommand, mdxComponentNode, mdxEsmNode, remarkMdxPlugin } from './mdx-plugin'
|
|
22
|
+
import { type ComponentResolver, createMdxComponentView } from './mdx-view'
|
|
23
|
+
|
|
24
|
+
export interface MdxBodyEditorProps {
|
|
25
|
+
value: string
|
|
26
|
+
onChange: (markdown: string) => void
|
|
27
|
+
/** Project component definitions — drives the insert picker and prop labels. */
|
|
28
|
+
components?: ComponentDefinition[]
|
|
29
|
+
/**
|
|
30
|
+
* Media source (the host's `CmsClient` satisfies it) — enables the toolbar's
|
|
31
|
+
* image insert and image-prop browse/upload. Absent → those affordances hide.
|
|
32
|
+
*/
|
|
33
|
+
media?: MediaSource
|
|
34
|
+
/** Upload context (collection/entry) so uploads file against this entry. */
|
|
35
|
+
mediaContext?: MediaContext
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const wrapper: React.CSSProperties = { border: '1px solid #d4d4d8', borderRadius: 6, background: '#fff' }
|
|
39
|
+
const editorHost: React.CSSProperties = { padding: '8px 12px', minHeight: 240, outline: 'none' }
|
|
40
|
+
|
|
41
|
+
export function MdxBodyEditor({ value, onChange, components, media, mediaContext }: MdxBodyEditorProps) {
|
|
42
|
+
const hostRef = useRef<HTMLDivElement>(null)
|
|
43
|
+
const editorRef = useRef<Editor | null>(null)
|
|
44
|
+
const latest = useRef(value)
|
|
45
|
+
const ready = useRef(false)
|
|
46
|
+
const settingExternal = useRef(false)
|
|
47
|
+
const onChangeRef = useRef(onChange)
|
|
48
|
+
onChangeRef.current = onChange
|
|
49
|
+
|
|
50
|
+
// Component map kept in a ref so the node-view resolver always sees the latest
|
|
51
|
+
// list even though the editor is created once.
|
|
52
|
+
const componentMap = useRef<Map<string, ComponentDefinition>>(new Map())
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
componentMap.current = new Map((components ?? []).map(c => [c.name, c]))
|
|
55
|
+
}, [components])
|
|
56
|
+
|
|
57
|
+
// Read inside the create-effect via refs so an inline `mediaContext` object from
|
|
58
|
+
// the host doesn't re-create the editor every render (deps stay `[]`). Both hosts
|
|
59
|
+
// remount per entry, so the value captured at mount is the right one.
|
|
60
|
+
const mediaRef = useRef(media)
|
|
61
|
+
mediaRef.current = media
|
|
62
|
+
const mediaContextRef = useRef(mediaContext)
|
|
63
|
+
mediaContextRef.current = mediaContext
|
|
64
|
+
|
|
65
|
+
const [pickerOpen, setPickerOpen] = useState(false)
|
|
66
|
+
// The created editor, surfaced as state so the toolbar (re)attaches once ready.
|
|
67
|
+
const [editorInstance, setEditorInstance] = useState<Editor | null>(null)
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const el = hostRef.current
|
|
71
|
+
if (!el) return
|
|
72
|
+
let destroyed = false
|
|
73
|
+
|
|
74
|
+
const resolver: ComponentResolver = (name) => componentMap.current.get(name)
|
|
75
|
+
|
|
76
|
+
const init = async () => {
|
|
77
|
+
const editor = await Editor.make()
|
|
78
|
+
.config((ctx) => {
|
|
79
|
+
ctx.set(rootCtx, el)
|
|
80
|
+
ctx.set(defaultValueCtx, latest.current)
|
|
81
|
+
ctx.get(listenerCtx).markdownUpdated((_, md) => {
|
|
82
|
+
latest.current = md
|
|
83
|
+
if (ready.current && !settingExternal.current) onChangeRef.current(md)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
.use(commonmark)
|
|
87
|
+
.use(gfm)
|
|
88
|
+
.use(listener)
|
|
89
|
+
.use(remarkMdxPlugin)
|
|
90
|
+
.use(mdxEsmNode)
|
|
91
|
+
.use(mdxComponentNode)
|
|
92
|
+
.use(createMdxComponentView(resolver, mediaRef.current, mediaContextRef.current))
|
|
93
|
+
.use(insertMdxComponentCommand)
|
|
94
|
+
.create()
|
|
95
|
+
|
|
96
|
+
if (destroyed) {
|
|
97
|
+
editor.destroy()
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
editorRef.current = editor
|
|
101
|
+
setEditorInstance(editor)
|
|
102
|
+
// Ignore the value-set that happens during creation; only user edits emit.
|
|
103
|
+
queueMicrotask(() => {
|
|
104
|
+
ready.current = true
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
void init()
|
|
108
|
+
|
|
109
|
+
return () => {
|
|
110
|
+
destroyed = true
|
|
111
|
+
ready.current = false
|
|
112
|
+
editorRef.current?.destroy()
|
|
113
|
+
editorRef.current = null
|
|
114
|
+
setEditorInstance(null)
|
|
115
|
+
}
|
|
116
|
+
}, [])
|
|
117
|
+
|
|
118
|
+
// Adopt external value changes (e.g. conflict resolution) without clobbering
|
|
119
|
+
// the user's own edits — when value already equals what we last emitted, skip.
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!editorRef.current || !ready.current) return
|
|
122
|
+
if (value === latest.current) return
|
|
123
|
+
settingExternal.current = true
|
|
124
|
+
editorRef.current.action(replaceAll(value))
|
|
125
|
+
latest.current = value
|
|
126
|
+
queueMicrotask(() => { settingExternal.current = false })
|
|
127
|
+
}, [value])
|
|
128
|
+
|
|
129
|
+
const insert = (componentName: string, props: Record<string, string>, children?: string) => {
|
|
130
|
+
editorRef.current?.action(callCommand(insertMdxComponentCommand.key, { componentName, props, children }))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div style={wrapper}>
|
|
135
|
+
<FormatToolbar
|
|
136
|
+
editor={editorInstance}
|
|
137
|
+
media={media}
|
|
138
|
+
mediaContext={mediaContext}
|
|
139
|
+
field="body"
|
|
140
|
+
onInsertComponent={() => setPickerOpen(true)}
|
|
141
|
+
/>
|
|
142
|
+
<div ref={hostRef} style={editorHost} />
|
|
143
|
+
<ComponentPicker open={pickerOpen} components={components ?? []} onInsert={insert} onClose={() => setPickerOpen(false)} />
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Milkdown plugin that round-trips MDX component blocks through the editor.
|
|
3
|
+
*
|
|
4
|
+
* Ported from `@nuasite/cms`'s in-iframe `milkdown-mdx-plugin.tsx` — the parse/
|
|
5
|
+
* serialize logic operates on the remark-mdx (mdast) AST and is framework-agnostic,
|
|
6
|
+
* so it moves here verbatim. The only piece that was preact-coupled — the node
|
|
7
|
+
* `$view` that renders the block-card — lives separately in `mdx-view.tsx` (React)
|
|
8
|
+
* and is composed into the plugin array by `MdxBodyEditor`.
|
|
9
|
+
*
|
|
10
|
+
* The mdast nodes are intentionally typed loosely (`any`): they are the dynamic
|
|
11
|
+
* remark-mdx AST, the same boundary the original treated as untyped.
|
|
12
|
+
*/
|
|
13
|
+
import type { Node as PmNode, NodeType } from '@milkdown/prose/model'
|
|
14
|
+
import type { Command } from '@milkdown/prose/state'
|
|
15
|
+
import type { MarkdownNode, SerializerState } from '@milkdown/transformer'
|
|
16
|
+
import { $command, $node, $remark } from '@milkdown/utils'
|
|
17
|
+
import remarkMdx from 'remark-mdx'
|
|
18
|
+
import remarkParse from 'remark-parse'
|
|
19
|
+
import { unified } from 'unified'
|
|
20
|
+
|
|
21
|
+
/** Prefix marking expression attributes (`prop={value}`) vs string literals in serialized props. */
|
|
22
|
+
export const MDX_EXPR_PREFIX = '__mdx_expr__:'
|
|
23
|
+
|
|
24
|
+
/** HTML void elements that should not be treated as editable MDX components. */
|
|
25
|
+
const HTML_VOID_ELEMENTS = new Set(['br', 'hr', 'img', 'input', 'meta', 'link', 'area', 'base', 'col', 'embed', 'source', 'track', 'wbr'])
|
|
26
|
+
|
|
27
|
+
/** Cached unified processor for parsing markdown children during serialization. */
|
|
28
|
+
const remarkParser = unified().use(remarkParse)
|
|
29
|
+
|
|
30
|
+
export const remarkMdxPlugin: any = $remark('remarkMdx', () => remarkMdx)
|
|
31
|
+
|
|
32
|
+
function parseJsxAttributes(attributes: any[]): { props: Record<string, string>; hasExpressions: boolean } {
|
|
33
|
+
const props: Record<string, string> = {}
|
|
34
|
+
let hasExpressions = false
|
|
35
|
+
|
|
36
|
+
if (!Array.isArray(attributes)) return { props, hasExpressions }
|
|
37
|
+
|
|
38
|
+
for (const attr of attributes) {
|
|
39
|
+
if (attr.type === 'mdxJsxAttribute' && attr.name) {
|
|
40
|
+
if (attr.value === null || attr.value === undefined) {
|
|
41
|
+
// Boolean attribute: <Component flag />
|
|
42
|
+
props[attr.name] = 'true'
|
|
43
|
+
} else if (typeof attr.value === 'string') {
|
|
44
|
+
props[attr.name] = attr.value
|
|
45
|
+
} else if (attr.value?.type === 'mdxJsxAttributeValueExpression') {
|
|
46
|
+
// Expression attribute: prop={value}
|
|
47
|
+
props[attr.name] = `${MDX_EXPR_PREFIX}${attr.value.value}`
|
|
48
|
+
hasExpressions = true
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (attr.type === 'mdxJsxExpressionAttribute') {
|
|
52
|
+
hasExpressions = true
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { props, hasExpressions }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function serializePropsToAttributes(props: Record<string, string>): any[] {
|
|
60
|
+
const attributes: any[] = []
|
|
61
|
+
|
|
62
|
+
for (const [name, value] of Object.entries(props)) {
|
|
63
|
+
if (value.startsWith(MDX_EXPR_PREFIX)) {
|
|
64
|
+
attributes.push({
|
|
65
|
+
type: 'mdxJsxAttribute',
|
|
66
|
+
name,
|
|
67
|
+
value: {
|
|
68
|
+
type: 'mdxJsxAttributeValueExpression',
|
|
69
|
+
value: value.slice(MDX_EXPR_PREFIX.length),
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
} else {
|
|
73
|
+
attributes.push({
|
|
74
|
+
type: 'mdxJsxAttribute',
|
|
75
|
+
name,
|
|
76
|
+
value,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return attributes
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Serialize mdast children back to markdown text. */
|
|
85
|
+
function serializeChildren(children: any[]): string {
|
|
86
|
+
if (!children || children.length === 0) return ''
|
|
87
|
+
const parts: string[] = []
|
|
88
|
+
for (const child of children) {
|
|
89
|
+
if (child.type === 'paragraph') {
|
|
90
|
+
const text = serializeInlineChildren(child.children ?? [])
|
|
91
|
+
parts.push(text)
|
|
92
|
+
} else if (child.type === 'text') {
|
|
93
|
+
parts.push(child.value ?? '')
|
|
94
|
+
} else if (child.type === 'mdxJsxFlowElement' || child.type === 'mdxJsxTextElement') {
|
|
95
|
+
// Nested JSX — reconstruct as string
|
|
96
|
+
const name = child.name ?? ''
|
|
97
|
+
const attrs = (child.attributes ?? [])
|
|
98
|
+
.map((a: any) => {
|
|
99
|
+
if (a.type === 'mdxJsxAttribute' && a.name) {
|
|
100
|
+
if (typeof a.value === 'string') return `${a.name}="${a.value}"`
|
|
101
|
+
if (a.value?.type === 'mdxJsxAttributeValueExpression') return `${a.name}={${a.value.value}}`
|
|
102
|
+
}
|
|
103
|
+
return ''
|
|
104
|
+
})
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.join(' ')
|
|
107
|
+
const inner = serializeChildren(child.children ?? [])
|
|
108
|
+
if (inner) {
|
|
109
|
+
parts.push(`<${name}${attrs ? ' ' + attrs : ''}>${inner}</${name}>`)
|
|
110
|
+
} else {
|
|
111
|
+
parts.push(`<${name}${attrs ? ' ' + attrs : ''} />`)
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
// Fallback — use value if present
|
|
115
|
+
if (child.value) parts.push(child.value)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return parts.join('\n\n')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function serializeInlineChildren(children: any[]): string {
|
|
122
|
+
return children.map((c: any) => {
|
|
123
|
+
if (c.type === 'text') return c.value ?? ''
|
|
124
|
+
if (c.type === 'strong') return `**${serializeInlineChildren(c.children ?? [])}**`
|
|
125
|
+
if (c.type === 'emphasis') return `*${serializeInlineChildren(c.children ?? [])}*`
|
|
126
|
+
if (c.type === 'inlineCode') return `\`${c.value ?? ''}\``
|
|
127
|
+
if (c.type === 'link') return `[${serializeInlineChildren(c.children ?? [])}](${c.url ?? ''})`
|
|
128
|
+
return c.value ?? ''
|
|
129
|
+
}).join('')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const mdxComponentNode = $node('mdx_component', () => ({
|
|
133
|
+
group: 'block',
|
|
134
|
+
atom: true,
|
|
135
|
+
isolating: true,
|
|
136
|
+
selectable: true,
|
|
137
|
+
draggable: false,
|
|
138
|
+
attrs: {
|
|
139
|
+
componentName: { default: '' },
|
|
140
|
+
props: { default: '{}' },
|
|
141
|
+
hasExpressions: { default: false },
|
|
142
|
+
children: { default: '' },
|
|
143
|
+
},
|
|
144
|
+
toDOM: (node: PmNode) => {
|
|
145
|
+
const div = document.createElement('div')
|
|
146
|
+
div.setAttribute('data-mdx-component', node.attrs.componentName)
|
|
147
|
+
div.setAttribute('data-mdx-props', node.attrs.props)
|
|
148
|
+
if (node.attrs.children) div.setAttribute('data-mdx-children', node.attrs.children)
|
|
149
|
+
if (node.attrs.hasExpressions) div.setAttribute('data-mdx-expressions', 'true')
|
|
150
|
+
div.className = 'mdx-component-block'
|
|
151
|
+
div.textContent = `<${node.attrs.componentName} />`
|
|
152
|
+
return div as any
|
|
153
|
+
},
|
|
154
|
+
parseDOM: [{
|
|
155
|
+
tag: 'div[data-mdx-component]',
|
|
156
|
+
getAttrs: (dom: HTMLElement) => ({
|
|
157
|
+
componentName: dom.getAttribute('data-mdx-component') || '',
|
|
158
|
+
props: dom.getAttribute('data-mdx-props') || '{}',
|
|
159
|
+
children: dom.getAttribute('data-mdx-children') || '',
|
|
160
|
+
hasExpressions: dom.getAttribute('data-mdx-expressions') === 'true',
|
|
161
|
+
}),
|
|
162
|
+
}],
|
|
163
|
+
parseMarkdown: {
|
|
164
|
+
match: (node: MarkdownNode) => node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement',
|
|
165
|
+
runner: (state, node, proseType: NodeType) => {
|
|
166
|
+
const name = (node as any).name as string | null
|
|
167
|
+
if (!name) return // Skip fragments
|
|
168
|
+
// Skip HTML void elements — they are not editable components
|
|
169
|
+
if (HTML_VOID_ELEMENTS.has(name.toLowerCase())) return
|
|
170
|
+
|
|
171
|
+
const { props, hasExpressions } = parseJsxAttributes((node as any).attributes)
|
|
172
|
+
const children = serializeChildren((node as any).children ?? [])
|
|
173
|
+
|
|
174
|
+
state.addNode(proseType, {
|
|
175
|
+
componentName: name,
|
|
176
|
+
props: JSON.stringify(props),
|
|
177
|
+
hasExpressions,
|
|
178
|
+
children,
|
|
179
|
+
})
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
toMarkdown: {
|
|
183
|
+
match: (node: PmNode) => node.type.name === 'mdx_component',
|
|
184
|
+
runner: (state: SerializerState, node: PmNode) => {
|
|
185
|
+
const componentName = node.attrs.componentName as string
|
|
186
|
+
const props: Record<string, string> = JSON.parse(node.attrs.props as string)
|
|
187
|
+
const childrenText = (node.attrs.children as string) || ''
|
|
188
|
+
const attributes = serializePropsToAttributes(props)
|
|
189
|
+
|
|
190
|
+
if (childrenText.trim()) {
|
|
191
|
+
// Parse children markdown into proper mdast nodes so headings, lists, etc. are preserved
|
|
192
|
+
const childrenAst = remarkParser.parse(childrenText)
|
|
193
|
+
state.addNode('mdxJsxFlowElement', undefined, undefined, {
|
|
194
|
+
name: componentName,
|
|
195
|
+
attributes,
|
|
196
|
+
children: childrenAst.children,
|
|
197
|
+
} as any)
|
|
198
|
+
} else {
|
|
199
|
+
state.addNode('mdxJsxFlowElement', undefined, undefined, {
|
|
200
|
+
name: componentName,
|
|
201
|
+
attributes,
|
|
202
|
+
children: [],
|
|
203
|
+
} as any)
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
}))
|
|
208
|
+
|
|
209
|
+
export interface InsertMdxComponentPayload {
|
|
210
|
+
componentName: string
|
|
211
|
+
props: Record<string, string>
|
|
212
|
+
children?: string
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const insertMdxComponentCommand = $command('insertMdxComponent', () => {
|
|
216
|
+
return (payload?: InsertMdxComponentPayload): Command => {
|
|
217
|
+
return (state, dispatch) => {
|
|
218
|
+
if (!payload) return false
|
|
219
|
+
const nodeType = state.schema.nodes.mdx_component
|
|
220
|
+
if (!nodeType) return false
|
|
221
|
+
|
|
222
|
+
const node = nodeType.create({
|
|
223
|
+
componentName: payload.componentName,
|
|
224
|
+
props: JSON.stringify(payload.props),
|
|
225
|
+
hasExpressions: false,
|
|
226
|
+
children: payload.children || '',
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
if (dispatch) {
|
|
230
|
+
const { from } = state.selection
|
|
231
|
+
const tr = state.tr.insert(from, node)
|
|
232
|
+
dispatch(tr)
|
|
233
|
+
}
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Hidden node that preserves `import ... from '...'` statements through the round-trip.
|
|
241
|
+
* remark-mdx parses these as `mdxjsEsm` — without a matching ProseMirror node Milkdown throws.
|
|
242
|
+
*/
|
|
243
|
+
export const mdxEsmNode = $node('mdx_esm', () => ({
|
|
244
|
+
group: 'block',
|
|
245
|
+
atom: true,
|
|
246
|
+
attrs: {
|
|
247
|
+
value: { default: '' },
|
|
248
|
+
},
|
|
249
|
+
toDOM: () => {
|
|
250
|
+
// Render nothing — imports are invisible in the editor
|
|
251
|
+
const span = document.createElement('span')
|
|
252
|
+
span.style.display = 'none'
|
|
253
|
+
return span as any
|
|
254
|
+
},
|
|
255
|
+
parseDOM: [],
|
|
256
|
+
parseMarkdown: {
|
|
257
|
+
match: (node: MarkdownNode) => node.type === 'mdxjsEsm',
|
|
258
|
+
runner: (state, node, proseType: NodeType) => {
|
|
259
|
+
state.addNode(proseType, { value: (node as any).value ?? '' })
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
toMarkdown: {
|
|
263
|
+
match: (node: PmNode) => node.type.name === 'mdx_esm',
|
|
264
|
+
runner: (state: SerializerState, node: PmNode) => {
|
|
265
|
+
state.addNode('mdxjsEsm', undefined, undefined, {
|
|
266
|
+
value: node.attrs.value as string,
|
|
267
|
+
} as any)
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
}))
|
package/src/mdx-view.tsx
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React node-view for the `mdx_component` node. Replaces the original preact
|
|
3
|
+
* `render()` view: renders `MdxBlockCard` into the node-view container via a
|
|
4
|
+
* per-node React root, and writes edits back as ProseMirror node attrs.
|
|
5
|
+
*
|
|
6
|
+
* `getComponent` resolves a component's definition (props/slots) for labels and
|
|
7
|
+
* default-slot detection — supplied by the editor from the `components` prop.
|
|
8
|
+
*/
|
|
9
|
+
import type { Node as PmNode } from '@milkdown/prose/model'
|
|
10
|
+
import { $view } from '@milkdown/utils'
|
|
11
|
+
import type { ComponentDefinition } from '@nuasite/cms-types'
|
|
12
|
+
import { createElement } from 'react'
|
|
13
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
14
|
+
import { MdxBlockCard } from './mdx-block-card'
|
|
15
|
+
import type { MediaContext, MediaSource } from './media-source'
|
|
16
|
+
import { mdxComponentNode } from './mdx-plugin'
|
|
17
|
+
|
|
18
|
+
export type ComponentResolver = (name: string) => ComponentDefinition | undefined
|
|
19
|
+
|
|
20
|
+
export function createMdxComponentView(getComponent: ComponentResolver, media?: MediaSource, mediaContext?: MediaContext) {
|
|
21
|
+
return $view(mdxComponentNode, () => {
|
|
22
|
+
return (pmNode, view, getPos) => {
|
|
23
|
+
const container = document.createElement('div')
|
|
24
|
+
container.className = 'mdx-block-card-wrapper'
|
|
25
|
+
container.setAttribute('data-cms-ui', '')
|
|
26
|
+
container.contentEditable = 'false'
|
|
27
|
+
const root: Root = createRoot(container)
|
|
28
|
+
|
|
29
|
+
let lastAttrs: { componentName: string; props: string; hasExpressions: boolean; children: string } | null = null
|
|
30
|
+
|
|
31
|
+
const updateNodeAttrs = (update: Record<string, unknown>) => {
|
|
32
|
+
const pos = typeof getPos === 'function' ? getPos() : null
|
|
33
|
+
if (pos == null) return
|
|
34
|
+
const currentNode = view.state.doc.nodeAt(pos)
|
|
35
|
+
if (!currentNode) return
|
|
36
|
+
const tr = view.state.tr.setNodeMarkup(pos, undefined, { ...currentNode.attrs, ...update })
|
|
37
|
+
view.dispatch(tr)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const removeNode = () => {
|
|
41
|
+
const pos = typeof getPos === 'function' ? getPos() : null
|
|
42
|
+
if (pos == null) return
|
|
43
|
+
const currentNode = view.state.doc.nodeAt(pos)
|
|
44
|
+
if (!currentNode) return
|
|
45
|
+
view.dispatch(view.state.tr.delete(pos, pos + currentNode.nodeSize))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const renderCard = (node: PmNode) => {
|
|
49
|
+
const componentName = node.attrs.componentName as string
|
|
50
|
+
const propsJson = node.attrs.props as string
|
|
51
|
+
const hasExpressions = node.attrs.hasExpressions as boolean
|
|
52
|
+
const children = (node.attrs.children as string) || ''
|
|
53
|
+
|
|
54
|
+
container.setAttribute('data-mdx-component', componentName)
|
|
55
|
+
container.setAttribute('data-mdx-props', propsJson)
|
|
56
|
+
container.setAttribute('data-mdx-children', children)
|
|
57
|
+
|
|
58
|
+
const props: Record<string, string> = JSON.parse(propsJson)
|
|
59
|
+
const definition = getComponent(componentName)
|
|
60
|
+
const hasDefaultSlot = (definition?.slots?.includes('default') ?? false) || children.trim() !== ''
|
|
61
|
+
|
|
62
|
+
lastAttrs = { componentName, props: propsJson, hasExpressions, children }
|
|
63
|
+
|
|
64
|
+
root.render(createElement(MdxBlockCard, {
|
|
65
|
+
componentName,
|
|
66
|
+
props,
|
|
67
|
+
hasExpressions,
|
|
68
|
+
slotContent: children,
|
|
69
|
+
definition,
|
|
70
|
+
media,
|
|
71
|
+
mediaContext,
|
|
72
|
+
onRemove: removeNode,
|
|
73
|
+
onSlotContentChange: hasDefaultSlot ? (newContent: string) => updateNodeAttrs({ children: newContent }) : undefined,
|
|
74
|
+
onPropsChange: hasExpressions ? undefined : (newProps: Record<string, string>) => updateNodeAttrs({ props: JSON.stringify(newProps) }),
|
|
75
|
+
}))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
renderCard(pmNode)
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
dom: container,
|
|
82
|
+
stopEvent: (event: Event) => {
|
|
83
|
+
const target = event.target as HTMLElement
|
|
84
|
+
if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') return true
|
|
85
|
+
if (target.isContentEditable || target.closest('[contenteditable]')) return true
|
|
86
|
+
if (target.closest('[data-mdx-action]')) return true
|
|
87
|
+
if (event.type === 'mousedown' || event.type === 'click') {
|
|
88
|
+
if (target.closest('button') || target.closest('[data-mdx-action]')) return true
|
|
89
|
+
}
|
|
90
|
+
return false
|
|
91
|
+
},
|
|
92
|
+
ignoreMutation: () => true,
|
|
93
|
+
update: (updatedNode: PmNode) => {
|
|
94
|
+
if (updatedNode.type.name !== 'mdx_component') return false
|
|
95
|
+
const attrs = updatedNode.attrs
|
|
96
|
+
if (
|
|
97
|
+
lastAttrs
|
|
98
|
+
&& attrs.componentName === lastAttrs.componentName
|
|
99
|
+
&& attrs.props === lastAttrs.props
|
|
100
|
+
&& attrs.hasExpressions === lastAttrs.hasExpressions
|
|
101
|
+
&& attrs.children === lastAttrs.children
|
|
102
|
+
) {
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
renderCard(updatedNode)
|
|
106
|
+
return true
|
|
107
|
+
},
|
|
108
|
+
destroy: () => {
|
|
109
|
+
// Defer unmount out of the ProseMirror dispatch tick (React forbids
|
|
110
|
+
// unmounting a root while it may still be rendering).
|
|
111
|
+
queueMicrotask(() => root.unmount())
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
}
|