@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,377 @@
1
+ /**
2
+ * Media library modal — browse folders, filter/search, upload (button + drag-drop),
3
+ * create folders, and select an asset. React port of `@nuasite/cms`'s in-iframe
4
+ * `media-library.tsx`, driven by an injected `MediaSource` (the host's CmsClient)
5
+ * instead of the old signals/markdown-api globals. Self-contained light-theme
6
+ * inline styles so it renders in any host.
7
+ *
8
+ * Opened from the body/slot toolbar (insert an image into prose) and from `image`
9
+ * props in the block card. Degrades gracefully when the sidecar has no media
10
+ * adapter (`501`): the gallery shows an "unavailable" hint and the manual-URL path
11
+ * stays usable upstream.
12
+ */
13
+ import type { MediaFolderItem, MediaItem, MediaTypeFilter } from '@nuasite/cms-types'
14
+ import { useEffect, useMemo, useRef, useState } from 'react'
15
+ import { isMediaUnavailableError, type MediaContext, type MediaSource } from './media-source'
16
+
17
+ const VECTOR_TYPES = new Set(['image/svg+xml', 'image/x-icon'])
18
+
19
+ const TYPE_FILTERS: Array<{ value: MediaTypeFilter; label: string }> = [
20
+ { value: 'all', label: 'All' },
21
+ { value: 'photo', label: 'Photos' },
22
+ { value: 'graphic', label: 'Graphics' },
23
+ { value: 'document', label: 'Documents' },
24
+ ]
25
+
26
+ function matchesTypeFilter(contentType: string, filter: MediaTypeFilter): boolean {
27
+ if (filter === 'all') return true
28
+ if (filter === 'photo') return contentType.startsWith('image/') && !VECTOR_TYPES.has(contentType)
29
+ if (filter === 'graphic') return VECTOR_TYPES.has(contentType)
30
+ if (filter === 'document') return contentType === 'application/pdf'
31
+ return true
32
+ }
33
+
34
+ export interface MediaLibraryProps {
35
+ media: MediaSource
36
+ context?: MediaContext
37
+ /** Field name uploads are filed under (e.g. the image prop name, or 'body'). */
38
+ field?: string
39
+ /** File-input `accept`. Defaults to images + PDF. */
40
+ accept?: string
41
+ onSelect: (url: string, alt?: string) => void
42
+ onClose: () => void
43
+ }
44
+
45
+ // ---- inline styles (light theme) ----
46
+ const backdrop: React.CSSProperties = {
47
+ position: 'fixed',
48
+ inset: 0,
49
+ background: 'rgba(0,0,0,0.45)',
50
+ display: 'flex',
51
+ alignItems: 'center',
52
+ justifyContent: 'center',
53
+ zIndex: 1100,
54
+ }
55
+ const modal: React.CSSProperties = {
56
+ width: 'min(760px, 94vw)',
57
+ maxHeight: '82vh',
58
+ display: 'flex',
59
+ flexDirection: 'column',
60
+ background: '#fff',
61
+ border: '1px solid #d4d4d8',
62
+ borderRadius: 12,
63
+ boxShadow: '0 16px 48px rgba(0,0,0,0.28)',
64
+ overflow: 'hidden',
65
+ fontSize: 13,
66
+ color: '#27272a',
67
+ }
68
+ const headerBar: React.CSSProperties = {
69
+ display: 'flex',
70
+ alignItems: 'center',
71
+ justifyContent: 'space-between',
72
+ padding: '12px 16px',
73
+ borderBottom: '1px solid #ececed',
74
+ }
75
+ const controls: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 16px', borderBottom: '1px solid #ececed' }
76
+ const row: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 8 }
77
+ const input: React.CSSProperties = {
78
+ flex: 1,
79
+ border: '1px solid #d4d4d8',
80
+ borderRadius: 6,
81
+ padding: '6px 10px',
82
+ font: 'inherit',
83
+ outline: 'none',
84
+ }
85
+ const btn: React.CSSProperties = {
86
+ border: '1px solid #d4d4d8',
87
+ background: '#fff',
88
+ borderRadius: 6,
89
+ padding: '6px 12px',
90
+ font: 'inherit',
91
+ cursor: 'pointer',
92
+ color: '#3f3f46',
93
+ whiteSpace: 'nowrap',
94
+ }
95
+ const primaryBtn: React.CSSProperties = { ...btn, background: '#2563eb', borderColor: '#2563eb', color: '#fff' }
96
+ const grid: React.CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, padding: 16, overflowY: 'auto' }
97
+ const tile: React.CSSProperties = {
98
+ position: 'relative',
99
+ aspectRatio: '1 / 1',
100
+ border: '1px solid #e4e4e7',
101
+ borderRadius: 8,
102
+ overflow: 'hidden',
103
+ cursor: 'pointer',
104
+ background: '#fafafa',
105
+ padding: 0,
106
+ display: 'flex',
107
+ flexDirection: 'column',
108
+ alignItems: 'center',
109
+ justifyContent: 'center',
110
+ gap: 6,
111
+ }
112
+ const tileCaption: React.CSSProperties = {
113
+ position: 'absolute',
114
+ left: 0,
115
+ right: 0,
116
+ bottom: 0,
117
+ padding: '4px 6px',
118
+ fontSize: 11,
119
+ color: '#fff',
120
+ background: 'linear-gradient(to top, rgba(0,0,0,0.65), transparent)',
121
+ textAlign: 'left',
122
+ whiteSpace: 'nowrap',
123
+ overflow: 'hidden',
124
+ textOverflow: 'ellipsis',
125
+ }
126
+
127
+ export function MediaLibrary({ media, context, field, accept = 'image/*,application/pdf', onSelect, onClose }: MediaLibraryProps) {
128
+ const [items, setItems] = useState<MediaItem[]>([])
129
+ const [folders, setFolders] = useState<MediaFolderItem[]>([])
130
+ const [currentFolder, setCurrentFolder] = useState('')
131
+ const [searchQuery, setSearchQuery] = useState('')
132
+ const [typeFilter, setTypeFilter] = useState<MediaTypeFilter>('all')
133
+ const [loading, setLoading] = useState(false)
134
+ const [unavailable, setUnavailable] = useState(false)
135
+ const [uploading, setUploading] = useState(false)
136
+ const [errorMsg, setErrorMsg] = useState<string | null>(null)
137
+ const [showNewFolder, setShowNewFolder] = useState(false)
138
+ const [newFolderName, setNewFolderName] = useState('')
139
+ const fileInputRef = useRef<HTMLInputElement>(null)
140
+
141
+ const loadFolder = useMemo(
142
+ () => async (folder: string) => {
143
+ setLoading(true)
144
+ setErrorMsg(null)
145
+ try {
146
+ const result = await media.listMedia({ folder: folder || undefined })
147
+ setItems(result.items)
148
+ setFolders(result.folders)
149
+ } catch (err: unknown) {
150
+ if (isMediaUnavailableError(err)) {
151
+ setUnavailable(true)
152
+ } else {
153
+ setErrorMsg(err instanceof Error ? err.message : 'Failed to load media')
154
+ }
155
+ } finally {
156
+ setLoading(false)
157
+ }
158
+ },
159
+ [media],
160
+ )
161
+
162
+ useEffect(() => {
163
+ void loadFolder(currentFolder)
164
+ }, [loadFolder, currentFolder])
165
+
166
+ const navigateToFolder = (folder: string) => {
167
+ setSearchQuery('')
168
+ setCurrentFolder(folder)
169
+ }
170
+
171
+ const handleUpload = async (file: File) => {
172
+ setUploading(true)
173
+ setErrorMsg(null)
174
+ try {
175
+ const result = await media.uploadMedia(file, { ...context, field, folder: currentFolder || undefined })
176
+ if (result.success && result.url) {
177
+ // Astro image() uploads return entry-relative paths (`./foo.jpg`) that live
178
+ // under src/content, not the media adapter — select directly, don't list.
179
+ if (result.url.startsWith('./')) {
180
+ onSelect(result.url, result.annotation ?? '')
181
+ return
182
+ }
183
+ const newItem: MediaItem = {
184
+ id: result.id ?? result.url,
185
+ url: result.url,
186
+ filename: result.filename ?? file.name,
187
+ annotation: result.annotation,
188
+ contentType: file.type || 'application/octet-stream',
189
+ folder: currentFolder || undefined,
190
+ }
191
+ setItems(prev => [newItem, ...prev])
192
+ } else {
193
+ setErrorMsg(result.error ?? 'Upload failed')
194
+ }
195
+ } catch (err: unknown) {
196
+ if (isMediaUnavailableError(err)) setUnavailable(true)
197
+ else setErrorMsg(err instanceof Error ? err.message : 'Upload failed')
198
+ } finally {
199
+ setUploading(false)
200
+ }
201
+ }
202
+
203
+ const handleCreateFolder = async () => {
204
+ const name = newFolderName.trim()
205
+ if (!name || !media.createFolder) return
206
+ if (/[/\\:*?"<>|]/.test(name)) {
207
+ setErrorMsg('Invalid folder name')
208
+ return
209
+ }
210
+ const folderPath = currentFolder ? `${currentFolder}/${name}` : name
211
+ try {
212
+ const result = await media.createFolder(folderPath)
213
+ if (result.success) {
214
+ setFolders(prev => [...prev, { name, path: folderPath }].sort((a, b) => a.name.localeCompare(b.name)))
215
+ } else {
216
+ setErrorMsg(result.error ?? 'Failed to create folder')
217
+ }
218
+ } catch (err: unknown) {
219
+ setErrorMsg(err instanceof Error ? err.message : 'Failed to create folder')
220
+ }
221
+ setNewFolderName('')
222
+ setShowNewFolder(false)
223
+ }
224
+
225
+ const filteredItems = useMemo(() => {
226
+ const byType = typeFilter === 'all' ? items : items.filter(i => matchesTypeFilter(i.contentType, typeFilter))
227
+ const q = searchQuery.trim().toLowerCase()
228
+ return q === '' ? byType : byType.filter(i => i.filename.toLowerCase().includes(q))
229
+ }, [items, typeFilter, searchQuery])
230
+
231
+ const breadcrumbs = useMemo(() => {
232
+ if (!currentFolder) return []
233
+ const parts = currentFolder.split('/')
234
+ return parts.map((name, i) => ({ name, path: parts.slice(0, i + 1).join('/') }))
235
+ }, [currentFolder])
236
+
237
+ const showFolders = !searchQuery && typeFilter === 'all'
238
+
239
+ return (
240
+ <div style={backdrop} onMouseDown={onClose} data-cms-ui="">
241
+ <div
242
+ style={modal}
243
+ onMouseDown={e => e.stopPropagation()}
244
+ onDrop={e => {
245
+ e.preventDefault()
246
+ const file = e.dataTransfer.files[0]
247
+ if (file) void handleUpload(file)
248
+ }}
249
+ onDragOver={e => e.preventDefault()}
250
+ >
251
+ <div style={headerBar}>
252
+ <strong style={{ fontSize: 15 }}>Media Library</strong>
253
+ <button type="button" style={{ ...btn, border: 'none', fontSize: 18, lineHeight: 1, padding: 2 }} onClick={onClose} aria-label="Close">×</button>
254
+ </div>
255
+
256
+ <div style={controls}>
257
+ {breadcrumbs.length > 0
258
+ ? (
259
+ <div style={{ ...row, gap: 4, fontSize: 12, color: '#71717a' }}>
260
+ <button type="button" style={{ ...btn, border: 'none', padding: '0 2px' }} onClick={() => navigateToFolder('')}>root</button>
261
+ {breadcrumbs.map((c, i) => (
262
+ <span key={c.path} style={row}>
263
+ <span>/</span>
264
+ {i === breadcrumbs.length - 1
265
+ ? <span style={{ color: '#27272a', fontWeight: 600 }}>{c.name}</span>
266
+ : <button type="button" style={{ ...btn, border: 'none', padding: '0 2px' }} onClick={() => navigateToFolder(c.path)}>{c.name}</button>}
267
+ </span>
268
+ ))}
269
+ </div>
270
+ )
271
+ : null}
272
+
273
+ <div style={row}>
274
+ <input style={input} placeholder="Search files…" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
275
+ {media.createFolder ? <button type="button" style={btn} onClick={() => setShowNewFolder(v => !v)}>New folder</button> : null}
276
+ <button type="button" style={primaryBtn} disabled={unavailable || uploading} onClick={() => fileInputRef.current?.click()}>
277
+ {uploading ? 'Uploading…' : 'Upload'}
278
+ </button>
279
+ <input
280
+ ref={fileInputRef}
281
+ type="file"
282
+ accept={accept}
283
+ style={{ display: 'none' }}
284
+ onChange={e => {
285
+ const file = e.target.files?.[0]
286
+ if (file) void handleUpload(file)
287
+ e.target.value = ''
288
+ }}
289
+ />
290
+ </div>
291
+
292
+ <div style={{ ...row, gap: 4 }}>
293
+ {TYPE_FILTERS.map(f => (
294
+ <button
295
+ key={f.value}
296
+ type="button"
297
+ style={{
298
+ ...btn,
299
+ padding: '3px 10px',
300
+ fontSize: 12,
301
+ ...(typeFilter === f.value ? { background: '#2563eb', borderColor: '#2563eb', color: '#fff' } : {}),
302
+ }}
303
+ onClick={() => setTypeFilter(f.value)}
304
+ >
305
+ {f.label}
306
+ </button>
307
+ ))}
308
+ </div>
309
+
310
+ {showNewFolder && media.createFolder
311
+ ? (
312
+ <div style={row}>
313
+ <input
314
+ style={input}
315
+ autoFocus
316
+ placeholder="Folder name…"
317
+ value={newFolderName}
318
+ onChange={e => setNewFolderName(e.target.value)}
319
+ onKeyDown={e => {
320
+ if (e.key === 'Enter') void handleCreateFolder()
321
+ if (e.key === 'Escape') {
322
+ setShowNewFolder(false)
323
+ setNewFolderName('')
324
+ }
325
+ }}
326
+ />
327
+ <button type="button" style={primaryBtn} onClick={() => void handleCreateFolder()}>Create</button>
328
+ </div>
329
+ )
330
+ : null}
331
+
332
+ {unavailable ? <div style={{ fontSize: 12, color: '#a16207' }}>Media uploads are not configured for this project.</div> : null}
333
+ {errorMsg ? <div style={{ fontSize: 12, color: '#dc2626' }}>{errorMsg}</div> : null}
334
+ </div>
335
+
336
+ {loading
337
+ ? <div style={{ padding: 32, textAlign: 'center', color: '#a1a1aa' }}>Loading…</div>
338
+ : folders.length === 0 && filteredItems.length === 0
339
+ ? (
340
+ <div style={{ padding: 32, textAlign: 'center', color: '#a1a1aa' }}>
341
+ {searchQuery || typeFilter !== 'all' ? 'No matching files.' : 'No files yet. Upload one to get started.'}
342
+ </div>
343
+ )
344
+ : (
345
+ <div style={grid}>
346
+ {showFolders
347
+ ? folders.map(folder => (
348
+ <button key={folder.path} type="button" style={tile} onClick={() => navigateToFolder(folder.path)} title={folder.name}>
349
+ <span style={{ fontSize: 30 }}>📁</span>
350
+ <span style={{ fontSize: 12, color: '#52525b', maxWidth: '90%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{folder.name}</span>
351
+ </button>
352
+ ))
353
+ : null}
354
+ {filteredItems.map(item => (
355
+ <button
356
+ key={item.id}
357
+ type="button"
358
+ style={tile}
359
+ title={item.filename}
360
+ onClick={() => onSelect(item.url, item.annotation || item.filename || 'Image')}
361
+ >
362
+ {item.contentType.startsWith('image/')
363
+ ? <img src={item.url} alt={item.annotation || item.filename} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
364
+ : <span style={{ fontSize: 30 }}>{item.contentType === 'application/pdf' ? '📄' : '📎'}</span>}
365
+ <span style={tileCaption}>{item.filename}</span>
366
+ </button>
367
+ ))}
368
+ </div>
369
+ )}
370
+
371
+ <div style={{ padding: '10px 16px', borderTop: '1px solid #ececed', fontSize: 12, color: '#a1a1aa', textAlign: 'center' }}>
372
+ Drag and drop a file here to upload{currentFolder ? ` to ${currentFolder}` : ''}.
373
+ </div>
374
+ </div>
375
+ </div>
376
+ )
377
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Media capability the editor consumes to browse/upload assets (the gallery in
3
+ * `media-library.tsx`, image props in the block card, prose image insertion).
4
+ *
5
+ * It is a *structural* subset of `@nuasite/cms-client`'s `CmsClient` — the host
6
+ * passes its `client` straight in (`media={client}`) without this package taking a
7
+ * dependency on the SDK. `createFolder` is optional so an older client (or a
8
+ * sidecar with a folder-less adapter) still satisfies it; the gallery hides the
9
+ * "new folder" affordance when it is absent.
10
+ */
11
+ import type { MediaListResult, MediaUploadResult } from '@nuasite/cms-types'
12
+
13
+ /** Where an upload is filed — mirrors the SDK's `MediaContext`. */
14
+ export interface MediaUploadContext {
15
+ collection?: string
16
+ entry?: string
17
+ field?: string
18
+ /** Subfolder under the media root. */
19
+ folder?: string
20
+ }
21
+
22
+ export interface MediaSource {
23
+ listMedia(options?: { folder?: string; cursor?: string; limit?: number }): Promise<MediaListResult>
24
+ uploadMedia(file: File, context?: MediaUploadContext): Promise<MediaUploadResult>
25
+ createFolder?(folder: string): Promise<{ success: boolean; error?: string }>
26
+ }
27
+
28
+ /** Editor-level upload context (the field name is added per call-site). */
29
+ export interface MediaContext {
30
+ collection?: string
31
+ entry?: string
32
+ }
33
+
34
+ /**
35
+ * Whether a thrown error means "media is not wired" — the deployed sidecar may
36
+ * answer media routes with `501 unsupported`. Duck-typed (no dependency on the
37
+ * SDK's `CmsClientError`) so the gallery degrades to a manual-URL hint instead of
38
+ * a hard error.
39
+ */
40
+ export function isMediaUnavailableError(err: unknown): boolean {
41
+ if (typeof err !== 'object' || err === null) return false
42
+ if ('status' in err && err.status === 501) return true
43
+ if ('code' in err && err.code === 'unsupported') return true
44
+ return false
45
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Selection/format helpers over a ProseMirror `EditorView` — used by the toolbar
3
+ * (`format-toolbar.tsx`) and the nested slot editor to reflect and toggle inline/
4
+ * block formatting. Framework-agnostic port of `@nuasite/cms`'s `milkdown-utils.ts`
5
+ * (it never depended on preact — it operates on the raw view).
6
+ */
7
+ import { type Editor, editorViewCtx } from '@milkdown/core'
8
+ import type { EditorView } from '@milkdown/prose/view'
9
+
10
+ export interface ActiveFormats {
11
+ bold: boolean
12
+ italic: boolean
13
+ strikethrough: boolean
14
+ link: boolean
15
+ linkHref: string | null
16
+ bulletList: boolean
17
+ orderedList: boolean
18
+ blockquote: boolean
19
+ heading: number | null
20
+ }
21
+
22
+ export const defaultActiveFormats: ActiveFormats = {
23
+ bold: false,
24
+ italic: false,
25
+ strikethrough: false,
26
+ link: false,
27
+ linkHref: null,
28
+ bulletList: false,
29
+ orderedList: false,
30
+ blockquote: false,
31
+ heading: null,
32
+ }
33
+
34
+ /** Detect active inline/block formats at the current selection. */
35
+ export function getActiveFormats(view: EditorView): ActiveFormats {
36
+ const { state } = view
37
+ const { $from, from, to } = state.selection
38
+
39
+ let bold = false
40
+ let italic = false
41
+ let strikethrough = false
42
+ let link = false
43
+ let linkHref: string | null = null
44
+
45
+ const marks = state.storedMarks || $from.marks()
46
+ for (const mark of marks) {
47
+ if (mark.type.name === 'strong') bold = true
48
+ if (mark.type.name === 'emphasis') italic = true
49
+ if (mark.type.name === 'strike_through') strikethrough = true
50
+ if (mark.type.name === 'link') {
51
+ link = true
52
+ linkHref = typeof mark.attrs.href === 'string' ? mark.attrs.href : null
53
+ }
54
+ }
55
+
56
+ if (from !== to) {
57
+ state.doc.nodesBetween(from, to, (node) => {
58
+ for (const mark of node.marks) {
59
+ if (mark.type.name === 'strong') bold = true
60
+ if (mark.type.name === 'emphasis') italic = true
61
+ if (mark.type.name === 'strike_through') strikethrough = true
62
+ if (mark.type.name === 'link') {
63
+ link = true
64
+ linkHref = typeof mark.attrs.href === 'string' ? mark.attrs.href : null
65
+ }
66
+ }
67
+ })
68
+ }
69
+
70
+ let bulletList = false
71
+ let orderedList = false
72
+ let blockquote = false
73
+ let heading: number | null = null
74
+
75
+ for (let depth = $from.depth; depth > 0; depth--) {
76
+ const node = $from.node(depth)
77
+ if (node.type.name === 'bullet_list') bulletList = true
78
+ if (node.type.name === 'ordered_list') orderedList = true
79
+ if (node.type.name === 'blockquote') blockquote = true
80
+ }
81
+
82
+ if ($from.parent.type.name === 'heading') {
83
+ const level = $from.parent.attrs.level
84
+ heading = typeof level === 'number' ? level : null
85
+ }
86
+
87
+ return { bold, italic, strikethrough, link, linkHref, bulletList, orderedList, blockquote, heading }
88
+ }
89
+
90
+ /** Whether the current selection is inside a list of the given node-type name. */
91
+ export function isInListType(view: EditorView, listType: string): boolean {
92
+ const { $from } = view.state.selection
93
+ for (let depth = $from.depth; depth > 0; depth--) {
94
+ if ($from.node(depth).type.name === listType) return true
95
+ }
96
+ return false
97
+ }
98
+
99
+ /**
100
+ * Toggle a heading level at the current selection. A heading already at `level`
101
+ * is converted back to a paragraph.
102
+ */
103
+ export function toggleHeading(view: EditorView, level: number): void {
104
+ const { state } = view
105
+ const headingType = state.schema.nodes.heading
106
+ const paragraphType = state.schema.nodes.paragraph
107
+ if (!headingType || !paragraphType) return
108
+
109
+ const { $from } = state.selection
110
+ const isCurrentHeading = $from.parent.type.name === 'heading' && $from.parent.attrs.level === level
111
+ const targetType = isCurrentHeading ? paragraphType : headingType
112
+ const attrs = isCurrentHeading ? undefined : { level }
113
+
114
+ const blockFrom = $from.before($from.depth)
115
+ const blockTo = state.selection.$to.after(state.selection.$to.depth)
116
+ view.dispatch(state.tr.setBlockType(blockFrom, blockTo, targetType, attrs))
117
+ view.focus()
118
+ }
119
+
120
+ /** Remove the link mark around the current cursor/selection. */
121
+ export function removeLinkMark(view: EditorView): void {
122
+ const { state } = view
123
+ const { from, to } = state.selection
124
+ const linkType = state.schema.marks.link
125
+ if (!linkType) return
126
+ let linkFrom = from
127
+ let linkTo = to
128
+ state.doc.nodesBetween(from, from === to ? to + 1 : to, (node, pos) => {
129
+ if (linkType.isInSet(node.marks)) {
130
+ linkFrom = pos
131
+ linkTo = pos + node.nodeSize
132
+ return false
133
+ }
134
+ })
135
+ view.dispatch(state.tr.removeMark(linkFrom, linkTo, linkType))
136
+ }
137
+
138
+ function formatsEqual(a: ActiveFormats, b: ActiveFormats): boolean {
139
+ return a.bold === b.bold
140
+ && a.italic === b.italic
141
+ && a.strikethrough === b.strikethrough
142
+ && a.link === b.link
143
+ && a.linkHref === b.linkHref
144
+ && a.bulletList === b.bulletList
145
+ && a.orderedList === b.orderedList
146
+ && a.blockquote === b.blockquote
147
+ && a.heading === b.heading
148
+ }
149
+
150
+ /**
151
+ * Wrap the view's `dispatch` to track active formats (rAF-debounced), firing the
152
+ * callback only when they change. Returns a cleanup that cancels a pending frame.
153
+ */
154
+ export function setupFormatTracking(editor: Editor, callback: (formats: ActiveFormats) => void): () => void {
155
+ let formatRaf = 0
156
+ let lastFormats: ActiveFormats = defaultActiveFormats
157
+
158
+ const update = () => {
159
+ const view = editor.ctx.get(editorViewCtx)
160
+ const formats = getActiveFormats(view)
161
+ if (!formatsEqual(formats, lastFormats)) {
162
+ lastFormats = formats
163
+ callback(formats)
164
+ }
165
+ }
166
+
167
+ const view = editor.ctx.get(editorViewCtx)
168
+ const origDispatch = view.dispatch.bind(view)
169
+ view.dispatch = (tr) => {
170
+ origDispatch(tr)
171
+ if (tr.selectionSet || tr.docChanged) {
172
+ cancelAnimationFrame(formatRaf)
173
+ formatRaf = requestAnimationFrame(update)
174
+ }
175
+ }
176
+
177
+ update()
178
+
179
+ return () => {
180
+ cancelAnimationFrame(formatRaf)
181
+ }
182
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Nested WYSIWYG editor for a component block's default-slot markdown (`children`).
3
+ * A self-contained mini Milkdown (commonmark + gfm) with the shared formatting
4
+ * toolbar — the React port of the original in-iframe `MiniMilkdownEditor`. Replaces
5
+ * the plain textarea so slot content is edited richly and still round-trips as
6
+ * markdown text stored on the node's `children` attr.
7
+ *
8
+ * Emits `onChange` on blur (not per keystroke) so typing never triggers a node-view
9
+ * re-render that would clobber the focused editor.
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 { replaceAll } from '@milkdown/utils'
16
+ import { useEffect, useRef, useState } from 'react'
17
+ import { FormatToolbar } from './format-toolbar'
18
+
19
+ const wrapper: React.CSSProperties = { border: '1px solid #e4e4e7', borderRadius: 6, background: '#fff' }
20
+ const host: React.CSSProperties = { padding: '6px 10px', minHeight: 60, fontSize: 13, lineHeight: 1.5, outline: 'none' }
21
+
22
+ export function SlotEditor({ value, onChange }: { value: string; onChange: (markdown: string) => void }) {
23
+ const hostRef = useRef<HTMLDivElement>(null)
24
+ const editorRef = useRef<Editor | null>(null)
25
+ const [editor, setEditor] = useState<Editor | null>(null)
26
+ const latest = useRef(value)
27
+ const focused = useRef(false)
28
+ const onChangeRef = useRef(onChange)
29
+ onChangeRef.current = onChange
30
+
31
+ useEffect(() => {
32
+ const el = hostRef.current
33
+ if (!el) return
34
+ let destroyed = false
35
+
36
+ const init = async () => {
37
+ const ed = await Editor.make()
38
+ .config((ctx) => {
39
+ ctx.set(rootCtx, el)
40
+ ctx.set(defaultValueCtx, latest.current)
41
+ ctx.get(listenerCtx).markdownUpdated((_, md) => {
42
+ latest.current = md
43
+ })
44
+ })
45
+ .use(commonmark)
46
+ .use(gfm)
47
+ .use(listener)
48
+ .create()
49
+
50
+ if (destroyed) {
51
+ ed.destroy()
52
+ return
53
+ }
54
+ editorRef.current = ed
55
+ setEditor(ed)
56
+ }
57
+ void init()
58
+
59
+ return () => {
60
+ destroyed = true
61
+ editorRef.current?.destroy()
62
+ editorRef.current = null
63
+ setEditor(null)
64
+ }
65
+ }, [])
66
+
67
+ // Adopt external value changes (node re-render with different children) while
68
+ // the user isn't actively typing.
69
+ useEffect(() => {
70
+ if (!editorRef.current || focused.current) return
71
+ if (value === latest.current) return
72
+ editorRef.current.action(replaceAll(value))
73
+ latest.current = value
74
+ }, [value])
75
+
76
+ return (
77
+ <div style={wrapper}>
78
+ <FormatToolbar editor={editor} />
79
+ <div
80
+ ref={hostRef}
81
+ style={host}
82
+ onFocusCapture={() => {
83
+ focused.current = true
84
+ }}
85
+ onBlurCapture={() => {
86
+ focused.current = false
87
+ if (latest.current !== value) onChangeRef.current(latest.current)
88
+ }}
89
+ />
90
+ </div>
91
+ )
92
+ }