@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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component picker for inserting an MDX block. A searchable list of the project's
|
|
3
|
+
* Astro components; selecting one inserts it at the cursor with its props
|
|
4
|
+
* pre-seeded (defaults where declared), then props/children are edited in the
|
|
5
|
+
* block-card. Self-contained inline styles.
|
|
6
|
+
*/
|
|
7
|
+
import type { ComponentDefinition } from '@nuasite/cms-types'
|
|
8
|
+
import { useMemo, useState } from 'react'
|
|
9
|
+
|
|
10
|
+
export interface ComponentPickerProps {
|
|
11
|
+
open: boolean
|
|
12
|
+
components: ComponentDefinition[]
|
|
13
|
+
onInsert: (componentName: string, props: Record<string, string>, children?: string) => void
|
|
14
|
+
onClose: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function defaultProps(def: ComponentDefinition): Record<string, string> {
|
|
18
|
+
const props: Record<string, string> = {}
|
|
19
|
+
for (const p of def.props) props[p.name] = p.defaultValue ?? ''
|
|
20
|
+
return props
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const backdrop: React.CSSProperties = {
|
|
24
|
+
position: 'fixed',
|
|
25
|
+
inset: 0,
|
|
26
|
+
background: 'rgba(0,0,0,0.35)',
|
|
27
|
+
display: 'flex',
|
|
28
|
+
alignItems: 'flex-start',
|
|
29
|
+
justifyContent: 'center',
|
|
30
|
+
paddingTop: '12vh',
|
|
31
|
+
zIndex: 1000,
|
|
32
|
+
}
|
|
33
|
+
const modal: React.CSSProperties = {
|
|
34
|
+
width: 'min(440px, 92vw)',
|
|
35
|
+
maxHeight: '70vh',
|
|
36
|
+
display: 'flex',
|
|
37
|
+
flexDirection: 'column',
|
|
38
|
+
background: '#fff',
|
|
39
|
+
border: '1px solid #d4d4d8',
|
|
40
|
+
borderRadius: 10,
|
|
41
|
+
boxShadow: '0 12px 32px rgba(0,0,0,0.18)',
|
|
42
|
+
overflow: 'hidden',
|
|
43
|
+
fontSize: 13,
|
|
44
|
+
}
|
|
45
|
+
const searchStyle: React.CSSProperties = { border: 'none', borderBottom: '1px solid #ececed', padding: '10px 12px', font: 'inherit', outline: 'none' }
|
|
46
|
+
const itemStyle: React.CSSProperties = { textAlign: 'left', border: 'none', background: 'transparent', padding: '8px 12px', cursor: 'pointer', borderRadius: 0 }
|
|
47
|
+
|
|
48
|
+
export function ComponentPicker({ open, components, onInsert, onClose }: ComponentPickerProps) {
|
|
49
|
+
const [query, setQuery] = useState('')
|
|
50
|
+
|
|
51
|
+
const filtered = useMemo(() => {
|
|
52
|
+
const q = query.trim().toLowerCase()
|
|
53
|
+
return components.filter(c => q === '' || c.name.toLowerCase().includes(q) || (c.description ?? '').toLowerCase().includes(q))
|
|
54
|
+
}, [components, query])
|
|
55
|
+
|
|
56
|
+
if (!open) return null
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div style={backdrop} onMouseDown={onClose}>
|
|
60
|
+
<div style={modal} onMouseDown={(e) => e.stopPropagation()}>
|
|
61
|
+
<input style={searchStyle} placeholder="Search components…" autoFocus value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
62
|
+
<div style={{ overflowY: 'auto' }}>
|
|
63
|
+
{filtered.length === 0 ? <div style={{ padding: 12, color: '#a1a1aa' }}>No components</div> : null}
|
|
64
|
+
{filtered.map(def => (
|
|
65
|
+
<button
|
|
66
|
+
key={def.name}
|
|
67
|
+
type="button"
|
|
68
|
+
style={itemStyle}
|
|
69
|
+
onClick={() => {
|
|
70
|
+
onInsert(def.name, defaultProps(def))
|
|
71
|
+
onClose()
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<div style={{ fontWeight: 600, fontFamily: 'ui-monospace, monospace', color: '#3f3f46' }}>{def.name}</div>
|
|
75
|
+
{def.description ? <div style={{ color: '#71717a', fontSize: 12 }}>{def.description}</div> : null}
|
|
76
|
+
</button>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich-text toolbar shared by the body editor and the nested slot editor. Reflects
|
|
3
|
+
* and toggles inline/block formatting on the bound Milkdown editor; optionally
|
|
4
|
+
* surfaces an image button (opens the media library) and an "insert component"
|
|
5
|
+
* button (body editor only). The nested slot editor passes neither, matching the
|
|
6
|
+
* original mini-editor's formatting-only toolbar.
|
|
7
|
+
*/
|
|
8
|
+
import { type Editor, editorViewCtx } from '@milkdown/core'
|
|
9
|
+
import {
|
|
10
|
+
liftListItemCommand,
|
|
11
|
+
toggleEmphasisCommand,
|
|
12
|
+
toggleLinkCommand,
|
|
13
|
+
toggleStrongCommand,
|
|
14
|
+
updateLinkCommand,
|
|
15
|
+
wrapInBlockquoteCommand,
|
|
16
|
+
wrapInBulletListCommand,
|
|
17
|
+
wrapInOrderedListCommand,
|
|
18
|
+
} from '@milkdown/preset-commonmark'
|
|
19
|
+
import { toggleStrikethroughCommand } from '@milkdown/preset-gfm'
|
|
20
|
+
import { callCommand } from '@milkdown/utils'
|
|
21
|
+
import { useEffect, useState } from 'react'
|
|
22
|
+
import { LinkPopover } from './link-popover'
|
|
23
|
+
import { MediaLibrary } from './media-library'
|
|
24
|
+
import { type ActiveFormats, defaultActiveFormats, isInListType, removeLinkMark, setupFormatTracking, toggleHeading } from './milkdown-utils'
|
|
25
|
+
import type { MediaContext, MediaSource } from './media-source'
|
|
26
|
+
|
|
27
|
+
/** Track active formats on the editor, re-attaching when the instance changes. */
|
|
28
|
+
export function useFormatTracking(editor: Editor | null): ActiveFormats {
|
|
29
|
+
const [formats, setFormats] = useState<ActiveFormats>(defaultActiveFormats)
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!editor) {
|
|
32
|
+
setFormats(defaultActiveFormats)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
return setupFormatTracking(editor, setFormats)
|
|
36
|
+
}, [editor])
|
|
37
|
+
return formats
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function doHeading(editor: Editor, level: number) {
|
|
41
|
+
toggleHeading(editor.ctx.get(editorViewCtx), level)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toggleList(editor: Editor, type: 'bullet' | 'ordered') {
|
|
45
|
+
const view = editor.ctx.get(editorViewCtx)
|
|
46
|
+
const listType = type === 'bullet' ? 'bullet_list' : 'ordered_list'
|
|
47
|
+
if (isInListType(view, listType)) {
|
|
48
|
+
editor.action(callCommand(liftListItemCommand.key))
|
|
49
|
+
} else {
|
|
50
|
+
editor.action(callCommand(type === 'bullet' ? wrapInBulletListCommand.key : wrapInOrderedListCommand.key))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function insertImage(editor: Editor, src: string, alt: string) {
|
|
55
|
+
editor.action((ctx) => {
|
|
56
|
+
const view = ctx.get(editorViewCtx)
|
|
57
|
+
const imageType = view.state.schema.nodes.image
|
|
58
|
+
if (!imageType) return
|
|
59
|
+
view.focus()
|
|
60
|
+
view.dispatch(view.state.tr.replaceSelectionWith(imageType.create({ src, alt })).scrollIntoView())
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface FormatToolbarProps {
|
|
65
|
+
editor: Editor | null
|
|
66
|
+
media?: MediaSource
|
|
67
|
+
mediaContext?: MediaContext
|
|
68
|
+
/** Upload field the image is filed under (e.g. 'body'). */
|
|
69
|
+
field?: string
|
|
70
|
+
onInsertComponent?: () => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const bar: React.CSSProperties = {
|
|
74
|
+
display: 'flex',
|
|
75
|
+
flexWrap: 'wrap',
|
|
76
|
+
alignItems: 'center',
|
|
77
|
+
gap: 3,
|
|
78
|
+
padding: '4px 6px',
|
|
79
|
+
borderBottom: '1px solid #ececed',
|
|
80
|
+
background: '#fafafa',
|
|
81
|
+
}
|
|
82
|
+
const sep: React.CSSProperties = { width: 1, height: 18, background: '#e4e4e7', margin: '0 3px' }
|
|
83
|
+
const baseBtn: React.CSSProperties = {
|
|
84
|
+
border: '1px solid transparent',
|
|
85
|
+
background: 'transparent',
|
|
86
|
+
borderRadius: 4,
|
|
87
|
+
padding: '2px 7px',
|
|
88
|
+
font: 'inherit',
|
|
89
|
+
fontSize: 12,
|
|
90
|
+
lineHeight: 1.4,
|
|
91
|
+
cursor: 'pointer',
|
|
92
|
+
color: '#52525b',
|
|
93
|
+
}
|
|
94
|
+
const activeBtn: React.CSSProperties = { ...baseBtn, background: '#2563eb', borderColor: '#2563eb', color: '#fff' }
|
|
95
|
+
|
|
96
|
+
function Btn({ active, title, onClick, style, children }: {
|
|
97
|
+
active?: boolean
|
|
98
|
+
title: string
|
|
99
|
+
onClick: () => void
|
|
100
|
+
style?: React.CSSProperties
|
|
101
|
+
children: React.ReactNode
|
|
102
|
+
}) {
|
|
103
|
+
return (
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
title={title}
|
|
107
|
+
data-mdx-action="format"
|
|
108
|
+
onMouseDown={e => e.preventDefault()}
|
|
109
|
+
onClick={onClick}
|
|
110
|
+
style={{ ...(active ? activeBtn : baseBtn), ...style }}
|
|
111
|
+
>
|
|
112
|
+
{children}
|
|
113
|
+
</button>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function FormatToolbar({ editor, media, mediaContext, field, onInsertComponent }: FormatToolbarProps) {
|
|
118
|
+
const formats = useFormatTracking(editor)
|
|
119
|
+
const [linkOpen, setLinkOpen] = useState(false)
|
|
120
|
+
const [mediaOpen, setMediaOpen] = useState(false)
|
|
121
|
+
const disabled = editor === null
|
|
122
|
+
|
|
123
|
+
const applyLink = (url: string) => {
|
|
124
|
+
setLinkOpen(false)
|
|
125
|
+
if (!editor) return
|
|
126
|
+
const view = editor.ctx.get(editorViewCtx)
|
|
127
|
+
view.focus()
|
|
128
|
+
editor.action(callCommand(formats.link ? updateLinkCommand.key : toggleLinkCommand.key, { href: url }))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const removeLink = () => {
|
|
132
|
+
setLinkOpen(false)
|
|
133
|
+
if (!editor) return
|
|
134
|
+
const view = editor.ctx.get(editorViewCtx)
|
|
135
|
+
view.focus()
|
|
136
|
+
removeLinkMark(view)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div>
|
|
141
|
+
<div style={bar}>
|
|
142
|
+
<Btn active={formats.bold} title="Bold" onClick={() => editor?.action(callCommand(toggleStrongCommand.key))} style={{ fontWeight: 700 }}>B</Btn>
|
|
143
|
+
<Btn active={formats.italic} title="Italic" onClick={() => editor?.action(callCommand(toggleEmphasisCommand.key))} style={{ fontStyle: 'italic' }}>I</Btn>
|
|
144
|
+
<Btn active={formats.strikethrough} title="Strikethrough" onClick={() => editor?.action(callCommand(toggleStrikethroughCommand.key))} style={{ textDecoration: 'line-through' }}>S</Btn>
|
|
145
|
+
<span style={sep} />
|
|
146
|
+
<Btn active={formats.heading === 2} title="Heading 2" onClick={() => editor && doHeading(editor, 2)}>H2</Btn>
|
|
147
|
+
<Btn active={formats.heading === 3} title="Heading 3" onClick={() => editor && doHeading(editor, 3)}>H3</Btn>
|
|
148
|
+
<Btn active={formats.heading === 4} title="Heading 4" onClick={() => editor && doHeading(editor, 4)}>H4</Btn>
|
|
149
|
+
<span style={sep} />
|
|
150
|
+
<Btn active={formats.bulletList} title="Bullet list" onClick={() => editor && toggleList(editor, 'bullet')}>• List</Btn>
|
|
151
|
+
<Btn active={formats.orderedList} title="Numbered list" onClick={() => editor && toggleList(editor, 'ordered')}>1. List</Btn>
|
|
152
|
+
<Btn active={formats.blockquote} title="Quote" onClick={() => editor?.action(callCommand(wrapInBlockquoteCommand.key))}>❝</Btn>
|
|
153
|
+
<span style={sep} />
|
|
154
|
+
<Btn active={formats.link || linkOpen} title="Link" onClick={() => !disabled && setLinkOpen(v => !v)}>🔗 Link</Btn>
|
|
155
|
+
{media ? <Btn title="Insert image" onClick={() => !disabled && setMediaOpen(true)}>🖼 Image</Btn> : null}
|
|
156
|
+
{onInsertComponent
|
|
157
|
+
? (
|
|
158
|
+
<>
|
|
159
|
+
<span style={{ flex: 1 }} />
|
|
160
|
+
<Btn title="Insert component block" onClick={() => !disabled && onInsertComponent()} style={{ border: '1px solid #d4d4d8', color: '#3f3f46' }}>+ Component</Btn>
|
|
161
|
+
</>
|
|
162
|
+
)
|
|
163
|
+
: null}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{linkOpen
|
|
167
|
+
? (
|
|
168
|
+
<div style={{ padding: '0 6px' }}>
|
|
169
|
+
<LinkPopover
|
|
170
|
+
initialUrl={formats.linkHref ?? 'https://'}
|
|
171
|
+
isEdit={formats.link}
|
|
172
|
+
onApply={applyLink}
|
|
173
|
+
onRemove={formats.link ? removeLink : undefined}
|
|
174
|
+
onClose={() => setLinkOpen(false)}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
: null}
|
|
179
|
+
|
|
180
|
+
{mediaOpen && media
|
|
181
|
+
? (
|
|
182
|
+
<MediaLibrary
|
|
183
|
+
media={media}
|
|
184
|
+
context={mediaContext}
|
|
185
|
+
field={field}
|
|
186
|
+
accept="image/*"
|
|
187
|
+
onSelect={(url, alt) => {
|
|
188
|
+
setMediaOpen(false)
|
|
189
|
+
if (editor) insertImage(editor, url, alt ?? '')
|
|
190
|
+
}}
|
|
191
|
+
onClose={() => setMediaOpen(false)}
|
|
192
|
+
/>
|
|
193
|
+
)
|
|
194
|
+
: null}
|
|
195
|
+
</div>
|
|
196
|
+
)
|
|
197
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nuasite/cms-mdx-editor` — React MDX body editor (Milkdown) for Nua CMS
|
|
3
|
+
* collection entries. Round-trips component blocks (props, children, expression
|
|
4
|
+
* props, imports) through the editor; rich-text toolbar, nested WYSIWYG slot
|
|
5
|
+
* editing, and a media library for image props and prose images. String in / out.
|
|
6
|
+
*
|
|
7
|
+
* Mount `<MdxBodyEditor value onChange components={…} media={client} mediaContext={…} />`;
|
|
8
|
+
* feed `components` from `cmsClient.getComponents()` and `media` is the host's
|
|
9
|
+
* `CmsClient` (it satisfies `MediaSource`). Consumed by `@nuasite/collections-admin`
|
|
10
|
+
* and the webmaster collections tab.
|
|
11
|
+
*/
|
|
12
|
+
export { ComponentPicker, type ComponentPickerProps } from './component-picker'
|
|
13
|
+
export { FormatToolbar, type FormatToolbarProps, useFormatTracking } from './format-toolbar'
|
|
14
|
+
export { LinkPopover, type LinkPopoverProps } from './link-popover'
|
|
15
|
+
export { MediaLibrary, type MediaLibraryProps } from './media-library'
|
|
16
|
+
export { isMediaUnavailableError, type MediaContext, type MediaSource, type MediaUploadContext } from './media-source'
|
|
17
|
+
export { MdxBlockCard, type MdxBlockCardProps } from './mdx-block-card'
|
|
18
|
+
export { MdxBodyEditor, type MdxBodyEditorProps } from './mdx-body-editor'
|
|
19
|
+
export { type InsertMdxComponentPayload, MDX_EXPR_PREFIX } from './mdx-plugin'
|
|
20
|
+
export { SlotEditor } from './slot-editor'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small link editor popover used by the toolbar: type/edit a URL, apply, or remove
|
|
3
|
+
* an existing link. Self-contained inline styles. (The original's page-path
|
|
4
|
+
* autocomplete is intentionally dropped — the headless editor has no page manifest;
|
|
5
|
+
* a plain URL covers link parity.)
|
|
6
|
+
*/
|
|
7
|
+
import { useState } from 'react'
|
|
8
|
+
|
|
9
|
+
export interface LinkPopoverProps {
|
|
10
|
+
initialUrl: string
|
|
11
|
+
isEdit: boolean
|
|
12
|
+
onApply: (url: string) => void
|
|
13
|
+
onRemove?: () => void
|
|
14
|
+
onClose: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const wrap: React.CSSProperties = {
|
|
18
|
+
display: 'flex',
|
|
19
|
+
gap: 6,
|
|
20
|
+
alignItems: 'center',
|
|
21
|
+
padding: 6,
|
|
22
|
+
border: '1px solid #d4d4d8',
|
|
23
|
+
borderRadius: 6,
|
|
24
|
+
background: '#fff',
|
|
25
|
+
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
|
26
|
+
marginTop: 6,
|
|
27
|
+
}
|
|
28
|
+
const input: React.CSSProperties = { flex: 1, border: '1px solid #d4d4d8', borderRadius: 4, padding: '4px 8px', font: 'inherit', outline: 'none', minWidth: 0 }
|
|
29
|
+
const btn: React.CSSProperties = { border: '1px solid #d4d4d8', background: '#fff', borderRadius: 4, padding: '4px 8px', font: 'inherit', fontSize: 12, cursor: 'pointer', color: '#3f3f46', whiteSpace: 'nowrap' }
|
|
30
|
+
|
|
31
|
+
export function LinkPopover({ initialUrl, isEdit, onApply, onRemove, onClose }: LinkPopoverProps) {
|
|
32
|
+
const [url, setUrl] = useState(initialUrl)
|
|
33
|
+
|
|
34
|
+
const apply = () => {
|
|
35
|
+
const trimmed = url.trim()
|
|
36
|
+
if (trimmed !== '') onApply(trimmed)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div style={wrap} data-mdx-action="link" onMouseDown={e => e.stopPropagation()}>
|
|
41
|
+
<input
|
|
42
|
+
style={input}
|
|
43
|
+
autoFocus
|
|
44
|
+
placeholder="https://…"
|
|
45
|
+
value={url}
|
|
46
|
+
onChange={e => setUrl(e.target.value)}
|
|
47
|
+
onKeyDown={e => {
|
|
48
|
+
if (e.key === 'Enter') {
|
|
49
|
+
e.preventDefault()
|
|
50
|
+
apply()
|
|
51
|
+
}
|
|
52
|
+
if (e.key === 'Escape') onClose()
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
<button type="button" style={{ ...btn, background: '#2563eb', borderColor: '#2563eb', color: '#fff' }} onMouseDown={e => e.preventDefault()} onClick={apply}>
|
|
56
|
+
{isEdit ? 'Update' : 'Add'}
|
|
57
|
+
</button>
|
|
58
|
+
{isEdit && onRemove
|
|
59
|
+
? <button type="button" style={{ ...btn, color: '#dc2626' }} onMouseDown={e => e.preventDefault()} onClick={onRemove}>Remove</button>
|
|
60
|
+
: null}
|
|
61
|
+
<button type="button" style={btn} onMouseDown={e => e.preventDefault()} onClick={onClose}>Cancel</button>
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block-card view for an MDX component node (React port of the in-iframe preact
|
|
3
|
+
* `MdxBlockCard`). Renders a non-live card with feature parity to the original:
|
|
4
|
+
* - typed prop widgets (boolean / color / image / number / url / date / text);
|
|
5
|
+
* - `image` props open the media library to browse + upload (when `media` given);
|
|
6
|
+
* - the default slot (`children`) is a nested WYSIWYG editor (`SlotEditor`);
|
|
7
|
+
* - expression props (`prop={expr}`) stay read-only.
|
|
8
|
+
* Self-contained inline styles so it renders in any host without a stylesheet.
|
|
9
|
+
*/
|
|
10
|
+
import type { ComponentDefinition, ComponentProp } from '@nuasite/cms-types'
|
|
11
|
+
import { useMemo, useState } from 'react'
|
|
12
|
+
import { MediaLibrary } from './media-library'
|
|
13
|
+
import type { MediaContext, MediaSource } from './media-source'
|
|
14
|
+
import { MDX_EXPR_PREFIX } from './mdx-plugin'
|
|
15
|
+
import { SlotEditor } from './slot-editor'
|
|
16
|
+
|
|
17
|
+
export interface MdxBlockCardProps {
|
|
18
|
+
componentName: string
|
|
19
|
+
props: Record<string, string>
|
|
20
|
+
hasExpressions: boolean
|
|
21
|
+
slotContent: string
|
|
22
|
+
definition?: ComponentDefinition
|
|
23
|
+
/** Media source — enables image-prop browse/upload. Absent → image props are URL-only. */
|
|
24
|
+
media?: MediaSource
|
|
25
|
+
mediaContext?: MediaContext
|
|
26
|
+
onRemove: () => void
|
|
27
|
+
/** Set when the component has a default slot — edits the markdown children. */
|
|
28
|
+
onSlotContentChange?: (content: string) => void
|
|
29
|
+
/** Set when there are no expression props — static props are editable. */
|
|
30
|
+
onPropsChange?: (props: Record<string, string>) => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const HTML_INPUT_TYPES: Record<string, string> = {
|
|
34
|
+
number: 'number',
|
|
35
|
+
url: 'url',
|
|
36
|
+
date: 'date',
|
|
37
|
+
datetime: 'datetime-local',
|
|
38
|
+
time: 'time',
|
|
39
|
+
email: 'email',
|
|
40
|
+
tel: 'tel',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function looksLikeUrl(value: string): boolean {
|
|
44
|
+
return value !== '' && /^(https?:\/\/|\/|\.\/)/.test(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const card: React.CSSProperties = { border: '1px solid #d4d4d8', borderRadius: 8, background: '#fafafa', margin: '8px 0', fontSize: 13 }
|
|
48
|
+
const header: React.CSSProperties = {
|
|
49
|
+
display: 'flex',
|
|
50
|
+
alignItems: 'center',
|
|
51
|
+
justifyContent: 'space-between',
|
|
52
|
+
gap: 8,
|
|
53
|
+
padding: '6px 10px',
|
|
54
|
+
borderBottom: '1px solid #ececed',
|
|
55
|
+
background: '#f1f1f3',
|
|
56
|
+
borderTopLeftRadius: 8,
|
|
57
|
+
borderTopRightRadius: 8,
|
|
58
|
+
}
|
|
59
|
+
const nameStyle: React.CSSProperties = { fontWeight: 600, fontFamily: 'ui-monospace, monospace', color: '#3f3f46' }
|
|
60
|
+
const body: React.CSSProperties = { padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 8 }
|
|
61
|
+
const rowLabel: React.CSSProperties = { fontSize: 11, color: '#71717a', marginBottom: 2 }
|
|
62
|
+
const inputStyle: React.CSSProperties = { width: '100%', boxSizing: 'border-box', border: '1px solid #d4d4d8', borderRadius: 4, padding: '4px 6px', font: 'inherit', background: '#fff' }
|
|
63
|
+
const roInput: React.CSSProperties = { ...inputStyle, background: '#f4f4f5', color: '#71717a' }
|
|
64
|
+
const exprBadge: React.CSSProperties = { fontSize: 10, color: '#a16207', background: '#fef9c3', borderRadius: 3, padding: '0 4px', marginLeft: 6 }
|
|
65
|
+
const removeBtn: React.CSSProperties = { border: 'none', background: 'transparent', color: '#a1a1aa', cursor: 'pointer', fontSize: 16, lineHeight: 1, padding: 2 }
|
|
66
|
+
const browseBtn: React.CSSProperties = { border: '1px solid #d4d4d8', background: '#fff', borderRadius: 4, padding: '4px 8px', font: 'inherit', fontSize: 12, cursor: 'pointer', color: '#3f3f46', whiteSpace: 'nowrap' }
|
|
67
|
+
|
|
68
|
+
// ---- typed prop field ----
|
|
69
|
+
|
|
70
|
+
function PropField({ name, value, propType, editable, hasMedia, onChange, onBrowse }: {
|
|
71
|
+
name: string
|
|
72
|
+
value: string
|
|
73
|
+
propType: string
|
|
74
|
+
editable: boolean
|
|
75
|
+
hasMedia: boolean
|
|
76
|
+
onChange: (value: string) => void
|
|
77
|
+
onBrowse: () => void
|
|
78
|
+
}) {
|
|
79
|
+
if (propType === 'boolean') {
|
|
80
|
+
return (
|
|
81
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
82
|
+
<input type="checkbox" checked={value === 'true'} disabled={!editable} onChange={e => onChange(e.target.checked ? 'true' : 'false')} />
|
|
83
|
+
<span style={{ color: '#52525b' }}>{value === 'true' ? 'Yes' : 'No'}</span>
|
|
84
|
+
</label>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (propType === 'image') {
|
|
89
|
+
return (
|
|
90
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
91
|
+
{looksLikeUrl(value) ? <img src={value} alt="" style={{ maxHeight: 96, maxWidth: '100%', objectFit: 'contain', borderRadius: 4, alignSelf: 'flex-start' }} /> : null}
|
|
92
|
+
<div style={{ display: 'flex', gap: 6 }}>
|
|
93
|
+
<input
|
|
94
|
+
style={editable ? inputStyle : roInput}
|
|
95
|
+
value={value}
|
|
96
|
+
readOnly={!editable}
|
|
97
|
+
placeholder="Image URL or path"
|
|
98
|
+
onChange={editable ? e => onChange(e.target.value) : undefined}
|
|
99
|
+
/>
|
|
100
|
+
{editable && hasMedia ? <button type="button" style={browseBtn} data-mdx-action="props" onClick={onBrowse}>Browse</button> : null}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (propType === 'color') {
|
|
107
|
+
return (
|
|
108
|
+
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
109
|
+
<input type="color" value={value || '#000000'} disabled={!editable} onChange={e => onChange(e.target.value)} style={{ width: 30, height: 28, padding: 0, border: '1px solid #d4d4d8', borderRadius: 4, background: 'transparent', cursor: editable ? 'pointer' : 'default' }} />
|
|
110
|
+
<input style={editable ? inputStyle : roInput} value={value} readOnly={!editable} placeholder="#000000" onChange={editable ? e => onChange(e.target.value) : undefined} />
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const htmlType = HTML_INPUT_TYPES[propType] ?? 'text'
|
|
116
|
+
return (
|
|
117
|
+
<input
|
|
118
|
+
type={htmlType}
|
|
119
|
+
style={editable ? inputStyle : roInput}
|
|
120
|
+
value={value}
|
|
121
|
+
readOnly={!editable}
|
|
122
|
+
placeholder={`Enter ${name}…`}
|
|
123
|
+
onChange={editable ? e => onChange(e.target.value) : undefined}
|
|
124
|
+
/>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function MdxBlockCard({
|
|
129
|
+
componentName,
|
|
130
|
+
props,
|
|
131
|
+
hasExpressions,
|
|
132
|
+
slotContent,
|
|
133
|
+
definition,
|
|
134
|
+
media,
|
|
135
|
+
mediaContext,
|
|
136
|
+
onRemove,
|
|
137
|
+
onSlotContentChange,
|
|
138
|
+
onPropsChange,
|
|
139
|
+
}: MdxBlockCardProps) {
|
|
140
|
+
const hasDefaultSlot = definition?.slots?.includes('default') ?? Boolean(onSlotContentChange)
|
|
141
|
+
const entries = Object.entries(props)
|
|
142
|
+
const [browseField, setBrowseField] = useState<string | null>(null)
|
|
143
|
+
|
|
144
|
+
const propTypes = useMemo(() => {
|
|
145
|
+
const map = new Map<string, ComponentProp>()
|
|
146
|
+
for (const p of definition?.props ?? []) map.set(p.name, p)
|
|
147
|
+
return map
|
|
148
|
+
}, [definition])
|
|
149
|
+
|
|
150
|
+
const setProp = (name: string, value: string) => {
|
|
151
|
+
if (!onPropsChange) return
|
|
152
|
+
onPropsChange({ ...props, [name]: value })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div style={card} data-cms-ui="" contentEditable={false}>
|
|
157
|
+
<div style={header}>
|
|
158
|
+
<span style={nameStyle}>
|
|
159
|
+
{`<${componentName} />`}
|
|
160
|
+
{hasExpressions ? <span style={exprBadge}>expr · read-only</span> : null}
|
|
161
|
+
</span>
|
|
162
|
+
<button type="button" style={removeBtn} title="Remove block" data-mdx-action="remove" onClick={onRemove}>×</button>
|
|
163
|
+
</div>
|
|
164
|
+
<div style={body}>
|
|
165
|
+
{hasDefaultSlot
|
|
166
|
+
? (
|
|
167
|
+
<div data-mdx-action="children">
|
|
168
|
+
<div style={rowLabel}>Content</div>
|
|
169
|
+
{onSlotContentChange
|
|
170
|
+
? <SlotEditor value={slotContent} onChange={onSlotContentChange} />
|
|
171
|
+
: <div style={{ ...roInput, whiteSpace: 'pre-wrap', minHeight: 40 }}>{slotContent}</div>}
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
: null}
|
|
175
|
+
|
|
176
|
+
{entries.length === 0 && !hasDefaultSlot ? <div style={{ color: '#a1a1aa', fontSize: 12 }}>No props</div> : null}
|
|
177
|
+
|
|
178
|
+
{entries.map(([name, value]) => {
|
|
179
|
+
const isExpr = value.startsWith(MDX_EXPR_PREFIX)
|
|
180
|
+
const editable = Boolean(onPropsChange) && !isExpr
|
|
181
|
+
const def = propTypes.get(name)
|
|
182
|
+
const propType = (def?.type ?? '').toLowerCase()
|
|
183
|
+
return (
|
|
184
|
+
// A plain div (not a <label>): the boolean field renders its own <label>
|
|
185
|
+
// around the checkbox, and nested <label>s double-fire the toggle click.
|
|
186
|
+
<div key={name} style={{ display: 'block' }} data-mdx-action="props">
|
|
187
|
+
<div style={rowLabel}>
|
|
188
|
+
{name}
|
|
189
|
+
{def?.required ? <span style={{ color: '#dc2626' }}> *</span> : null}
|
|
190
|
+
{isExpr ? <span style={exprBadge}>expression</span> : null}
|
|
191
|
+
</div>
|
|
192
|
+
{isExpr
|
|
193
|
+
? <input style={roInput} value={`{${value.slice(MDX_EXPR_PREFIX.length)}}`} readOnly />
|
|
194
|
+
: (
|
|
195
|
+
<PropField
|
|
196
|
+
name={name}
|
|
197
|
+
value={value}
|
|
198
|
+
propType={propType}
|
|
199
|
+
editable={editable}
|
|
200
|
+
hasMedia={Boolean(media)}
|
|
201
|
+
onChange={v => setProp(name, v)}
|
|
202
|
+
onBrowse={() => setBrowseField(name)}
|
|
203
|
+
/>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
)
|
|
207
|
+
})}
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{browseField !== null && media
|
|
211
|
+
? (
|
|
212
|
+
<MediaLibrary
|
|
213
|
+
media={media}
|
|
214
|
+
context={mediaContext}
|
|
215
|
+
field={browseField}
|
|
216
|
+
accept="image/*"
|
|
217
|
+
onSelect={(url) => {
|
|
218
|
+
setProp(browseField, url)
|
|
219
|
+
setBrowseField(null)
|
|
220
|
+
}}
|
|
221
|
+
onClose={() => setBrowseField(null)}
|
|
222
|
+
/>
|
|
223
|
+
)
|
|
224
|
+
: null}
|
|
225
|
+
</div>
|
|
226
|
+
)
|
|
227
|
+
}
|