@nuasite/cms 0.26.0 → 0.28.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/README.md +103 -0
- package/dist/editor.js +25765 -25462
- package/package.json +1 -1
- package/src/collection-scanner.ts +152 -12
- package/src/dev-middleware.ts +7 -0
- package/src/editor/components/fields.tsx +8 -2
- package/src/editor/components/frontmatter-fields.tsx +13 -3
- package/src/editor/components/link-edit-popover.tsx +232 -0
- package/src/editor/components/markdown-editor-overlay.tsx +37 -0
- package/src/editor/components/markdown-inline-editor.tsx +25 -52
- package/src/editor/components/mdx-block-view.tsx +151 -41
- package/src/editor/hooks/useLinkPopover.ts +64 -0
- package/src/editor/milkdown-mdx-plugin.tsx +5 -0
- package/src/editor/milkdown-utils.ts +21 -0
- package/src/field-types.ts +109 -27
- package/src/index.ts +21 -8
- package/src/pages/component-preview.astro +56 -0
- package/src/types.ts +18 -0
- package/src/vite-plugin.ts +9 -0
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
commonmark,
|
|
5
5
|
liftListItemCommand,
|
|
6
6
|
toggleEmphasisCommand,
|
|
7
|
-
toggleLinkCommand,
|
|
8
7
|
toggleStrongCommand,
|
|
9
8
|
wrapInBlockquoteCommand,
|
|
10
9
|
wrapInBulletListCommand,
|
|
@@ -13,10 +12,12 @@ import {
|
|
|
13
12
|
import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm'
|
|
14
13
|
import { callCommand, insert, replaceAll } from '@milkdown/utils'
|
|
15
14
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
|
15
|
+
import { useLinkPopover } from '../hooks/useLinkPopover'
|
|
16
16
|
import { uploadMedia } from '../markdown-api'
|
|
17
17
|
import { insertMdxComponentCommand, mdxComponentPlugin } from '../milkdown-mdx-plugin'
|
|
18
18
|
import { type ActiveFormats, defaultActiveFormats, isInListType, setupFormatTracking, toggleHeading } from '../milkdown-utils'
|
|
19
19
|
import { config, mdxComponentPickerOpen, openMediaLibraryWithCallback, resetMarkdownEditorState, showToast, updateMarkdownContent } from '../signals'
|
|
20
|
+
import { LinkEditPopover } from './link-edit-popover'
|
|
20
21
|
import { MdxComponentIcon } from './mdx-block-view'
|
|
21
22
|
import { MdxComponentPicker } from './mdx-component-picker'
|
|
22
23
|
|
|
@@ -46,6 +47,15 @@ export function MarkdownInlineEditor({
|
|
|
46
47
|
|
|
47
48
|
// Track active formatting for toolbar highlighting
|
|
48
49
|
const [activeFormats, setActiveFormats] = useState<ActiveFormats>(defaultActiveFormats)
|
|
50
|
+
const {
|
|
51
|
+
linkPopoverState,
|
|
52
|
+
linkPopoverOpen,
|
|
53
|
+
closeLinkPopover,
|
|
54
|
+
toggleLinkPopover,
|
|
55
|
+
applyLink,
|
|
56
|
+
removeLink,
|
|
57
|
+
pageSuggestions,
|
|
58
|
+
} = useLinkPopover(editorInstanceRef, activeFormats)
|
|
49
59
|
|
|
50
60
|
// Store initial content in ref to avoid stale closure issues
|
|
51
61
|
const initialContentRef = useRef(initialContent)
|
|
@@ -202,54 +212,6 @@ export function MarkdownInlineEditor({
|
|
|
202
212
|
}
|
|
203
213
|
}, [runCommand, checkInList])
|
|
204
214
|
|
|
205
|
-
const handleInsertLink = useCallback(() => {
|
|
206
|
-
if (!editorInstanceRef.current) return
|
|
207
|
-
|
|
208
|
-
// If already in a link, remove it
|
|
209
|
-
if (activeFormats.link) {
|
|
210
|
-
try {
|
|
211
|
-
// Use toggleLinkCommand with empty href to remove link
|
|
212
|
-
editorInstanceRef.current.action(
|
|
213
|
-
callCommand(toggleLinkCommand.key, { href: '' }),
|
|
214
|
-
)
|
|
215
|
-
return
|
|
216
|
-
} catch (error) {
|
|
217
|
-
console.error('Failed to remove link:', error)
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Get selected text from editor
|
|
222
|
-
let selectedText = ''
|
|
223
|
-
try {
|
|
224
|
-
const view = editorInstanceRef.current.ctx.get(editorViewCtx)
|
|
225
|
-
const { state } = view
|
|
226
|
-
const { from, to } = state.selection
|
|
227
|
-
if (from !== to) {
|
|
228
|
-
selectedText = state.doc.textBetween(from, to, ' ')
|
|
229
|
-
}
|
|
230
|
-
} catch {
|
|
231
|
-
// Ignore errors
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Prompt for URL (pre-fill with existing URL if editing)
|
|
235
|
-
const defaultUrl = activeFormats.linkHref || ''
|
|
236
|
-
const url = prompt('Enter URL:', defaultUrl)
|
|
237
|
-
if (url) {
|
|
238
|
-
try {
|
|
239
|
-
// Use toggleLinkCommand to add/update link
|
|
240
|
-
editorInstanceRef.current.action(
|
|
241
|
-
callCommand(toggleLinkCommand.key, { href: url }),
|
|
242
|
-
)
|
|
243
|
-
} catch (error) {
|
|
244
|
-
console.error('Failed to add link:', error)
|
|
245
|
-
// Fallback: use markdown insertion
|
|
246
|
-
const linkText = selectedText || prompt('Enter link text:', 'Link') || 'Link'
|
|
247
|
-
const linkMarkdown = `[${linkText}](${url})`
|
|
248
|
-
editorInstanceRef.current.action(insert(linkMarkdown))
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}, [activeFormats.link, activeFormats.linkHref])
|
|
252
|
-
|
|
253
215
|
const handleInsertHeading = useCallback((level: number) => {
|
|
254
216
|
if (!editorInstanceRef.current) return
|
|
255
217
|
try {
|
|
@@ -575,9 +537,9 @@ export function MarkdownInlineEditor({
|
|
|
575
537
|
{/* Links & Images */}
|
|
576
538
|
<div class="flex items-center gap-0.5">
|
|
577
539
|
<ToolbarButton
|
|
578
|
-
onClick={
|
|
579
|
-
title={activeFormats.link ? '
|
|
580
|
-
active={activeFormats.link}
|
|
540
|
+
onClick={toggleLinkPopover}
|
|
541
|
+
title={activeFormats.link ? 'Edit Link' : 'Insert Link'}
|
|
542
|
+
active={activeFormats.link || linkPopoverOpen}
|
|
581
543
|
>
|
|
582
544
|
<svg
|
|
583
545
|
class="w-4 h-4"
|
|
@@ -620,6 +582,17 @@ export function MarkdownInlineEditor({
|
|
|
620
582
|
</div>
|
|
621
583
|
</div>
|
|
622
584
|
|
|
585
|
+
{/* Link edit popover — rendered outside the toolbar stacking context so it layers above the sidebar */}
|
|
586
|
+
{linkPopoverState && (
|
|
587
|
+
<LinkEditPopover
|
|
588
|
+
initialUrl={linkPopoverState.href}
|
|
589
|
+
suggestions={pageSuggestions}
|
|
590
|
+
onApply={applyLink}
|
|
591
|
+
onRemove={linkPopoverState.isEdit ? removeLink : undefined}
|
|
592
|
+
onClose={closeLinkPopover}
|
|
593
|
+
/>
|
|
594
|
+
)}
|
|
595
|
+
|
|
623
596
|
{/* Editor */}
|
|
624
597
|
<div
|
|
625
598
|
class={`flex-1 min-h-0 overflow-auto relative transition-colors ${isDragging ? 'bg-cms-primary/10' : ''}`}
|
|
@@ -4,18 +4,22 @@ import {
|
|
|
4
4
|
commonmark,
|
|
5
5
|
liftListItemCommand,
|
|
6
6
|
toggleEmphasisCommand,
|
|
7
|
-
toggleLinkCommand,
|
|
8
7
|
toggleStrongCommand,
|
|
9
8
|
wrapInBlockquoteCommand,
|
|
10
9
|
wrapInBulletListCommand,
|
|
11
10
|
wrapInOrderedListCommand,
|
|
12
11
|
} from '@milkdown/preset-commonmark'
|
|
13
12
|
import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm'
|
|
14
|
-
import { callCommand,
|
|
13
|
+
import { callCommand, replaceAll } from '@milkdown/utils'
|
|
15
14
|
import type { ComponentChildren } from 'preact'
|
|
16
|
-
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
|
15
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
16
|
+
import { useLinkPopover } from '../hooks/useLinkPopover'
|
|
17
|
+
import { getComponentDefinition } from '../manifest'
|
|
17
18
|
import { MDX_EXPR_PREFIX } from '../milkdown-mdx-plugin'
|
|
18
19
|
import { type ActiveFormats, defaultActiveFormats, isInListType, setupFormatTracking, toggleHeading } from '../milkdown-utils'
|
|
20
|
+
import { manifest, openMediaLibraryWithCallback } from '../signals'
|
|
21
|
+
import type { ComponentProp } from '../types'
|
|
22
|
+
import { LinkEditPopover } from './link-edit-popover'
|
|
19
23
|
|
|
20
24
|
const MDX_COMPONENT_ICON_PATH =
|
|
21
25
|
'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'
|
|
@@ -75,6 +79,7 @@ function MiniMilkdownEditor({ value, onChange }: { value: string; onChange: (v:
|
|
|
75
79
|
const latestMarkdown = useRef(value)
|
|
76
80
|
const isFocused = useRef(false)
|
|
77
81
|
const [formats, setFormats] = useState<ActiveFormats>(defaultActiveFormats)
|
|
82
|
+
const link = useLinkPopover(editorRef, formats)
|
|
78
83
|
|
|
79
84
|
useEffect(() => {
|
|
80
85
|
const el = containerRef.current
|
|
@@ -141,18 +146,6 @@ function MiniMilkdownEditor({ value, onChange }: { value: string; onChange: (v:
|
|
|
141
146
|
return false
|
|
142
147
|
}, [])
|
|
143
148
|
|
|
144
|
-
const handleLink = useCallback(() => {
|
|
145
|
-
if (!editorRef.current) return
|
|
146
|
-
const url = prompt('Enter URL:', 'https://')
|
|
147
|
-
if (!url) return
|
|
148
|
-
try {
|
|
149
|
-
editorRef.current.action(callCommand(toggleLinkCommand.key, { href: url }))
|
|
150
|
-
} catch {
|
|
151
|
-
const linkText = window.getSelection()?.toString() || 'Link'
|
|
152
|
-
editorRef.current.action(insert(`[${linkText}](${url})`))
|
|
153
|
-
}
|
|
154
|
-
}, [])
|
|
155
|
-
|
|
156
149
|
const handleHeadingToggle = useCallback((level: number) => {
|
|
157
150
|
if (!editorRef.current) return
|
|
158
151
|
try {
|
|
@@ -239,7 +232,7 @@ function MiniMilkdownEditor({ value, onChange }: { value: string; onChange: (v:
|
|
|
239
232
|
<div class="w-px h-4 bg-white/15 mx-0.5" />
|
|
240
233
|
|
|
241
234
|
{/* Link */}
|
|
242
|
-
<MiniToolbarButton onClick={
|
|
235
|
+
<MiniToolbarButton onClick={link.toggleLinkPopover} title="Link" active={formats.link || link.linkPopoverOpen}>
|
|
243
236
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
244
237
|
<path
|
|
245
238
|
stroke-linecap="round"
|
|
@@ -250,6 +243,19 @@ function MiniMilkdownEditor({ value, onChange }: { value: string; onChange: (v:
|
|
|
250
243
|
</MiniToolbarButton>
|
|
251
244
|
</div>
|
|
252
245
|
|
|
246
|
+
{link.linkPopoverState && (
|
|
247
|
+
<div class="mb-1.5">
|
|
248
|
+
<LinkEditPopover
|
|
249
|
+
inline
|
|
250
|
+
initialUrl={link.linkPopoverState.href}
|
|
251
|
+
suggestions={link.pageSuggestions}
|
|
252
|
+
onApply={link.applyLink}
|
|
253
|
+
onRemove={link.linkPopoverState.isEdit ? link.removeLink : undefined}
|
|
254
|
+
onClose={link.closeLinkPopover}
|
|
255
|
+
/>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
|
|
253
259
|
{/* Editor */}
|
|
254
260
|
<div
|
|
255
261
|
ref={(el) => {
|
|
@@ -301,6 +307,101 @@ function InlineInput({ value, onChange, placeholder }: { value: string; onChange
|
|
|
301
307
|
)
|
|
302
308
|
}
|
|
303
309
|
|
|
310
|
+
const INLINE_INPUT_TYPES: Record<string, string> = {
|
|
311
|
+
number: 'number',
|
|
312
|
+
url: 'url',
|
|
313
|
+
date: 'date',
|
|
314
|
+
datetime: 'datetime-local',
|
|
315
|
+
time: 'time',
|
|
316
|
+
email: 'email',
|
|
317
|
+
}
|
|
318
|
+
const inputClass =
|
|
319
|
+
'w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white/80 placeholder:text-white/30 outline-none focus:border-white/25 transition-colors'
|
|
320
|
+
|
|
321
|
+
function InlinePropField(
|
|
322
|
+
{ name, value, propDef, onChange }: { name: string; value: string; propDef?: ComponentProp; onChange: (v: string) => void },
|
|
323
|
+
) {
|
|
324
|
+
const typeLower = propDef?.type.toLowerCase() ?? ''
|
|
325
|
+
|
|
326
|
+
if (typeLower === 'boolean') {
|
|
327
|
+
return (
|
|
328
|
+
<div class="flex items-center gap-2">
|
|
329
|
+
<label class="text-[11px] text-white/40 font-medium w-20 shrink-0 text-right">{name}</label>
|
|
330
|
+
<label class="flex items-center gap-2 cursor-pointer py-1">
|
|
331
|
+
<input
|
|
332
|
+
type="checkbox"
|
|
333
|
+
checked={value === 'true'}
|
|
334
|
+
onChange={(e) => onChange((e.target as HTMLInputElement).checked ? 'true' : 'false')}
|
|
335
|
+
class="accent-cms-primary w-4 h-4 rounded"
|
|
336
|
+
/>
|
|
337
|
+
<span class="text-[12px] text-white/60">{value === 'true' ? 'Yes' : 'No'}</span>
|
|
338
|
+
</label>
|
|
339
|
+
</div>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (typeLower === 'image') {
|
|
344
|
+
return (
|
|
345
|
+
<div class="flex items-center gap-2">
|
|
346
|
+
<label class="text-[11px] text-white/40 font-medium w-20 shrink-0 text-right">{name}</label>
|
|
347
|
+
<div class="flex gap-1.5 flex-1">
|
|
348
|
+
<InlineInput value={value} onChange={onChange} placeholder="Select an image..." />
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
onClick={() => openMediaLibraryWithCallback((url: string) => onChange(url))}
|
|
352
|
+
class="px-2 py-1.5 bg-white/5 border border-white/10 text-white/50 hover:text-white hover:bg-white/10 rounded-cms-sm transition-colors shrink-0"
|
|
353
|
+
title="Browse media"
|
|
354
|
+
>
|
|
355
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
356
|
+
<path
|
|
357
|
+
stroke-linecap="round"
|
|
358
|
+
stroke-linejoin="round"
|
|
359
|
+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
360
|
+
/>
|
|
361
|
+
</svg>
|
|
362
|
+
</button>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (typeLower === 'color') {
|
|
369
|
+
return (
|
|
370
|
+
<div class="flex items-center gap-2">
|
|
371
|
+
<label class="text-[11px] text-white/40 font-medium w-20 shrink-0 text-right">{name}</label>
|
|
372
|
+
<div class="flex gap-1.5 flex-1 items-center">
|
|
373
|
+
<input
|
|
374
|
+
type="color"
|
|
375
|
+
value={value || '#000000'}
|
|
376
|
+
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
377
|
+
class="w-7 h-7 rounded-cms-sm border border-white/10 bg-transparent cursor-pointer shrink-0"
|
|
378
|
+
/>
|
|
379
|
+
<InlineInput value={value} onChange={onChange} placeholder="#000000" />
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const htmlType = INLINE_INPUT_TYPES[typeLower]
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<div class="flex items-center gap-2">
|
|
389
|
+
<label class="text-[11px] text-white/40 font-medium w-20 shrink-0 text-right">{name}</label>
|
|
390
|
+
{htmlType
|
|
391
|
+
? (
|
|
392
|
+
<input
|
|
393
|
+
type={htmlType}
|
|
394
|
+
value={value}
|
|
395
|
+
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
396
|
+
placeholder={`Enter ${name}...`}
|
|
397
|
+
class={inputClass}
|
|
398
|
+
/>
|
|
399
|
+
)
|
|
400
|
+
: <InlineInput value={value} onChange={onChange} placeholder={`Enter ${name}...`} />}
|
|
401
|
+
</div>
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
304
405
|
// ============================================================================
|
|
305
406
|
// Block Card
|
|
306
407
|
// ============================================================================
|
|
@@ -311,6 +412,14 @@ export function MdxBlockCard({ componentName, props, hasExpressions, slotContent
|
|
|
311
412
|
const expressionProps = propEntries.filter(([_, v]) => v.startsWith(MDX_EXPR_PREFIX))
|
|
312
413
|
|
|
313
414
|
const hasSlotContent = onSlotContentChange != null
|
|
415
|
+
const definition = getComponentDefinition(manifest.value, componentName)
|
|
416
|
+
const propTypes = useMemo(() => {
|
|
417
|
+
const map = new Map<string, ComponentProp>()
|
|
418
|
+
if (definition?.props) {
|
|
419
|
+
for (const p of definition.props) map.set(p.name, p)
|
|
420
|
+
}
|
|
421
|
+
return map
|
|
422
|
+
}, [definition])
|
|
314
423
|
|
|
315
424
|
const handlePropChange = (name: string, newValue: string) => {
|
|
316
425
|
if (onPropsChange) {
|
|
@@ -320,31 +429,33 @@ export function MdxBlockCard({ componentName, props, hasExpressions, slotContent
|
|
|
320
429
|
|
|
321
430
|
return (
|
|
322
431
|
<div
|
|
323
|
-
class="my-3 mx-0 bg-white/5 border border-white/15 rounded-cms-md
|
|
432
|
+
class="my-3 mx-0 bg-white/5 border border-white/15 rounded-cms-md select-none"
|
|
324
433
|
data-cms-ui
|
|
325
434
|
>
|
|
326
435
|
{/* Header */}
|
|
327
|
-
<div class="flex items-center justify-between px-4 py-2.5 bg-white/5 border-b border-white/10">
|
|
436
|
+
<div class="flex items-center justify-between px-4 py-2.5 bg-white/5 border-b border-white/10 rounded-t-cms-md">
|
|
328
437
|
<div class="flex items-center gap-2">
|
|
329
438
|
<MdxComponentIcon />
|
|
330
439
|
<span class="text-[13px] font-semibold text-white">{componentName}</span>
|
|
331
440
|
{hasExpressions && <span class="text-[10px] px-1.5 py-0.5 bg-amber-500/20 text-amber-300 rounded font-mono">expr</span>}
|
|
332
441
|
</div>
|
|
333
|
-
<
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
<
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
442
|
+
<div class="flex items-center gap-1">
|
|
443
|
+
<button
|
|
444
|
+
type="button"
|
|
445
|
+
data-mdx-action="remove"
|
|
446
|
+
onClick={onRemove}
|
|
447
|
+
class="p-1.5 rounded-cms-sm text-white/50 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
|
448
|
+
title="Remove block"
|
|
449
|
+
>
|
|
450
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
451
|
+
<path
|
|
452
|
+
stroke-linecap="round"
|
|
453
|
+
stroke-linejoin="round"
|
|
454
|
+
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"
|
|
455
|
+
/>
|
|
456
|
+
</svg>
|
|
457
|
+
</button>
|
|
458
|
+
</div>
|
|
348
459
|
</div>
|
|
349
460
|
|
|
350
461
|
{/* Slot content editor */}
|
|
@@ -361,14 +472,13 @@ export function MdxBlockCard({ componentName, props, hasExpressions, slotContent
|
|
|
361
472
|
{onPropsChange && editableProps.length > 0 && (
|
|
362
473
|
<div class="px-4 py-3 space-y-2" data-mdx-action="props">
|
|
363
474
|
{editableProps.map(([name, value]) => (
|
|
364
|
-
<
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
</div>
|
|
475
|
+
<InlinePropField
|
|
476
|
+
key={name}
|
|
477
|
+
name={name}
|
|
478
|
+
value={value}
|
|
479
|
+
propDef={propTypes.get(name)}
|
|
480
|
+
onChange={(v) => handlePropChange(name, v)}
|
|
481
|
+
/>
|
|
372
482
|
))}
|
|
373
483
|
</div>
|
|
374
484
|
)}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Editor } from '@milkdown/core'
|
|
2
|
+
import { editorViewCtx } from '@milkdown/core'
|
|
3
|
+
import { toggleLinkCommand, updateLinkCommand } from '@milkdown/preset-commonmark'
|
|
4
|
+
import { callCommand } from '@milkdown/utils'
|
|
5
|
+
import type { RefObject } from 'preact'
|
|
6
|
+
import { useCallback, useMemo, useState } from 'preact/hooks'
|
|
7
|
+
import type { LinkSuggestion } from '../components/link-edit-popover'
|
|
8
|
+
import type { ActiveFormats } from '../milkdown-utils'
|
|
9
|
+
import { removeLinkMark } from '../milkdown-utils'
|
|
10
|
+
import { manifest } from '../signals'
|
|
11
|
+
|
|
12
|
+
export interface LinkPopoverState {
|
|
13
|
+
href: string
|
|
14
|
+
isEdit: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useLinkPopover(editorRef: RefObject<Editor | null>, activeFormats: ActiveFormats) {
|
|
18
|
+
const [linkPopoverState, setLinkPopoverState] = useState<LinkPopoverState | null>(null)
|
|
19
|
+
const linkPopoverOpen = linkPopoverState !== null
|
|
20
|
+
const closeLinkPopover = useCallback(() => setLinkPopoverState(null), [])
|
|
21
|
+
|
|
22
|
+
const pageSuggestions = useMemo<LinkSuggestion[]>(() =>
|
|
23
|
+
(manifest.value.pages || []).map(p => ({
|
|
24
|
+
value: p.pathname,
|
|
25
|
+
label: p.title || p.pathname,
|
|
26
|
+
description: p.title ? p.pathname : undefined,
|
|
27
|
+
})), [manifest.value.pages])
|
|
28
|
+
|
|
29
|
+
const toggleLinkPopover = useCallback(() => {
|
|
30
|
+
if (!editorRef.current) return
|
|
31
|
+
setLinkPopoverState((prev) => prev !== null ? null : { href: activeFormats.linkHref || 'https://', isEdit: activeFormats.link })
|
|
32
|
+
}, [activeFormats.link, activeFormats.linkHref, editorRef])
|
|
33
|
+
|
|
34
|
+
const applyLink = useCallback((url: string) => {
|
|
35
|
+
if (!editorRef.current) return
|
|
36
|
+
const isEdit = linkPopoverState?.isEdit ?? false
|
|
37
|
+
closeLinkPopover()
|
|
38
|
+
try {
|
|
39
|
+
const view = editorRef.current.ctx.get(editorViewCtx)
|
|
40
|
+
view.focus()
|
|
41
|
+
if (isEdit) {
|
|
42
|
+
editorRef.current.action(callCommand(updateLinkCommand.key, { href: url }))
|
|
43
|
+
} else {
|
|
44
|
+
editorRef.current.action(callCommand(toggleLinkCommand.key, { href: url }))
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error('Failed to apply link:', error)
|
|
48
|
+
}
|
|
49
|
+
}, [linkPopoverState, closeLinkPopover, editorRef])
|
|
50
|
+
|
|
51
|
+
const removeLink = useCallback(() => {
|
|
52
|
+
if (!editorRef.current) return
|
|
53
|
+
closeLinkPopover()
|
|
54
|
+
try {
|
|
55
|
+
const view = editorRef.current.ctx.get(editorViewCtx)
|
|
56
|
+
view.focus()
|
|
57
|
+
removeLinkMark(view)
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error('Failed to remove link:', error)
|
|
60
|
+
}
|
|
61
|
+
}, [closeLinkPopover, editorRef])
|
|
62
|
+
|
|
63
|
+
return { linkPopoverState, linkPopoverOpen, closeLinkPopover, toggleLinkPopover, applyLink, removeLink, pageSuggestions }
|
|
64
|
+
}
|
|
@@ -240,6 +240,11 @@ export const mdxComponentView = $view(mdxComponentNode, () => {
|
|
|
240
240
|
const renderCard = (node: PmNode) => {
|
|
241
241
|
const componentName = node.attrs.componentName as string
|
|
242
242
|
const propsJson = node.attrs.props as string
|
|
243
|
+
|
|
244
|
+
// Store attrs on the wrapper so preview mode can read them from copied DOM
|
|
245
|
+
container.setAttribute('data-mdx-component', componentName)
|
|
246
|
+
container.setAttribute('data-mdx-props', propsJson)
|
|
247
|
+
container.setAttribute('data-mdx-children', (node.attrs.children as string) || '')
|
|
243
248
|
const props: Record<string, string> = JSON.parse(propsJson)
|
|
244
249
|
const hasExpressions = node.attrs.hasExpressions as boolean
|
|
245
250
|
const children = (node.attrs.children as string) || ''
|
|
@@ -121,6 +121,27 @@ export function toggleHeading(view: EditorView, level: number): void {
|
|
|
121
121
|
view.focus()
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Remove the link mark around the current cursor position.
|
|
126
|
+
* Finds the text node with a link mark at/near the selection and dispatches a removeMark transaction.
|
|
127
|
+
*/
|
|
128
|
+
export function removeLinkMark(view: EditorView): void {
|
|
129
|
+
const { state } = view
|
|
130
|
+
const { from, to } = state.selection
|
|
131
|
+
const linkType = state.schema.marks.link
|
|
132
|
+
if (!linkType) return
|
|
133
|
+
let linkFrom = from
|
|
134
|
+
let linkTo = to
|
|
135
|
+
state.doc.nodesBetween(from, from === to ? to + 1 : to, (node, pos) => {
|
|
136
|
+
if (linkType.isInSet(node.marks)) {
|
|
137
|
+
linkFrom = pos
|
|
138
|
+
linkTo = pos + node.nodeSize
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
view.dispatch(state.tr.removeMark(linkFrom, linkTo, linkType))
|
|
143
|
+
}
|
|
144
|
+
|
|
124
145
|
function formatsEqual(a: ActiveFormats, b: ActiveFormats): boolean {
|
|
125
146
|
return a.bold === b.bold
|
|
126
147
|
&& a.italic === b.italic
|
package/src/field-types.ts
CHANGED
|
@@ -1,47 +1,129 @@
|
|
|
1
1
|
import { z } from 'astro/zod'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Schema helpers for content collections.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* scanner detects them by name in the source and renders the
|
|
9
|
-
* appropriate editor input.
|
|
6
|
+
* Combines Zod passthrough methods with CMS-aware semantic types,
|
|
7
|
+
* so a content config can import only `n` instead of both `n` and `z`.
|
|
10
8
|
*
|
|
11
|
-
*
|
|
9
|
+
* Pass an options object to configure editor hints and Zod validation
|
|
10
|
+
* in one place. Chain `.orderBy('asc' | 'desc')` to mark the ordering field.
|
|
12
11
|
*
|
|
13
12
|
* @example
|
|
14
13
|
* ```ts
|
|
15
14
|
* import { n } from '@nuasite/cms'
|
|
16
|
-
* import { z } from 'astro/zod'
|
|
17
15
|
*
|
|
18
|
-
* const schema =
|
|
16
|
+
* const schema = n.object({
|
|
17
|
+
* title: n.text({ placeholder: "Enter title", maxLength: 120 }),
|
|
19
18
|
* photo: n.image(),
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* opensAt: n.time(),
|
|
26
|
-
* bio: n.textarea(),
|
|
19
|
+
* bio: n.textarea({ rows: 4, maxLength: 500 }),
|
|
20
|
+
* order: n.number({ min: 1, max: 100, step: 1 }).orderBy('asc'),
|
|
21
|
+
* date: n.date().orderBy('desc'),
|
|
22
|
+
* tags: n.array(n.string()),
|
|
23
|
+
* featured: n.boolean().default(false),
|
|
27
24
|
* })
|
|
28
25
|
* ```
|
|
29
26
|
*/
|
|
27
|
+
|
|
28
|
+
// --- Per-type hint interfaces ---
|
|
29
|
+
|
|
30
|
+
export interface NumberHints {
|
|
31
|
+
min?: number
|
|
32
|
+
max?: number
|
|
33
|
+
step?: number
|
|
34
|
+
placeholder?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TextHints {
|
|
38
|
+
placeholder?: string
|
|
39
|
+
maxLength?: number
|
|
40
|
+
minLength?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TextareaHints {
|
|
44
|
+
placeholder?: string
|
|
45
|
+
maxLength?: number
|
|
46
|
+
rows?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DateHints {
|
|
50
|
+
min?: string
|
|
51
|
+
max?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ImageHints {
|
|
55
|
+
accept?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- Internals ---
|
|
59
|
+
|
|
60
|
+
type OrderByDirection = 'asc' | 'desc'
|
|
61
|
+
type WithOrderBy<T> = T & { orderBy(direction?: OrderByDirection): T }
|
|
62
|
+
|
|
63
|
+
/** Normalize YAML Date objects to ISO date strings (YYYY-MM-DD) */
|
|
64
|
+
const toISODate = (v: unknown) => (v instanceof Date ? v.toISOString().slice(0, 10) : v)
|
|
65
|
+
/** Normalize YAML Date objects to ISO datetime strings */
|
|
66
|
+
const toISODatetime = (v: unknown) => (v instanceof Date ? v.toISOString() : v)
|
|
67
|
+
|
|
68
|
+
/** Add a chainable `.orderBy()` method to a Zod schema. The scanner detects it from source code. */
|
|
69
|
+
function withOrderBy<T extends z.ZodTypeAny>(schema: T): WithOrderBy<T> {
|
|
70
|
+
const s = schema as WithOrderBy<T>
|
|
71
|
+
s.orderBy = (_direction?: OrderByDirection) => schema
|
|
72
|
+
return s
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Build a CMS string field with optional length validation. Shared by text, url, email, textarea. */
|
|
76
|
+
function stringField(cmsType: string, hints?: { minLength?: number; maxLength?: number }) {
|
|
77
|
+
let schema = z.string()
|
|
78
|
+
if (hints?.minLength != null) schema = schema.min(hints.minLength)
|
|
79
|
+
if (hints?.maxLength != null) schema = schema.max(hints.maxLength)
|
|
80
|
+
return withOrderBy(schema.describe(`cms:${cmsType}`))
|
|
81
|
+
}
|
|
82
|
+
|
|
30
83
|
export const n = {
|
|
31
|
-
|
|
32
|
-
|
|
84
|
+
// --- Zod passthroughs ---
|
|
85
|
+
/** Object schema */
|
|
86
|
+
object: <T extends z.ZodRawShape>(shape: T) => z.object(shape),
|
|
87
|
+
/** Array schema */
|
|
88
|
+
array: <T extends z.ZodTypeAny>(schema: T) => z.array(schema),
|
|
89
|
+
/** Enum schema */
|
|
90
|
+
enum: <U extends string, T extends [U, ...U[]]>(values: T) => z.enum(values),
|
|
91
|
+
/** Coerce namespace — parses input into the target type */
|
|
92
|
+
coerce: {
|
|
93
|
+
date: () => withOrderBy(z.coerce.date()),
|
|
94
|
+
number: () => withOrderBy(z.coerce.number()),
|
|
95
|
+
string: () => withOrderBy(z.coerce.string()),
|
|
96
|
+
boolean: () => withOrderBy(z.coerce.boolean()),
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// --- CMS semantic types ---
|
|
100
|
+
/** Boolean / checkbox */
|
|
101
|
+
boolean: () => withOrderBy(z.boolean().describe('cms:checkbox')),
|
|
102
|
+
/** Number input with optional min/max/step */
|
|
103
|
+
number: (hints?: NumberHints) => {
|
|
104
|
+
let schema = z.number()
|
|
105
|
+
if (hints?.min != null) schema = schema.min(hints.min)
|
|
106
|
+
if (hints?.max != null) schema = schema.max(hints.max)
|
|
107
|
+
return withOrderBy(schema.describe('cms:number'))
|
|
108
|
+
},
|
|
109
|
+
/** Image picker (opens media library). Accepts hints for the scanner; no Zod validation applied. */
|
|
110
|
+
image: (_hints?: ImageHints) => withOrderBy(z.string().describe('cms:image')),
|
|
33
111
|
/** URL input */
|
|
34
|
-
url: () =>
|
|
112
|
+
url: (hints?: TextHints) => stringField('url', hints),
|
|
35
113
|
/** Email input */
|
|
36
|
-
email: () =>
|
|
114
|
+
email: (hints?: TextHints) => stringField('email', hints),
|
|
37
115
|
/** Color picker */
|
|
38
|
-
color: () => z.string().describe('cms:color'),
|
|
39
|
-
/** Date picker */
|
|
40
|
-
date: () => z.string().describe('cms:date'),
|
|
41
|
-
/** Date + time picker */
|
|
42
|
-
datetime: () => z.string().describe('cms:datetime'),
|
|
43
|
-
/** Time picker */
|
|
44
|
-
time: () => z.string().describe('cms:time'),
|
|
116
|
+
color: () => withOrderBy(z.string().describe('cms:color')),
|
|
117
|
+
/** Date picker (handles YAML Date coercion → ISO date string). Accepts hints for the scanner; no Zod validation applied. */
|
|
118
|
+
date: (_hints?: DateHints) => withOrderBy(z.preprocess(toISODate, z.string()).describe('cms:date')),
|
|
119
|
+
/** Date + time picker (handles YAML Date coercion → ISO datetime string). Accepts hints for the scanner; no Zod validation applied. */
|
|
120
|
+
datetime: (_hints?: DateHints) => withOrderBy(z.preprocess(toISODatetime, z.string()).describe('cms:datetime')),
|
|
121
|
+
/** Time picker. Accepts hints for the scanner; no Zod validation applied. */
|
|
122
|
+
time: (_hints?: DateHints) => withOrderBy(z.string().describe('cms:time')),
|
|
45
123
|
/** Multiline textarea */
|
|
46
|
-
textarea: () =>
|
|
124
|
+
textarea: (hints?: TextareaHints) => stringField('textarea', hints),
|
|
125
|
+
/** Text input */
|
|
126
|
+
text: (hints?: TextHints) => stringField('text', hints),
|
|
127
|
+
/** Plain string (no CMS type hint — type inferred from values) */
|
|
128
|
+
string: () => withOrderBy(z.string()),
|
|
47
129
|
}
|