@nuasite/cms 0.27.0 → 0.29.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 +10967 -10736
- package/package.json +1 -1
- package/src/collection-scanner.ts +203 -29
- package/src/dev-middleware.ts +86 -45
- package/src/editor/components/collections-browser.tsx +3 -11
- package/src/editor/components/create-page-modal.tsx +22 -10
- package/src/editor/components/fields.tsx +30 -8
- package/src/editor/components/frontmatter-fields.tsx +22 -4
- package/src/editor/components/link-edit-popover.tsx +232 -0
- package/src/editor/components/markdown-editor-overlay.tsx +16 -12
- package/src/editor/components/markdown-inline-editor.tsx +25 -52
- package/src/editor/components/mdx-block-view.tsx +21 -17
- package/src/editor/components/prop-editor.tsx +10 -5
- package/src/editor/hooks/useLinkPopover.ts +64 -0
- package/src/editor/milkdown-utils.ts +21 -0
- package/src/field-types.ts +111 -27
- package/src/handlers/api-routes.ts +10 -16
- package/src/index.ts +2 -0
- package/src/manifest-writer.ts +15 -0
- package/src/types.ts +19 -0
- package/src/vite-plugin.ts +18 -72
- package/src/content-invalidator.ts +0 -134
|
@@ -39,13 +39,18 @@ export interface TextFieldProps {
|
|
|
39
39
|
label: string
|
|
40
40
|
value: string | undefined
|
|
41
41
|
placeholder?: string
|
|
42
|
+
maxLength?: number
|
|
43
|
+
minLength?: number
|
|
42
44
|
onChange: (value: string) => void
|
|
43
45
|
isDirty?: boolean
|
|
44
46
|
onReset?: () => void
|
|
45
47
|
inputType?: string
|
|
48
|
+
required?: boolean
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
export function TextField(
|
|
51
|
+
export function TextField(
|
|
52
|
+
{ label, value, placeholder, maxLength, minLength, onChange, isDirty, onReset, inputType = 'text', required }: TextFieldProps,
|
|
53
|
+
) {
|
|
49
54
|
return (
|
|
50
55
|
<div class="space-y-1.5">
|
|
51
56
|
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
|
|
@@ -53,6 +58,9 @@ export function TextField({ label, value, placeholder, onChange, isDirty, onRese
|
|
|
53
58
|
type={inputType}
|
|
54
59
|
value={value ?? ''}
|
|
55
60
|
placeholder={placeholder}
|
|
61
|
+
maxLength={maxLength}
|
|
62
|
+
minLength={minLength}
|
|
63
|
+
required={required}
|
|
56
64
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
57
65
|
class={cn(
|
|
58
66
|
'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
@@ -78,9 +86,10 @@ export interface ImageFieldProps {
|
|
|
78
86
|
onBrowse: () => void
|
|
79
87
|
isDirty?: boolean
|
|
80
88
|
onReset?: () => void
|
|
89
|
+
required?: boolean
|
|
81
90
|
}
|
|
82
91
|
|
|
83
|
-
export function ImageField({ label, value, placeholder, onChange, onBrowse, isDirty, onReset }: ImageFieldProps) {
|
|
92
|
+
export function ImageField({ label, value, placeholder, onChange, onBrowse, isDirty, onReset, required }: ImageFieldProps) {
|
|
84
93
|
const hasImage = !!value && value.length > 0
|
|
85
94
|
|
|
86
95
|
return (
|
|
@@ -110,6 +119,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
|
|
|
110
119
|
type="text"
|
|
111
120
|
value={value ?? ''}
|
|
112
121
|
placeholder={placeholder}
|
|
122
|
+
required={required}
|
|
113
123
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
114
124
|
class={cn(
|
|
115
125
|
'flex-1 min-w-0 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
@@ -143,9 +153,10 @@ export interface ColorFieldProps {
|
|
|
143
153
|
onChange: (value: string) => void
|
|
144
154
|
isDirty?: boolean
|
|
145
155
|
onReset?: () => void
|
|
156
|
+
required?: boolean
|
|
146
157
|
}
|
|
147
158
|
|
|
148
|
-
export function ColorField({ label, value, placeholder, onChange, isDirty, onReset }: ColorFieldProps) {
|
|
159
|
+
export function ColorField({ label, value, placeholder, onChange, isDirty, onReset, required }: ColorFieldProps) {
|
|
149
160
|
const colorValue = value || '#000000'
|
|
150
161
|
// Validate hex for the native picker (must be #rrggbb)
|
|
151
162
|
const isValidHex = /^#[0-9a-fA-F]{6}$/.test(colorValue)
|
|
@@ -166,6 +177,7 @@ export function ColorField({ label, value, placeholder, onChange, isDirty, onRes
|
|
|
166
177
|
type="text"
|
|
167
178
|
value={value ?? ''}
|
|
168
179
|
placeholder={placeholder ?? '#000000'}
|
|
180
|
+
required={required}
|
|
169
181
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
170
182
|
class={cn(
|
|
171
183
|
'flex-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
@@ -270,12 +282,14 @@ export interface NumberFieldProps {
|
|
|
270
282
|
placeholder?: string
|
|
271
283
|
min?: number
|
|
272
284
|
max?: number
|
|
285
|
+
step?: number
|
|
273
286
|
onChange: (value: number | undefined) => void
|
|
274
287
|
isDirty?: boolean
|
|
275
288
|
onReset?: () => void
|
|
289
|
+
required?: boolean
|
|
276
290
|
}
|
|
277
291
|
|
|
278
|
-
export function NumberField({ label, value, placeholder, min, max, onChange, isDirty, onReset }: NumberFieldProps) {
|
|
292
|
+
export function NumberField({ label, value, placeholder, min, max, step, onChange, isDirty, onReset, required }: NumberFieldProps) {
|
|
279
293
|
return (
|
|
280
294
|
<div class="space-y-1.5">
|
|
281
295
|
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
|
|
@@ -285,6 +299,8 @@ export function NumberField({ label, value, placeholder, min, max, onChange, isD
|
|
|
285
299
|
placeholder={placeholder}
|
|
286
300
|
min={min}
|
|
287
301
|
max={max}
|
|
302
|
+
step={step}
|
|
303
|
+
required={required}
|
|
288
304
|
onInput={(e) => {
|
|
289
305
|
const val = (e.target as HTMLInputElement).value
|
|
290
306
|
onChange(val === '' ? undefined : Number(val))
|
|
@@ -330,9 +346,10 @@ export interface ComboBoxFieldProps {
|
|
|
330
346
|
onChange: (value: string) => void
|
|
331
347
|
isDirty?: boolean
|
|
332
348
|
onReset?: () => void
|
|
349
|
+
required?: boolean
|
|
333
350
|
}
|
|
334
351
|
|
|
335
|
-
export function ComboBoxField({ label, value, placeholder, options, onChange, isDirty, onReset }: ComboBoxFieldProps) {
|
|
352
|
+
export function ComboBoxField({ label, value, placeholder, options, onChange, isDirty, onReset, required }: ComboBoxFieldProps) {
|
|
336
353
|
const [query, setQuery] = useState('')
|
|
337
354
|
const [isOpen, setIsOpen] = useState(false)
|
|
338
355
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
@@ -372,6 +389,13 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
372
389
|
}, [onChange])
|
|
373
390
|
|
|
374
391
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
392
|
+
if (e.key === 'Enter') {
|
|
393
|
+
e.preventDefault()
|
|
394
|
+
if (isOpen && highlightedIndex >= 0 && filtered[highlightedIndex]) {
|
|
395
|
+
selectOption(filtered[highlightedIndex]!.value)
|
|
396
|
+
}
|
|
397
|
+
return
|
|
398
|
+
}
|
|
375
399
|
if (!isOpen || filtered.length === 0) return
|
|
376
400
|
if (e.key === 'ArrowDown') {
|
|
377
401
|
e.preventDefault()
|
|
@@ -379,9 +403,6 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
379
403
|
} else if (e.key === 'ArrowUp') {
|
|
380
404
|
e.preventDefault()
|
|
381
405
|
setHighlightedIndex(i => Math.max(i - 1, 0))
|
|
382
|
-
} else if (e.key === 'Enter' && highlightedIndex >= 0) {
|
|
383
|
-
e.preventDefault()
|
|
384
|
-
selectOption(filtered[highlightedIndex]!.value)
|
|
385
406
|
} else if (e.key === 'Escape') {
|
|
386
407
|
setIsOpen(false)
|
|
387
408
|
}
|
|
@@ -405,6 +426,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
405
426
|
type="text"
|
|
406
427
|
value={value ?? ''}
|
|
407
428
|
placeholder={placeholder}
|
|
429
|
+
required={required}
|
|
408
430
|
onInput={handleInput}
|
|
409
431
|
onFocus={handleFocus}
|
|
410
432
|
onBlur={handleBlur}
|
|
@@ -394,7 +394,8 @@ export function SchemaFrontmatterField({
|
|
|
394
394
|
value,
|
|
395
395
|
onChange,
|
|
396
396
|
}: SchemaFrontmatterFieldProps) {
|
|
397
|
-
const label = formatFieldLabel(field.name)
|
|
397
|
+
const label = field.required ? `${formatFieldLabel(field.name)} *` : formatFieldLabel(field.name)
|
|
398
|
+
const hints = field.hints
|
|
398
399
|
|
|
399
400
|
switch (field.type) {
|
|
400
401
|
case 'text':
|
|
@@ -404,9 +405,12 @@ export function SchemaFrontmatterField({
|
|
|
404
405
|
<TextField
|
|
405
406
|
label={label}
|
|
406
407
|
value={(value as string) ?? ''}
|
|
407
|
-
placeholder={getPlaceholder(field)}
|
|
408
|
+
placeholder={hints?.placeholder ?? getPlaceholder(field)}
|
|
409
|
+
maxLength={hints?.maxLength as number | undefined}
|
|
410
|
+
minLength={hints?.minLength as number | undefined}
|
|
408
411
|
onChange={(v) => onChange(v)}
|
|
409
412
|
inputType={field.type === 'text' ? undefined : field.type}
|
|
413
|
+
required={field.required}
|
|
410
414
|
/>
|
|
411
415
|
)
|
|
412
416
|
|
|
@@ -422,6 +426,7 @@ export function SchemaFrontmatterField({
|
|
|
422
426
|
onChange(url)
|
|
423
427
|
})
|
|
424
428
|
}}
|
|
429
|
+
required={field.required}
|
|
425
430
|
/>
|
|
426
431
|
)
|
|
427
432
|
|
|
@@ -432,6 +437,7 @@ export function SchemaFrontmatterField({
|
|
|
432
437
|
value={(value as string) ?? ''}
|
|
433
438
|
placeholder={getPlaceholder(field)}
|
|
434
439
|
onChange={(v) => onChange(v)}
|
|
440
|
+
required={field.required}
|
|
435
441
|
/>
|
|
436
442
|
)
|
|
437
443
|
|
|
@@ -442,8 +448,10 @@ export function SchemaFrontmatterField({
|
|
|
442
448
|
<textarea
|
|
443
449
|
value={(value as string) ?? ''}
|
|
444
450
|
onInput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
|
|
445
|
-
placeholder={getPlaceholder(field)}
|
|
446
|
-
rows={3}
|
|
451
|
+
placeholder={hints?.placeholder ?? getPlaceholder(field)}
|
|
452
|
+
rows={hints?.rows ?? 3}
|
|
453
|
+
maxLength={hints?.maxLength as number | undefined}
|
|
454
|
+
required={field.required}
|
|
447
455
|
class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40 resize-none"
|
|
448
456
|
data-cms-ui
|
|
449
457
|
/>
|
|
@@ -459,6 +467,9 @@ export function SchemaFrontmatterField({
|
|
|
459
467
|
<input
|
|
460
468
|
type={field.type === 'datetime' ? 'datetime-local' : field.type}
|
|
461
469
|
value={(value as string) ?? ''}
|
|
470
|
+
min={hints?.min != null ? String(hints.min) : undefined}
|
|
471
|
+
max={hints?.max != null ? String(hints.max) : undefined}
|
|
472
|
+
required={field.required}
|
|
462
473
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
463
474
|
class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white focus:outline-none focus:border-white/40"
|
|
464
475
|
data-cms-ui
|
|
@@ -471,7 +482,12 @@ export function SchemaFrontmatterField({
|
|
|
471
482
|
<NumberField
|
|
472
483
|
label={label}
|
|
473
484
|
value={(value as number) ?? undefined}
|
|
485
|
+
placeholder={hints?.placeholder}
|
|
486
|
+
min={typeof hints?.min === 'number' ? hints.min : undefined}
|
|
487
|
+
max={typeof hints?.max === 'number' ? hints.max : undefined}
|
|
488
|
+
step={hints?.step}
|
|
474
489
|
onChange={(v) => onChange(v ?? 0)}
|
|
490
|
+
required={field.required}
|
|
475
491
|
/>
|
|
476
492
|
)
|
|
477
493
|
|
|
@@ -495,6 +511,7 @@ export function SchemaFrontmatterField({
|
|
|
495
511
|
label: opt,
|
|
496
512
|
}))}
|
|
497
513
|
onChange={(v) => onChange(v)}
|
|
514
|
+
required={field.required}
|
|
498
515
|
/>
|
|
499
516
|
)
|
|
500
517
|
|
|
@@ -507,6 +524,7 @@ export function SchemaFrontmatterField({
|
|
|
507
524
|
placeholder={`Select ${label.toLowerCase()}...`}
|
|
508
525
|
options={refOptions}
|
|
509
526
|
onChange={(v) => onChange(v)}
|
|
527
|
+
required={field.required}
|
|
510
528
|
/>
|
|
511
529
|
)
|
|
512
530
|
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
|
+
import { HighlightMatch } from './fields'
|
|
3
|
+
|
|
4
|
+
export interface LinkSuggestion {
|
|
5
|
+
value: string
|
|
6
|
+
label: string
|
|
7
|
+
description?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface LinkEditPopoverProps {
|
|
11
|
+
initialUrl: string
|
|
12
|
+
suggestions?: LinkSuggestion[]
|
|
13
|
+
onApply: (url: string) => void
|
|
14
|
+
onRemove?: () => void
|
|
15
|
+
onClose: () => void
|
|
16
|
+
/** Use static positioning instead of absolute (for inline contexts) */
|
|
17
|
+
inline?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function LinkEditPopover({ initialUrl, suggestions, onApply, onRemove, onClose, inline }: LinkEditPopoverProps) {
|
|
21
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
22
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
23
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
24
|
+
const [query, setQuery] = useState(initialUrl)
|
|
25
|
+
const [showSuggestions, setShowSuggestions] = useState(false)
|
|
26
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
27
|
+
|
|
28
|
+
const filtered = useMemo(() => {
|
|
29
|
+
if (!suggestions?.length) return []
|
|
30
|
+
if (!query || query === 'https://') return suggestions
|
|
31
|
+
const q = query.toLowerCase()
|
|
32
|
+
return suggestions.filter(
|
|
33
|
+
o => o.value.toLowerCase().includes(q) || o.label.toLowerCase().includes(q),
|
|
34
|
+
)
|
|
35
|
+
}, [query, suggestions])
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const input = inputRef.current
|
|
39
|
+
if (input) {
|
|
40
|
+
input.focus()
|
|
41
|
+
input.select()
|
|
42
|
+
}
|
|
43
|
+
}, [])
|
|
44
|
+
|
|
45
|
+
// Close on click outside — uses `click` in bubble phase so form submit
|
|
46
|
+
// (which fires synchronously during the button's click) completes first
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const handler = (e: MouseEvent) => {
|
|
49
|
+
if (rootRef.current && !e.composedPath().includes(rootRef.current)) {
|
|
50
|
+
onClose()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
document.addEventListener('click', handler)
|
|
54
|
+
return () => document.removeEventListener('click', handler)
|
|
55
|
+
}, [onClose])
|
|
56
|
+
|
|
57
|
+
// Scroll highlighted item into view
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (highlightedIndex >= 0 && listRef.current) {
|
|
60
|
+
const item = listRef.current.children[highlightedIndex] as HTMLElement | undefined
|
|
61
|
+
item?.scrollIntoView({ block: 'nearest' })
|
|
62
|
+
}
|
|
63
|
+
}, [highlightedIndex])
|
|
64
|
+
|
|
65
|
+
const handleSubmit = useCallback((e: Event) => {
|
|
66
|
+
e.preventDefault()
|
|
67
|
+
const url = inputRef.current?.value.trim()
|
|
68
|
+
if (url) {
|
|
69
|
+
onApply(url)
|
|
70
|
+
}
|
|
71
|
+
}, [onApply])
|
|
72
|
+
|
|
73
|
+
const selectOption = useCallback((value: string) => {
|
|
74
|
+
if (inputRef.current) inputRef.current.value = value
|
|
75
|
+
setQuery(value)
|
|
76
|
+
setShowSuggestions(false)
|
|
77
|
+
onApply(value)
|
|
78
|
+
}, [onApply])
|
|
79
|
+
|
|
80
|
+
const handleInput = useCallback((e: Event) => {
|
|
81
|
+
const v = (e.target as HTMLInputElement).value
|
|
82
|
+
setQuery(v)
|
|
83
|
+
setShowSuggestions(true)
|
|
84
|
+
setHighlightedIndex(-1)
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
88
|
+
if (e.key === 'Escape') {
|
|
89
|
+
if (showSuggestions && filtered.length > 0) {
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
e.stopPropagation()
|
|
92
|
+
setShowSuggestions(false)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
e.preventDefault()
|
|
96
|
+
e.stopPropagation()
|
|
97
|
+
onClose()
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!showSuggestions || filtered.length === 0) return
|
|
102
|
+
|
|
103
|
+
if (e.key === 'ArrowDown') {
|
|
104
|
+
e.preventDefault()
|
|
105
|
+
setHighlightedIndex(i => Math.min(i + 1, filtered.length - 1))
|
|
106
|
+
} else if (e.key === 'ArrowUp') {
|
|
107
|
+
e.preventDefault()
|
|
108
|
+
setHighlightedIndex(i => Math.max(i - 1, 0))
|
|
109
|
+
} else if (e.key === 'Enter' && highlightedIndex >= 0) {
|
|
110
|
+
e.preventDefault()
|
|
111
|
+
selectOption(filtered[highlightedIndex]!.value)
|
|
112
|
+
}
|
|
113
|
+
}, [showSuggestions, filtered, highlightedIndex, selectOption, onClose])
|
|
114
|
+
|
|
115
|
+
const handleFocus = useCallback(() => {
|
|
116
|
+
setShowSuggestions(true)
|
|
117
|
+
}, [])
|
|
118
|
+
|
|
119
|
+
const handleBlur = useCallback(() => {
|
|
120
|
+
setTimeout(() => setShowSuggestions(false), 150)
|
|
121
|
+
}, [])
|
|
122
|
+
|
|
123
|
+
const showDropdown = showSuggestions && filtered.length > 0
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
ref={rootRef}
|
|
128
|
+
class={inline ? 'slide-in' : 'relative z-[9999] slide-in shrink-0'}
|
|
129
|
+
data-cms-ui
|
|
130
|
+
>
|
|
131
|
+
<form
|
|
132
|
+
onSubmit={handleSubmit}
|
|
133
|
+
class={`flex items-center gap-2 ${inline ? 'py-1.5' : 'px-4 py-2.5 bg-cms-dark border-b border-white/10'}`}
|
|
134
|
+
>
|
|
135
|
+
<svg class="w-4 h-4 text-white/40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
136
|
+
<path
|
|
137
|
+
stroke-linecap="round"
|
|
138
|
+
stroke-linejoin="round"
|
|
139
|
+
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
|
140
|
+
/>
|
|
141
|
+
</svg>
|
|
142
|
+
|
|
143
|
+
<div class="flex-1 min-w-0 relative">
|
|
144
|
+
<input
|
|
145
|
+
ref={inputRef}
|
|
146
|
+
type="text"
|
|
147
|
+
defaultValue={initialUrl}
|
|
148
|
+
placeholder="https://example.com or /page"
|
|
149
|
+
onInput={handleInput}
|
|
150
|
+
onFocus={handleFocus}
|
|
151
|
+
onBlur={handleBlur}
|
|
152
|
+
onKeyDown={handleKeyDown}
|
|
153
|
+
autocomplete="off"
|
|
154
|
+
class="w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white placeholder:text-white/30 outline-none focus:border-cms-primary/50 transition-colors"
|
|
155
|
+
data-cms-ui
|
|
156
|
+
/>
|
|
157
|
+
{showDropdown && (
|
|
158
|
+
<div
|
|
159
|
+
ref={listRef}
|
|
160
|
+
class="absolute z-50 left-0 right-0 mt-1 max-h-48 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-sm shadow-lg"
|
|
161
|
+
data-cms-ui
|
|
162
|
+
>
|
|
163
|
+
{filtered.map((opt, i) => (
|
|
164
|
+
<button
|
|
165
|
+
key={opt.value}
|
|
166
|
+
type="button"
|
|
167
|
+
onMouseDown={(e) => {
|
|
168
|
+
e.preventDefault()
|
|
169
|
+
selectOption(opt.value)
|
|
170
|
+
}}
|
|
171
|
+
class={`w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer ${
|
|
172
|
+
i === highlightedIndex
|
|
173
|
+
? 'bg-white/15 text-white'
|
|
174
|
+
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
|
175
|
+
}`}
|
|
176
|
+
data-cms-ui
|
|
177
|
+
>
|
|
178
|
+
<span class="block truncate font-medium">
|
|
179
|
+
<HighlightMatch text={opt.label} query={query === 'https://' ? '' : query} />
|
|
180
|
+
</span>
|
|
181
|
+
{opt.description && (
|
|
182
|
+
<span class="block truncate text-white/40">
|
|
183
|
+
<HighlightMatch text={opt.description} query={query === 'https://' ? '' : query} />
|
|
184
|
+
</span>
|
|
185
|
+
)}
|
|
186
|
+
</button>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<button
|
|
193
|
+
type="submit"
|
|
194
|
+
class="px-3 py-1.5 bg-cms-primary text-cms-primary-text text-[12px] font-medium rounded-cms-sm hover:bg-cms-primary-hover transition-colors shrink-0"
|
|
195
|
+
data-cms-ui
|
|
196
|
+
>
|
|
197
|
+
Apply
|
|
198
|
+
</button>
|
|
199
|
+
|
|
200
|
+
{onRemove && (
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={onRemove}
|
|
204
|
+
class="p-1.5 text-white/40 hover:text-red-400 hover:bg-red-500/10 rounded-cms-sm transition-colors shrink-0"
|
|
205
|
+
title="Remove link"
|
|
206
|
+
data-cms-ui
|
|
207
|
+
>
|
|
208
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
209
|
+
<path
|
|
210
|
+
stroke-linecap="round"
|
|
211
|
+
stroke-linejoin="round"
|
|
212
|
+
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
|
213
|
+
/>
|
|
214
|
+
</svg>
|
|
215
|
+
</button>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
onClick={onClose}
|
|
221
|
+
class="p-1.5 text-white/40 hover:text-white hover:bg-white/10 rounded-cms-sm transition-colors shrink-0"
|
|
222
|
+
title="Cancel"
|
|
223
|
+
data-cms-ui
|
|
224
|
+
>
|
|
225
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
226
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
227
|
+
</svg>
|
|
228
|
+
</button>
|
|
229
|
+
</form>
|
|
230
|
+
</div>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
@@ -375,11 +375,22 @@ export function MarkdownEditorOverlay() {
|
|
|
375
375
|
onMouseDown={stopPropagation}
|
|
376
376
|
onClick={stopPropagation}
|
|
377
377
|
>
|
|
378
|
-
<
|
|
378
|
+
<form
|
|
379
379
|
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 ${
|
|
380
380
|
hasSidebar ? 'max-w-6xl' : 'max-w-4xl'
|
|
381
381
|
}`}
|
|
382
382
|
data-cms-ui
|
|
383
|
+
onSubmit={(e) => {
|
|
384
|
+
e.preventDefault()
|
|
385
|
+
if (isCreateMode) {
|
|
386
|
+
handleCreate()
|
|
387
|
+
} else {
|
|
388
|
+
const currentContent = currentMarkdownPage.value?.content
|
|
389
|
+
if (currentContent !== undefined) {
|
|
390
|
+
handleSave(currentContent)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}}
|
|
383
394
|
>
|
|
384
395
|
{/* Header */}
|
|
385
396
|
<div class="flex items-center justify-between px-5 py-4 border-b border-white/10">
|
|
@@ -495,9 +506,8 @@ export function MarkdownEditorOverlay() {
|
|
|
495
506
|
{isCreateMode
|
|
496
507
|
? (
|
|
497
508
|
<button
|
|
498
|
-
type="
|
|
499
|
-
|
|
500
|
-
disabled={isSaving || !(page.frontmatter.title as string)?.trim()}
|
|
509
|
+
type="submit"
|
|
510
|
+
disabled={isSaving}
|
|
501
511
|
class="px-4 py-2 text-sm bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-pill transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
502
512
|
data-cms-ui
|
|
503
513
|
>
|
|
@@ -507,13 +517,7 @@ export function MarkdownEditorOverlay() {
|
|
|
507
517
|
)
|
|
508
518
|
: (
|
|
509
519
|
<button
|
|
510
|
-
type="
|
|
511
|
-
onClick={() => {
|
|
512
|
-
const currentContent = currentMarkdownPage.value?.content
|
|
513
|
-
if (currentContent !== undefined) {
|
|
514
|
-
handleSave(currentContent)
|
|
515
|
-
}
|
|
516
|
-
}}
|
|
520
|
+
type="submit"
|
|
517
521
|
disabled={isSaving}
|
|
518
522
|
class="px-4 py-2 text-sm bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-pill transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
519
523
|
data-cms-ui
|
|
@@ -577,7 +581,7 @@ export function MarkdownEditorOverlay() {
|
|
|
577
581
|
/>
|
|
578
582
|
)}
|
|
579
583
|
</div>
|
|
580
|
-
</
|
|
584
|
+
</form>
|
|
581
585
|
</div>
|
|
582
586
|
)
|
|
583
587
|
}
|
|
@@ -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' : ''}`}
|