@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.
Files changed (39) hide show
  1. package/dist/types/component-picker.d.ts +15 -0
  2. package/dist/types/component-picker.d.ts.map +1 -0
  3. package/dist/types/format-toolbar.d.ts +22 -0
  4. package/dist/types/format-toolbar.d.ts.map +1 -0
  5. package/dist/types/index.d.ts +21 -0
  6. package/dist/types/index.d.ts.map +1 -0
  7. package/dist/types/link-popover.d.ts +9 -0
  8. package/dist/types/link-popover.d.ts.map +1 -0
  9. package/dist/types/mdx-block-card.d.ts +28 -0
  10. package/dist/types/mdx-block-card.d.ts.map +1 -0
  11. package/dist/types/mdx-body-editor.d.ts +17 -0
  12. package/dist/types/mdx-body-editor.d.ts.map +1 -0
  13. package/dist/types/mdx-plugin.d.ts +16 -0
  14. package/dist/types/mdx-plugin.d.ts.map +1 -0
  15. package/dist/types/mdx-view.d.ts +20 -0
  16. package/dist/types/mdx-view.d.ts.map +1 -0
  17. package/dist/types/media-library.d.ts +13 -0
  18. package/dist/types/media-library.d.ts.map +1 -0
  19. package/dist/types/media-source.d.ts +44 -0
  20. package/dist/types/media-source.d.ts.map +1 -0
  21. package/dist/types/milkdown-utils.d.ts +37 -0
  22. package/dist/types/milkdown-utils.d.ts.map +1 -0
  23. package/dist/types/slot-editor.d.ts +5 -0
  24. package/dist/types/slot-editor.d.ts.map +1 -0
  25. package/dist/types/tsconfig.tsbuildinfo +1 -0
  26. package/package.json +63 -0
  27. package/src/component-picker.tsx +82 -0
  28. package/src/format-toolbar.tsx +197 -0
  29. package/src/index.ts +20 -0
  30. package/src/link-popover.tsx +64 -0
  31. package/src/mdx-block-card.tsx +227 -0
  32. package/src/mdx-body-editor.tsx +146 -0
  33. package/src/mdx-plugin.ts +270 -0
  34. package/src/mdx-view.tsx +116 -0
  35. package/src/media-library.tsx +377 -0
  36. package/src/media-source.ts +45 -0
  37. package/src/milkdown-utils.ts +182 -0
  38. package/src/slot-editor.tsx +92 -0
  39. 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
+ }