@nuasite/cms 0.18.0 → 0.19.0
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/editor.js +44697 -26834
- package/package.json +23 -21
- package/src/build-processor.ts +4 -1
- package/src/collection-scanner.ts +425 -48
- package/src/dev-middleware.ts +26 -203
- package/src/editor/api.ts +1 -22
- package/src/editor/components/ai-chat.tsx +3 -3
- package/src/editor/components/ai-tooltip.tsx +2 -1
- package/src/editor/components/block-editor.tsx +13 -108
- package/src/editor/components/collections-browser.tsx +168 -205
- package/src/editor/components/component-card.tsx +49 -0
- package/src/editor/components/confirm-dialog.tsx +34 -47
- package/src/editor/components/create-page-modal.tsx +529 -101
- package/src/editor/components/delete-page-dialog.tsx +100 -0
- package/src/editor/components/fields.tsx +175 -0
- package/src/editor/components/frontmatter-fields.tsx +281 -70
- package/src/editor/components/frontmatter-sidebar.tsx +223 -0
- package/src/editor/components/highlight-overlay.ts +3 -2
- package/src/editor/components/markdown-editor-overlay.tsx +131 -85
- package/src/editor/components/markdown-inline-editor.tsx +74 -5
- package/src/editor/components/mdx-block-view.tsx +102 -0
- package/src/editor/components/mdx-component-picker.tsx +123 -0
- package/src/editor/components/mdx-props-editor.tsx +94 -0
- package/src/editor/components/media-library.tsx +373 -100
- package/src/editor/components/modal-shell.tsx +87 -0
- package/src/editor/components/prop-editor.tsx +52 -0
- package/src/editor/components/redirect-countdown.tsx +3 -1
- package/src/editor/components/redirects-manager.tsx +269 -0
- package/src/editor/components/reference-picker.tsx +203 -0
- package/src/editor/components/seo-editor.tsx +285 -303
- package/src/editor/components/toast/toast-container.tsx +2 -1
- package/src/editor/components/toolbar.tsx +177 -46
- package/src/editor/constants.ts +26 -0
- package/src/editor/editor.ts +112 -0
- package/src/editor/fetch.ts +62 -0
- package/src/editor/index.tsx +19 -1
- package/src/editor/markdown-api.ts +105 -156
- package/src/editor/milkdown-mdx-plugin.tsx +269 -0
- package/src/editor/signals.ts +206 -13
- package/src/editor/types.ts +52 -1
- package/src/handlers/api-routes.ts +251 -0
- package/src/handlers/component-ops.ts +2 -18
- package/src/handlers/markdown-ops.ts +202 -47
- package/src/handlers/page-ops.ts +229 -0
- package/src/handlers/redirect-ops.ts +163 -0
- package/src/handlers/source-writer.ts +157 -1
- package/src/html-processor.ts +14 -2
- package/src/index.ts +76 -2
- package/src/manifest-writer.ts +19 -1
- package/src/media/contember.ts +2 -1
- package/src/media/local.ts +66 -28
- package/src/media/project-images.ts +81 -0
- package/src/media/s3.ts +32 -11
- package/src/media/types.ts +24 -2
- package/src/shared.ts +27 -0
- package/src/source-finder/collection-finder.ts +219 -41
- package/src/source-finder/index.ts +7 -1
- package/src/source-finder/search-index.ts +178 -36
- package/src/source-finder/snippet-utils.ts +423 -3
- package/src/tsconfig.json +0 -2
- package/src/types.ts +111 -2
- package/src/utils.ts +40 -4
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Shadow DOM-based highlight overlay for CMS elements.
|
|
3
3
|
* This component renders highlights without modifying the target element's styles.
|
|
4
4
|
*/
|
|
5
|
+
import { Z_INDEX } from '../constants'
|
|
5
6
|
|
|
6
7
|
export interface HighlightState {
|
|
7
8
|
color: string
|
|
@@ -38,7 +39,7 @@ class CmsHighlightOverlay extends HTMLElement {
|
|
|
38
39
|
:host {
|
|
39
40
|
position: absolute;
|
|
40
41
|
pointer-events: none;
|
|
41
|
-
z-index:
|
|
42
|
+
z-index: ${Z_INDEX.OVERLAY};
|
|
42
43
|
box-sizing: border-box;
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -167,7 +168,7 @@ export function initHighlightContainer(): void {
|
|
|
167
168
|
width: 0;
|
|
168
169
|
height: 0;
|
|
169
170
|
pointer-events: none;
|
|
170
|
-
z-index:
|
|
171
|
+
z-index: ${Z_INDEX.OVERLAY};
|
|
171
172
|
`
|
|
172
173
|
document.body.appendChild(highlightContainer)
|
|
173
174
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type Editor, editorViewCtx } from '@milkdown/core'
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import { slugify } from '../../shared'
|
|
3
4
|
import { updateMarkdownPage } from '../api'
|
|
4
|
-
import { STORAGE_KEYS } from '../constants'
|
|
5
|
+
import { STORAGE_KEYS, Z_INDEX } from '../constants'
|
|
5
6
|
import { startDeploymentPolling } from '../editor'
|
|
6
7
|
import { createMarkdownPage } from '../markdown-api'
|
|
7
8
|
import {
|
|
@@ -14,7 +15,8 @@ import {
|
|
|
14
15
|
startRedirectCountdown,
|
|
15
16
|
updateMarkdownFrontmatter,
|
|
16
17
|
} from '../signals'
|
|
17
|
-
import { CreateModeFrontmatter, EditModeFrontmatter
|
|
18
|
+
import { CreateModeFrontmatter, EditModeFrontmatter } from './frontmatter-fields'
|
|
19
|
+
import { FrontmatterSidebar, partitionFields } from './frontmatter-sidebar'
|
|
18
20
|
import { MarkdownInlineEditor } from './markdown-inline-editor'
|
|
19
21
|
|
|
20
22
|
/**
|
|
@@ -28,8 +30,21 @@ export function MarkdownEditorOverlay() {
|
|
|
28
30
|
const createOptions = editorState.createOptions
|
|
29
31
|
const collectionDef = editorState.collectionDefinition
|
|
30
32
|
|
|
33
|
+
const activeCollectionDef = isCreateMode ? createOptions?.collectionDefinition : collectionDef
|
|
34
|
+
const { sidebar: sidebarFields, header: headerFields } = activeCollectionDef
|
|
35
|
+
? partitionFields(activeCollectionDef.fields)
|
|
36
|
+
: { sidebar: [], header: [] }
|
|
37
|
+
const hasSidebar = sidebarFields.length > 0
|
|
38
|
+
const isDataCollection = activeCollectionDef?.type === 'data'
|
|
39
|
+
// Derive MDX mode from the actual file extension when available
|
|
40
|
+
// (a collection can have mixed .md and .mdx files),
|
|
41
|
+
// but fall back to the collection file extension in create mode before a file path exists.
|
|
42
|
+
const isMdx = page?.filePath
|
|
43
|
+
? page.filePath.endsWith('.mdx')
|
|
44
|
+
: activeCollectionDef?.fileExtension === 'mdx'
|
|
45
|
+
|
|
31
46
|
const [isSaving, setIsSaving] = useState(false)
|
|
32
|
-
const [showFrontmatter, setShowFrontmatter] = useState(isCreateMode)
|
|
47
|
+
const [showFrontmatter, setShowFrontmatter] = useState(isCreateMode || isDataCollection)
|
|
33
48
|
// Track whether the user has manually edited the slug (disables auto-slug from title)
|
|
34
49
|
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false)
|
|
35
50
|
// Preview mode state
|
|
@@ -37,12 +52,11 @@ export function MarkdownEditorOverlay() {
|
|
|
37
52
|
const originalHTMLRef = useRef<string | null>(null)
|
|
38
53
|
const editorInstanceRef = useRef<Editor | null>(null)
|
|
39
54
|
|
|
40
|
-
// Open metadata by default when entering create mode
|
|
41
55
|
useEffect(() => {
|
|
42
|
-
if (isCreateMode) {
|
|
56
|
+
if (isCreateMode || isDataCollection) {
|
|
43
57
|
setShowFrontmatter(true)
|
|
44
58
|
}
|
|
45
|
-
}, [isCreateMode])
|
|
59
|
+
}, [isCreateMode, isDataCollection])
|
|
46
60
|
|
|
47
61
|
const handleDeploymentComplete = useCallback(
|
|
48
62
|
(status: 'completed' | 'failed' | 'timeout') => {
|
|
@@ -142,10 +156,13 @@ export function MarkdownEditorOverlay() {
|
|
|
142
156
|
|
|
143
157
|
setIsSaving(true)
|
|
144
158
|
try {
|
|
145
|
-
|
|
159
|
+
const isData = opts.collectionDefinition.type === 'data'
|
|
160
|
+
|
|
161
|
+
// Build frontmatter — for data collections include all fields; for markdown exclude title
|
|
146
162
|
const frontmatter: Record<string, unknown> = {}
|
|
147
163
|
for (const [key, value] of Object.entries(currentPage.frontmatter)) {
|
|
148
|
-
if (
|
|
164
|
+
if (!isData && key === 'title') continue
|
|
165
|
+
if (value !== undefined && value !== '') {
|
|
149
166
|
frontmatter[key] = value
|
|
150
167
|
}
|
|
151
168
|
}
|
|
@@ -155,7 +172,8 @@ export function MarkdownEditorOverlay() {
|
|
|
155
172
|
title: title.trim(),
|
|
156
173
|
slug,
|
|
157
174
|
frontmatter,
|
|
158
|
-
content: currentPage.content || '',
|
|
175
|
+
content: isData ? '' : (currentPage.content || ''),
|
|
176
|
+
fileExtension: opts.collectionDefinition.fileExtension,
|
|
159
177
|
})
|
|
160
178
|
|
|
161
179
|
if (result.success) {
|
|
@@ -237,7 +255,8 @@ export function MarkdownEditorOverlay() {
|
|
|
237
255
|
if (isPreview) {
|
|
238
256
|
return (
|
|
239
257
|
<div
|
|
240
|
-
|
|
258
|
+
style={{ zIndex: Z_INDEX.MODAL }}
|
|
259
|
+
class="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-3 px-5 py-3 bg-cms-dark/95 border border-white/15 rounded-cms-pill shadow-[0_8px_32px_rgba(0,0,0,0.4)] backdrop-blur-md"
|
|
241
260
|
data-cms-ui
|
|
242
261
|
onMouseDown={stopPropagation}
|
|
243
262
|
onClick={stopPropagation}
|
|
@@ -293,13 +312,16 @@ export function MarkdownEditorOverlay() {
|
|
|
293
312
|
|
|
294
313
|
return (
|
|
295
314
|
<div
|
|
296
|
-
|
|
315
|
+
style={{ zIndex: Z_INDEX.MODAL }}
|
|
316
|
+
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 backdrop-blur-md"
|
|
297
317
|
data-cms-ui
|
|
298
318
|
onMouseDown={stopPropagation}
|
|
299
319
|
onClick={stopPropagation}
|
|
300
320
|
>
|
|
301
321
|
<div
|
|
302
|
-
class=
|
|
322
|
+
class={`bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-white/10 w-full max-h-[90vh] flex flex-col ${
|
|
323
|
+
hasSidebar ? 'max-w-6xl' : 'max-w-4xl'
|
|
324
|
+
}`}
|
|
303
325
|
data-cms-ui
|
|
304
326
|
>
|
|
305
327
|
{/* Header */}
|
|
@@ -326,11 +348,13 @@ export function MarkdownEditorOverlay() {
|
|
|
326
348
|
<div>
|
|
327
349
|
<input
|
|
328
350
|
type="text"
|
|
329
|
-
value={(page.frontmatter.title as string) || ''}
|
|
330
|
-
placeholder=
|
|
351
|
+
value={(page.frontmatter.title as string) || (page.frontmatter.name as string) || ''}
|
|
352
|
+
placeholder={isDataCollection ? 'Entry name...' : 'Page title...'}
|
|
331
353
|
onInput={(e) => {
|
|
332
354
|
const title = (e.target as HTMLInputElement).value
|
|
333
|
-
|
|
355
|
+
// Data collections may use 'name' instead of 'title'
|
|
356
|
+
const titleField = isDataCollection && !('title' in page.frontmatter) && 'name' in page.frontmatter ? 'name' : 'title'
|
|
357
|
+
updateMarkdownFrontmatter({ [titleField]: title })
|
|
334
358
|
// Auto-generate slug in create mode if not manually edited
|
|
335
359
|
if (isCreateMode && !slugManuallyEdited) {
|
|
336
360
|
markdownEditorState.value = {
|
|
@@ -351,49 +375,51 @@ export function MarkdownEditorOverlay() {
|
|
|
351
375
|
</div>
|
|
352
376
|
</div>
|
|
353
377
|
<div class="flex items-center gap-2">
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
<svg
|
|
365
|
-
class="w-4 h-4"
|
|
366
|
-
fill="none"
|
|
367
|
-
stroke="currentColor"
|
|
368
|
-
viewBox="0 0 24 24"
|
|
369
|
-
stroke-width="2"
|
|
370
|
-
>
|
|
371
|
-
<path
|
|
372
|
-
stroke-linecap="round"
|
|
373
|
-
stroke-linejoin="round"
|
|
374
|
-
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
|
375
|
-
/>
|
|
376
|
-
<path
|
|
377
|
-
stroke-linecap="round"
|
|
378
|
-
stroke-linejoin="round"
|
|
379
|
-
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
380
|
-
/>
|
|
381
|
-
</svg>
|
|
382
|
-
Metadata
|
|
383
|
-
<svg
|
|
384
|
-
class={`w-3.5 h-3.5 transition-transform ${showFrontmatter ? 'rotate-180' : ''}`}
|
|
385
|
-
fill="none"
|
|
386
|
-
stroke="currentColor"
|
|
387
|
-
viewBox="0 0 24 24"
|
|
388
|
-
stroke-width="2.5"
|
|
378
|
+
{!isDataCollection && (
|
|
379
|
+
<button
|
|
380
|
+
type="button"
|
|
381
|
+
onClick={() => setShowFrontmatter(!showFrontmatter)}
|
|
382
|
+
class={`px-3 py-2 text-sm rounded-cms-pill transition-colors flex items-center gap-1.5 ${
|
|
383
|
+
showFrontmatter
|
|
384
|
+
? 'bg-white/20 text-white'
|
|
385
|
+
: 'text-white/70 hover:text-white hover:bg-white/10'
|
|
386
|
+
}`}
|
|
387
|
+
data-cms-ui
|
|
389
388
|
>
|
|
390
|
-
<
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
389
|
+
<svg
|
|
390
|
+
class="w-4 h-4"
|
|
391
|
+
fill="none"
|
|
392
|
+
stroke="currentColor"
|
|
393
|
+
viewBox="0 0 24 24"
|
|
394
|
+
stroke-width="2"
|
|
395
|
+
>
|
|
396
|
+
<path
|
|
397
|
+
stroke-linecap="round"
|
|
398
|
+
stroke-linejoin="round"
|
|
399
|
+
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
|
400
|
+
/>
|
|
401
|
+
<path
|
|
402
|
+
stroke-linecap="round"
|
|
403
|
+
stroke-linejoin="round"
|
|
404
|
+
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
405
|
+
/>
|
|
406
|
+
</svg>
|
|
407
|
+
Metadata
|
|
408
|
+
<svg
|
|
409
|
+
class={`w-3.5 h-3.5 transition-transform ${showFrontmatter ? 'rotate-180' : ''}`}
|
|
410
|
+
fill="none"
|
|
411
|
+
stroke="currentColor"
|
|
412
|
+
viewBox="0 0 24 24"
|
|
413
|
+
stroke-width="2.5"
|
|
414
|
+
>
|
|
415
|
+
<path
|
|
416
|
+
stroke-linecap="round"
|
|
417
|
+
stroke-linejoin="round"
|
|
418
|
+
d="M19 9l-7 7-7-7"
|
|
419
|
+
/>
|
|
420
|
+
</svg>
|
|
421
|
+
</button>
|
|
422
|
+
)}
|
|
397
423
|
{!isCreateMode && editorState.activeElementId && (
|
|
398
424
|
<button
|
|
399
425
|
type="button"
|
|
@@ -467,37 +493,57 @@ export function MarkdownEditorOverlay() {
|
|
|
467
493
|
</div>
|
|
468
494
|
</div>
|
|
469
495
|
|
|
470
|
-
{/*
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
496
|
+
{/* Content area: main + optional sidebar */}
|
|
497
|
+
<div class="flex-1 min-h-0 flex">
|
|
498
|
+
{/* Main: frontmatter header + editor */}
|
|
499
|
+
<div class="flex-1 min-w-0 flex flex-col">
|
|
500
|
+
{/* Frontmatter Editor (header-positioned fields only) */}
|
|
501
|
+
{(showFrontmatter || isDataCollection) && (
|
|
502
|
+
<div class={`px-5 py-4 border-b border-white/10 bg-white/5 overflow-y-auto ${isDataCollection ? 'flex-1' : 'max-h-[40vh]'}`}>
|
|
503
|
+
{isCreateMode && createOptions
|
|
504
|
+
? (
|
|
505
|
+
<CreateModeFrontmatter
|
|
506
|
+
page={page}
|
|
507
|
+
collectionDefinition={createOptions.collectionDefinition}
|
|
508
|
+
fields={headerFields}
|
|
509
|
+
onSlugManualEdit={() => setSlugManuallyEdited(true)}
|
|
510
|
+
/>
|
|
511
|
+
)
|
|
512
|
+
: (
|
|
513
|
+
<EditModeFrontmatter
|
|
514
|
+
page={page}
|
|
515
|
+
collectionDefinition={collectionDef}
|
|
516
|
+
fields={headerFields}
|
|
517
|
+
/>
|
|
518
|
+
)}
|
|
519
|
+
</div>
|
|
520
|
+
)}
|
|
521
|
+
|
|
522
|
+
{/* Editor — hidden for data collections (JSON/YAML have no body) */}
|
|
523
|
+
{!isDataCollection && (
|
|
524
|
+
<div class="flex-1 min-h-0 overflow-auto bg-black/20">
|
|
525
|
+
<MarkdownInlineEditor
|
|
526
|
+
elementId={page.slug || 'new-page'}
|
|
527
|
+
initialContent={page.content}
|
|
528
|
+
isMdx={isMdx}
|
|
529
|
+
onSave={isCreateMode ? () => handleCreate() : handleSave}
|
|
530
|
+
onCancel={handleCancel}
|
|
531
|
+
onEditorReady={(editor) => {
|
|
532
|
+
editorInstanceRef.current = editor
|
|
533
|
+
}}
|
|
485
534
|
/>
|
|
486
|
-
|
|
535
|
+
</div>
|
|
536
|
+
)}
|
|
487
537
|
</div>
|
|
488
|
-
)}
|
|
489
538
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
editorInstanceRef.current = editor
|
|
499
|
-
}}
|
|
500
|
-
/>
|
|
539
|
+
{/* Sidebar (sidebar-positioned fields) */}
|
|
540
|
+
{hasSidebar && (
|
|
541
|
+
<FrontmatterSidebar
|
|
542
|
+
fields={sidebarFields}
|
|
543
|
+
page={page}
|
|
544
|
+
collectionDefinition={activeCollectionDef}
|
|
545
|
+
/>
|
|
546
|
+
)}
|
|
501
547
|
</div>
|
|
502
548
|
</div>
|
|
503
549
|
</div>
|
|
@@ -14,11 +14,16 @@ import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm'
|
|
|
14
14
|
import { callCommand, insert, replaceAll } from '@milkdown/utils'
|
|
15
15
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
|
16
16
|
import { uploadMedia } from '../markdown-api'
|
|
17
|
-
import {
|
|
17
|
+
import { insertMdxComponentCommand, mdxComponentPlugin } from '../milkdown-mdx-plugin'
|
|
18
|
+
import { config, mdxComponentPickerOpen, openMediaLibraryWithCallback, resetMarkdownEditorState, showToast, updateMarkdownContent } from '../signals'
|
|
19
|
+
import { MdxComponentIcon } from './mdx-block-view'
|
|
20
|
+
import { MdxComponentPicker } from './mdx-component-picker'
|
|
21
|
+
import { MdxPropsEditor } from './mdx-props-editor'
|
|
18
22
|
|
|
19
23
|
export interface MarkdownInlineEditorProps {
|
|
20
24
|
elementId: string
|
|
21
25
|
initialContent: string
|
|
26
|
+
isMdx?: boolean
|
|
22
27
|
onSave: (content: string) => void
|
|
23
28
|
onCancel: () => void
|
|
24
29
|
onEditorReady?: (editor: Editor) => void
|
|
@@ -27,6 +32,7 @@ export interface MarkdownInlineEditorProps {
|
|
|
27
32
|
export function MarkdownInlineEditor({
|
|
28
33
|
elementId,
|
|
29
34
|
initialContent,
|
|
35
|
+
isMdx,
|
|
30
36
|
onSave,
|
|
31
37
|
onCancel,
|
|
32
38
|
onEditorReady,
|
|
@@ -69,6 +75,9 @@ export function MarkdownInlineEditor({
|
|
|
69
75
|
// Store onEditorReady in ref to avoid re-initializing editor when callback changes
|
|
70
76
|
const onEditorReadyRef = useRef(onEditorReady)
|
|
71
77
|
onEditorReadyRef.current = onEditorReady
|
|
78
|
+
// Store isMdx in ref for editor initialization
|
|
79
|
+
const isMdxRef = useRef(isMdx ?? false)
|
|
80
|
+
isMdxRef.current = isMdx ?? false
|
|
72
81
|
|
|
73
82
|
// Check active formatting at current selection
|
|
74
83
|
const updateActiveFormats = useCallback(() => {
|
|
@@ -154,9 +163,11 @@ export function MarkdownInlineEditor({
|
|
|
154
163
|
useEffect(() => {
|
|
155
164
|
if (!editorRef.current) return
|
|
156
165
|
|
|
166
|
+
let formatRaf = 0
|
|
167
|
+
|
|
157
168
|
const initEditor = async () => {
|
|
158
169
|
try {
|
|
159
|
-
const
|
|
170
|
+
const builder = Editor.make()
|
|
160
171
|
.config((ctx) => {
|
|
161
172
|
ctx.set(rootCtx, editorRef.current)
|
|
162
173
|
ctx.set(defaultValueCtx, initialContentRef.current)
|
|
@@ -168,19 +179,29 @@ export function MarkdownInlineEditor({
|
|
|
168
179
|
.use(commonmark)
|
|
169
180
|
.use(gfm)
|
|
170
181
|
.use(listener)
|
|
171
|
-
|
|
182
|
+
|
|
183
|
+
// Add MDX component support for .mdx files
|
|
184
|
+
if (isMdxRef.current) {
|
|
185
|
+
for (const plugin of mdxComponentPlugin) {
|
|
186
|
+
builder.use(plugin as any)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const editor = await builder.create()
|
|
172
191
|
|
|
173
192
|
editorInstanceRef.current = editor
|
|
174
193
|
setIsReady(true)
|
|
175
194
|
onEditorReadyRef.current?.(editor)
|
|
176
195
|
|
|
177
|
-
// Set up selection change listener
|
|
196
|
+
// Set up selection change listener — debounce via rAF to avoid
|
|
197
|
+
// redundant mark-scanning on every keystroke
|
|
178
198
|
const view = editor.ctx.get(editorViewCtx)
|
|
179
199
|
const originalDispatch = view.dispatch.bind(view)
|
|
180
200
|
view.dispatch = (tr) => {
|
|
181
201
|
originalDispatch(tr)
|
|
182
202
|
if (tr.selectionSet || tr.docChanged) {
|
|
183
|
-
|
|
203
|
+
cancelAnimationFrame(formatRaf)
|
|
204
|
+
formatRaf = requestAnimationFrame(updateActiveFormats)
|
|
184
205
|
}
|
|
185
206
|
}
|
|
186
207
|
|
|
@@ -195,6 +216,7 @@ export function MarkdownInlineEditor({
|
|
|
195
216
|
initEditor()
|
|
196
217
|
|
|
197
218
|
return () => {
|
|
219
|
+
cancelAnimationFrame(formatRaf)
|
|
198
220
|
editorInstanceRef.current?.destroy()
|
|
199
221
|
editorInstanceRef.current = null
|
|
200
222
|
}
|
|
@@ -360,6 +382,38 @@ export function MarkdownInlineEditor({
|
|
|
360
382
|
}
|
|
361
383
|
}, [])
|
|
362
384
|
|
|
385
|
+
// MDX component insertion
|
|
386
|
+
const handleInsertMdxComponent = useCallback((componentName: string, props: Record<string, string>) => {
|
|
387
|
+
if (editorInstanceRef.current) {
|
|
388
|
+
try {
|
|
389
|
+
editorInstanceRef.current.action(callCommand(insertMdxComponentCommand.key, { componentName, props }))
|
|
390
|
+
} catch (error) {
|
|
391
|
+
console.error('Failed to insert MDX component:', error)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}, [])
|
|
395
|
+
|
|
396
|
+
const handleOpenMdxPicker = useCallback(() => {
|
|
397
|
+
mdxComponentPickerOpen.value = true
|
|
398
|
+
}, [])
|
|
399
|
+
|
|
400
|
+
const handleUpdateMdxProps = useCallback((nodePos: number, props: Record<string, string>) => {
|
|
401
|
+
if (!editorInstanceRef.current) return
|
|
402
|
+
try {
|
|
403
|
+
const view = editorInstanceRef.current.ctx.get(editorViewCtx)
|
|
404
|
+
const node = view.state.doc.nodeAt(nodePos)
|
|
405
|
+
if (node && node.type.name === 'mdx_component') {
|
|
406
|
+
const tr = view.state.tr.setNodeMarkup(nodePos, undefined, {
|
|
407
|
+
...node.attrs,
|
|
408
|
+
props: JSON.stringify(props),
|
|
409
|
+
})
|
|
410
|
+
view.dispatch(tr)
|
|
411
|
+
}
|
|
412
|
+
} catch (error) {
|
|
413
|
+
console.error('Failed to update MDX component props:', error)
|
|
414
|
+
}
|
|
415
|
+
}, [])
|
|
416
|
+
|
|
363
417
|
// Drag and drop handlers for direct image upload
|
|
364
418
|
const handleDragOver = useCallback((e: DragEvent) => {
|
|
365
419
|
e.preventDefault()
|
|
@@ -682,6 +736,15 @@ export function MarkdownInlineEditor({
|
|
|
682
736
|
/>
|
|
683
737
|
</svg>
|
|
684
738
|
</ToolbarButton>
|
|
739
|
+
{isMdx && (
|
|
740
|
+
<>
|
|
741
|
+
{/* Divider */}
|
|
742
|
+
<div class="w-px h-5 bg-white/20 mx-1" />
|
|
743
|
+
<ToolbarButton onClick={handleOpenMdxPicker} title="Insert Component">
|
|
744
|
+
<MdxComponentIcon size="md" />
|
|
745
|
+
</ToolbarButton>
|
|
746
|
+
</>
|
|
747
|
+
)}
|
|
685
748
|
</div>
|
|
686
749
|
</div>
|
|
687
750
|
|
|
@@ -745,6 +808,12 @@ export function MarkdownInlineEditor({
|
|
|
745
808
|
</div>
|
|
746
809
|
)}
|
|
747
810
|
</div>
|
|
811
|
+
|
|
812
|
+
{/* MDX Component Picker */}
|
|
813
|
+
{isMdx && <MdxComponentPicker onInsert={handleInsertMdxComponent} />}
|
|
814
|
+
|
|
815
|
+
{/* MDX Props Editor */}
|
|
816
|
+
{isMdx && <MdxPropsEditor onUpdateProps={handleUpdateMdxProps} />}
|
|
748
817
|
</div>
|
|
749
818
|
)
|
|
750
819
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { MDX_EXPR_PREFIX } from '../milkdown-mdx-plugin'
|
|
2
|
+
|
|
3
|
+
const MDX_COMPONENT_ICON_PATH =
|
|
4
|
+
'M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5'
|
|
5
|
+
|
|
6
|
+
export function MdxComponentIcon({ size = 'sm' }: { size?: 'sm' | 'md' }) {
|
|
7
|
+
const iconClass = size === 'md' ? 'w-4 h-4' : 'w-3 h-3'
|
|
8
|
+
const svg = (
|
|
9
|
+
<svg class={`${iconClass} text-cms-primary`} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
10
|
+
<path stroke-linecap="round" stroke-linejoin="round" d={MDX_COMPONENT_ICON_PATH} />
|
|
11
|
+
</svg>
|
|
12
|
+
)
|
|
13
|
+
if (size === 'md') return svg
|
|
14
|
+
return (
|
|
15
|
+
<div class="w-5 h-5 rounded bg-cms-primary/20 flex items-center justify-center">
|
|
16
|
+
{svg}
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MdxBlockCardProps {
|
|
22
|
+
componentName: string
|
|
23
|
+
props: Record<string, string>
|
|
24
|
+
hasExpressions: boolean
|
|
25
|
+
onEdit: (cursorPos: { x: number; y: number }) => void
|
|
26
|
+
onRemove: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function MdxBlockCard({ componentName, props, hasExpressions, onEdit, onRemove }: MdxBlockCardProps) {
|
|
30
|
+
const propEntries = Object.entries(props).filter(([_, v]) => v !== '')
|
|
31
|
+
const displayProps = propEntries.map(([name, value]) => {
|
|
32
|
+
if (value.startsWith(MDX_EXPR_PREFIX)) {
|
|
33
|
+
return { name, value: value.slice(MDX_EXPR_PREFIX.length), isExpression: true }
|
|
34
|
+
}
|
|
35
|
+
return { name, value, isExpression: false }
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
class="my-3 mx-0 bg-white/5 border border-white/15 rounded-cms-md overflow-hidden select-none"
|
|
41
|
+
data-cms-ui
|
|
42
|
+
>
|
|
43
|
+
<div class="flex items-center justify-between px-4 py-2.5 bg-white/5 border-b border-white/10">
|
|
44
|
+
<div class="flex items-center gap-2">
|
|
45
|
+
<MdxComponentIcon />
|
|
46
|
+
<span class="text-[13px] font-semibold text-white">{componentName}</span>
|
|
47
|
+
{hasExpressions && <span class="text-[10px] px-1.5 py-0.5 bg-amber-500/20 text-amber-300 rounded font-mono">expr</span>}
|
|
48
|
+
</div>
|
|
49
|
+
<div class="flex items-center gap-1">
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
data-mdx-action="edit"
|
|
53
|
+
onClick={(e: MouseEvent) => {
|
|
54
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
55
|
+
onEdit({ x: rect.left, y: rect.bottom + 4 })
|
|
56
|
+
}}
|
|
57
|
+
class="p-1.5 rounded-cms-sm text-white/50 hover:text-white hover:bg-white/10 transition-colors"
|
|
58
|
+
title="Edit props"
|
|
59
|
+
>
|
|
60
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
61
|
+
<path
|
|
62
|
+
stroke-linecap="round"
|
|
63
|
+
stroke-linejoin="round"
|
|
64
|
+
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
|
65
|
+
/>
|
|
66
|
+
</svg>
|
|
67
|
+
</button>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
data-mdx-action="remove"
|
|
71
|
+
onClick={onRemove}
|
|
72
|
+
class="p-1.5 rounded-cms-sm text-white/50 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
|
73
|
+
title="Remove block"
|
|
74
|
+
>
|
|
75
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
76
|
+
<path
|
|
77
|
+
stroke-linecap="round"
|
|
78
|
+
stroke-linejoin="round"
|
|
79
|
+
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
80
|
+
/>
|
|
81
|
+
</svg>
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{displayProps.length > 0 && (
|
|
87
|
+
<div class="px-4 py-2 flex flex-wrap gap-x-3 gap-y-1">
|
|
88
|
+
{displayProps.slice(0, 6).map(({ name, value, isExpression }) => (
|
|
89
|
+
<span key={name} class="text-[11px] text-white/40 font-mono">
|
|
90
|
+
<span class="text-white/60">{name}</span>
|
|
91
|
+
<span class="text-white/30">=</span>
|
|
92
|
+
{isExpression
|
|
93
|
+
? <span class="text-amber-300/60">{`{${value.length > 20 ? value.slice(0, 20) + '...' : value}}`}</span>
|
|
94
|
+
: <span class="text-cms-primary/60">"{value.length > 25 ? value.slice(0, 25) + '...' : value}"</span>}
|
|
95
|
+
</span>
|
|
96
|
+
))}
|
|
97
|
+
{displayProps.length > 6 && <span class="text-[11px] text-white/30">+{displayProps.length - 6} more</span>}
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|