@nuasite/cms 0.29.0 → 0.30.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 +11959 -12025
- package/package.json +1 -1
- package/src/editor/components/attribute-editor.tsx +2 -10
- package/src/editor/components/bg-image-overlay.tsx +2 -10
- package/src/editor/components/collections-browser.tsx +5 -13
- package/src/editor/components/color-toolbar.tsx +2 -9
- package/src/editor/components/confirm-dialog.tsx +4 -12
- package/src/editor/components/create-page-modal.tsx +1 -9
- package/src/editor/components/fields.tsx +134 -116
- package/src/editor/components/link-edit-popover.tsx +3 -6
- package/src/editor/components/markdown-editor-overlay.tsx +31 -37
- package/src/editor/components/markdown-inline-editor.tsx +2 -1
- package/src/editor/components/mdx-component-picker.tsx +3 -6
- package/src/editor/components/media-library.tsx +15 -37
- package/src/editor/components/modal-shell.tsx +34 -5
- package/src/editor/components/prop-editor.tsx +67 -68
- package/src/editor/components/reference-picker.tsx +6 -24
- package/src/editor/components/seo-editor.tsx +4 -10
- package/src/editor/components/spinner.tsx +17 -0
- package/src/editor/components/toolbar.tsx +2 -1
- package/src/editor/constants.ts +33 -0
- package/src/editor/hooks/index.ts +4 -0
- package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
- package/src/editor/hooks/useSearchFilter.ts +21 -0
- package/src/html-processor.ts +75 -94
- package/src/index.ts +5 -0
- package/src/rehype-cms-marker.ts +15 -0
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import * as signals from '../signals'
|
|
|
4
4
|
import { saveAttributeEditsToStorage } from '../storage'
|
|
5
5
|
import type { Attribute } from '../types'
|
|
6
6
|
import { ComboBoxField, FieldLabel, ImageField, NumberField, SelectField, TextField, ToggleField } from './fields'
|
|
7
|
+
import { CloseButton } from './modal-shell'
|
|
7
8
|
|
|
8
9
|
// ============================================================================
|
|
9
10
|
// Attribute Field Configuration
|
|
@@ -542,16 +543,7 @@ export function AttributeEditor({ onClose }: AttributeEditorProps) {
|
|
|
542
543
|
</span>
|
|
543
544
|
)}
|
|
544
545
|
</div>
|
|
545
|
-
<
|
|
546
|
-
type="button"
|
|
547
|
-
onClick={handleClose}
|
|
548
|
-
class="text-white/50 hover:text-white cursor-pointer p-1.5 hover:bg-white/10 rounded-full transition-colors"
|
|
549
|
-
data-cms-ui
|
|
550
|
-
>
|
|
551
|
-
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
552
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
553
|
-
</svg>
|
|
554
|
-
</button>
|
|
546
|
+
<CloseButton onClick={handleClose} size="sm" />
|
|
555
547
|
</div>
|
|
556
548
|
|
|
557
549
|
{/* Content */}
|
|
@@ -5,6 +5,7 @@ import { cn } from '../lib/cn'
|
|
|
5
5
|
import * as signals from '../signals'
|
|
6
6
|
import { saveBgImageEditsToStorage } from '../storage'
|
|
7
7
|
import { FieldLabel, ImageField, SelectField } from './fields'
|
|
8
|
+
import { CloseButton } from './modal-shell'
|
|
8
9
|
|
|
9
10
|
export interface BgImageOverlayProps {
|
|
10
11
|
visible: boolean
|
|
@@ -232,16 +233,7 @@ export function BgImageOverlay({ visible, rect, element, cmsId }: BgImageOverlay
|
|
|
232
233
|
</span>
|
|
233
234
|
)}
|
|
234
235
|
</div>
|
|
235
|
-
<
|
|
236
|
-
type="button"
|
|
237
|
-
onClick={handleClose}
|
|
238
|
-
class="text-white/50 hover:text-white cursor-pointer p-1.5 hover:bg-white/10 rounded-full transition-colors"
|
|
239
|
-
data-cms-ui
|
|
240
|
-
>
|
|
241
|
-
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
242
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
243
|
-
</svg>
|
|
244
|
-
</button>
|
|
236
|
+
<CloseButton onClick={handleClose} size="sm" />
|
|
245
237
|
</div>
|
|
246
238
|
|
|
247
239
|
{/* Content */}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { signal } from '@preact/signals'
|
|
2
2
|
import { useMemo, useState } from 'preact/hooks'
|
|
3
|
+
import { useSearchFilter } from '../hooks/useSearchFilter'
|
|
3
4
|
import { deleteMarkdownPage } from '../markdown-api'
|
|
4
5
|
import {
|
|
5
6
|
closeCollectionsBrowser,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
selectedBrowserCollection,
|
|
13
14
|
} from '../signals'
|
|
14
15
|
import { ChevronRightIcon, CollectionIcon } from './create-page-modal'
|
|
15
|
-
import { CloseButton, ModalBackdrop, ModalHeader } from './modal-shell'
|
|
16
|
+
import { CloseButton, ModalBackdrop, ModalHeader, PrimaryButton } from './modal-shell'
|
|
16
17
|
|
|
17
18
|
const deletingEntry = signal<string | null>(null)
|
|
18
19
|
const confirmDeleteSlug = signal<string | null>(null)
|
|
@@ -33,11 +34,7 @@ export function CollectionsBrowser() {
|
|
|
33
34
|
const selectedDef = selected ? collectionDefinitions[selected] : undefined
|
|
34
35
|
const entries = selectedDef?.entries ?? EMPTY_ENTRIES
|
|
35
36
|
|
|
36
|
-
const filteredEntries =
|
|
37
|
-
if (!search) return entries
|
|
38
|
-
const q = search.toLowerCase()
|
|
39
|
-
return entries.filter(e => (e.title || '').toLowerCase().includes(q) || e.slug.toLowerCase().includes(q))
|
|
40
|
-
}, [entries, search])
|
|
37
|
+
const filteredEntries = useSearchFilter(entries, search, e => `${e.title ?? ''} ${e.slug}`)
|
|
41
38
|
|
|
42
39
|
if (!visible) return null
|
|
43
40
|
|
|
@@ -116,14 +113,9 @@ export function CollectionsBrowser() {
|
|
|
116
113
|
<h2 class="text-lg font-semibold text-white">{def.label}</h2>
|
|
117
114
|
</div>
|
|
118
115
|
<div class="flex items-center gap-2">
|
|
119
|
-
<
|
|
120
|
-
type="button"
|
|
121
|
-
onClick={handleAddNew}
|
|
122
|
-
class="px-3 py-1.5 text-sm font-medium text-black bg-cms-primary hover:bg-cms-primary/80 rounded-cms-pill transition-colors"
|
|
123
|
-
data-cms-ui
|
|
124
|
-
>
|
|
116
|
+
<PrimaryButton onClick={handleAddNew} className="px-3 py-1.5">
|
|
125
117
|
+ Add New
|
|
126
|
-
</
|
|
118
|
+
</PrimaryButton>
|
|
127
119
|
<CloseButton onClick={handleClose} />
|
|
128
120
|
</div>
|
|
129
121
|
</div>
|
|
@@ -12,6 +12,7 @@ import { CSS, Z_INDEX } from '../constants'
|
|
|
12
12
|
import { cn } from '../lib/cn'
|
|
13
13
|
import * as signals from '../signals'
|
|
14
14
|
import type { Attribute, AvailableColors } from '../types'
|
|
15
|
+
import { CloseButton } from './modal-shell'
|
|
15
16
|
|
|
16
17
|
export interface ColorToolbarProps {
|
|
17
18
|
visible: boolean
|
|
@@ -247,15 +248,7 @@ export function ColorToolbar({
|
|
|
247
248
|
{/* Header */}
|
|
248
249
|
<div class="flex items-center justify-between">
|
|
249
250
|
<span class="font-medium text-white">Element Colors</span>
|
|
250
|
-
<
|
|
251
|
-
type="button"
|
|
252
|
-
onClick={onClose}
|
|
253
|
-
class="text-white/50 hover:text-white cursor-pointer p-1.5 hover:bg-white/10 rounded-full transition-colors"
|
|
254
|
-
>
|
|
255
|
-
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
256
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
257
|
-
</svg>
|
|
258
|
-
</button>
|
|
251
|
+
{onClose && <CloseButton onClick={onClose} size="sm" />}
|
|
259
252
|
</div>
|
|
260
253
|
|
|
261
254
|
{/* Background color section */}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cn } from '../lib/cn'
|
|
2
2
|
import { confirmDialogState } from '../signals'
|
|
3
|
-
import { ModalBackdrop } from './modal-shell'
|
|
3
|
+
import { CancelButton, ModalBackdrop, ModalFooter } from './modal-shell'
|
|
4
4
|
|
|
5
5
|
export function ConfirmDialog() {
|
|
6
6
|
const state = confirmDialogState.value
|
|
@@ -27,16 +27,8 @@ export function ConfirmDialog() {
|
|
|
27
27
|
<p class="text-sm text-white/70 leading-relaxed">{state.message}</p>
|
|
28
28
|
</div>
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
<button
|
|
33
|
-
type="button"
|
|
34
|
-
onClick={handleCancel}
|
|
35
|
-
class="px-4 py-2.5 text-sm text-white/80 font-medium rounded-cms-pill hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
|
|
36
|
-
data-cms-ui
|
|
37
|
-
>
|
|
38
|
-
{state.cancelLabel}
|
|
39
|
-
</button>
|
|
30
|
+
<ModalFooter>
|
|
31
|
+
<CancelButton onClick={handleCancel} label={state.cancelLabel} />
|
|
40
32
|
<button
|
|
41
33
|
type="button"
|
|
42
34
|
onClick={handleConfirm}
|
|
@@ -50,7 +42,7 @@ export function ConfirmDialog() {
|
|
|
50
42
|
>
|
|
51
43
|
{state.confirmLabel}
|
|
52
44
|
</button>
|
|
53
|
-
</
|
|
45
|
+
</ModalFooter>
|
|
54
46
|
</ModalBackdrop>
|
|
55
47
|
)
|
|
56
48
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from '../signals'
|
|
15
15
|
import type { LayoutInfo } from '../types'
|
|
16
16
|
import { CancelButton, ModalBackdrop, ModalFooter, ModalHeader } from './modal-shell'
|
|
17
|
+
import { Spinner } from './spinner'
|
|
17
18
|
|
|
18
19
|
export function CreatePageModal() {
|
|
19
20
|
const visible = isCreatePageOpen.value
|
|
@@ -494,15 +495,6 @@ function PageCreatingOverlay({ phase, slug }: { phase: 'creating' | 'preparing';
|
|
|
494
495
|
)
|
|
495
496
|
}
|
|
496
497
|
|
|
497
|
-
function Spinner() {
|
|
498
|
-
return (
|
|
499
|
-
<svg class="w-8 h-8 animate-spin text-cms-primary" viewBox="0 0 24 24" fill="none" data-cms-ui>
|
|
500
|
-
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
|
|
501
|
-
<path class="opacity-80" d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
|
|
502
|
-
</svg>
|
|
503
|
-
)
|
|
504
|
-
}
|
|
505
|
-
|
|
506
498
|
/**
|
|
507
499
|
* Poll a URL until the dev server returns a non-404 response,
|
|
508
500
|
* so navigation doesn't land on a 404 while Astro processes the new file.
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import type { ComponentChildren } from 'preact'
|
|
1
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import { getDropdownPosition } from '../constants'
|
|
4
|
+
import { useClickOutsideEscape } from '../hooks/useClickOutsideEscape'
|
|
5
|
+
import { useSearchFilter } from '../hooks/useSearchFilter'
|
|
2
6
|
import { cn } from '../lib/cn'
|
|
3
7
|
|
|
4
8
|
// ============================================================================
|
|
@@ -334,6 +338,48 @@ export function HighlightMatch({ text, query }: { text: string; query: string })
|
|
|
334
338
|
)
|
|
335
339
|
}
|
|
336
340
|
|
|
341
|
+
// ============================================================================
|
|
342
|
+
// Dropdown Panel (fixed-position container for select/combobox dropdowns)
|
|
343
|
+
// ============================================================================
|
|
344
|
+
|
|
345
|
+
export interface DropdownPanelProps {
|
|
346
|
+
/** Ref to the trigger element — used for positioning and outside-click detection */
|
|
347
|
+
triggerRef: { readonly current: HTMLElement | null }
|
|
348
|
+
isOpen: boolean
|
|
349
|
+
onClose: () => void
|
|
350
|
+
maxHeight?: number
|
|
351
|
+
children: ComponentChildren
|
|
352
|
+
className?: string
|
|
353
|
+
/** Forward a ref to the panel div (e.g. for keyboard-nav scroll) */
|
|
354
|
+
panelRef?: { current: HTMLDivElement | null }
|
|
355
|
+
/** Additional refs to exempt from outside-click detection (e.g. a wrapper containing related UI like selected tags) */
|
|
356
|
+
exemptRefs?: ReadonlyArray<{ readonly current: HTMLElement | null }>
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Fixed-position dropdown container that escapes parent overflow clipping.
|
|
361
|
+
* Handles outside-click and Escape-key dismissal.
|
|
362
|
+
*/
|
|
363
|
+
export function DropdownPanel({ triggerRef, isOpen, onClose, maxHeight = 192, children, className, panelRef, exemptRefs }: DropdownPanelProps) {
|
|
364
|
+
const internalRef = useRef<HTMLDivElement>(null)
|
|
365
|
+
const ref = panelRef ?? internalRef
|
|
366
|
+
|
|
367
|
+
useClickOutsideEscape([ref, triggerRef, ...(exemptRefs ?? [])], isOpen, onClose)
|
|
368
|
+
|
|
369
|
+
if (!isOpen) return null
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<div
|
|
373
|
+
ref={ref}
|
|
374
|
+
class={cn('overflow-y-auto bg-cms-dark shadow-lg', className)}
|
|
375
|
+
style={getDropdownPosition(triggerRef.current, maxHeight)}
|
|
376
|
+
data-cms-ui
|
|
377
|
+
>
|
|
378
|
+
{children}
|
|
379
|
+
</div>
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
|
|
337
383
|
// ============================================================================
|
|
338
384
|
// ComboBox Field (searchable dropdown with free-text input)
|
|
339
385
|
// ============================================================================
|
|
@@ -356,14 +402,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
356
402
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
357
403
|
const listRef = useRef<HTMLDivElement>(null)
|
|
358
404
|
|
|
359
|
-
|
|
360
|
-
const filtered = useMemo(() => {
|
|
361
|
-
if (!query) return options
|
|
362
|
-
const q = query.toLowerCase()
|
|
363
|
-
return options.filter(
|
|
364
|
-
o => o.value.toLowerCase().includes(q) || o.label.toLowerCase().includes(q),
|
|
365
|
-
)
|
|
366
|
-
}, [query, options])
|
|
405
|
+
const filtered = useSearchFilter(options, query, o => `${o.label} ${o.value}`)
|
|
367
406
|
|
|
368
407
|
const handleInput = useCallback((e: Event) => {
|
|
369
408
|
const v = (e.target as HTMLInputElement).value
|
|
@@ -373,21 +412,14 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
373
412
|
setHighlightedIndex(-1)
|
|
374
413
|
}, [onChange])
|
|
375
414
|
|
|
376
|
-
const handleFocus = useCallback(() => {
|
|
377
|
-
setIsOpen(true)
|
|
378
|
-
}, [])
|
|
379
|
-
|
|
380
|
-
const handleBlur = useCallback(() => {
|
|
381
|
-
// Delay to allow click on option to register
|
|
382
|
-
setTimeout(() => setIsOpen(false), 150)
|
|
383
|
-
}, [])
|
|
384
|
-
|
|
385
415
|
const selectOption = useCallback((optValue: string) => {
|
|
386
416
|
onChange(optValue)
|
|
387
417
|
setQuery('')
|
|
388
418
|
setIsOpen(false)
|
|
389
419
|
}, [onChange])
|
|
390
420
|
|
|
421
|
+
const closeDropdown = useCallback(() => setIsOpen(false), [])
|
|
422
|
+
|
|
391
423
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
392
424
|
if (e.key === 'Enter') {
|
|
393
425
|
e.preventDefault()
|
|
@@ -403,8 +435,6 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
403
435
|
} else if (e.key === 'ArrowUp') {
|
|
404
436
|
e.preventDefault()
|
|
405
437
|
setHighlightedIndex(i => Math.max(i - 1, 0))
|
|
406
|
-
} else if (e.key === 'Escape') {
|
|
407
|
-
setIsOpen(false)
|
|
408
438
|
}
|
|
409
439
|
}, [isOpen, filtered, highlightedIndex, selectOption])
|
|
410
440
|
|
|
@@ -419,7 +449,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
419
449
|
const showDropdown = isOpen && filtered.length > 0
|
|
420
450
|
|
|
421
451
|
return (
|
|
422
|
-
<div class="space-y-1.5
|
|
452
|
+
<div class="space-y-1.5">
|
|
423
453
|
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
|
|
424
454
|
<input
|
|
425
455
|
ref={inputRef}
|
|
@@ -428,8 +458,8 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
428
458
|
placeholder={placeholder}
|
|
429
459
|
required={required}
|
|
430
460
|
onInput={handleInput}
|
|
431
|
-
onFocus={
|
|
432
|
-
onBlur={
|
|
461
|
+
onFocus={() => setIsOpen(true)}
|
|
462
|
+
onBlur={() => setTimeout(closeDropdown, 150)}
|
|
433
463
|
onKeyDown={handleKeyDown}
|
|
434
464
|
autocomplete="off"
|
|
435
465
|
class={cn(
|
|
@@ -440,40 +470,41 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
440
470
|
)}
|
|
441
471
|
data-cms-ui
|
|
442
472
|
/>
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
473
|
+
<DropdownPanel
|
|
474
|
+
triggerRef={inputRef}
|
|
475
|
+
isOpen={showDropdown}
|
|
476
|
+
onClose={closeDropdown}
|
|
477
|
+
maxHeight={160}
|
|
478
|
+
panelRef={listRef}
|
|
479
|
+
className="border border-white/15 rounded-cms-sm"
|
|
480
|
+
>
|
|
481
|
+
{filtered.map((opt, i) => (
|
|
482
|
+
<button
|
|
483
|
+
key={opt.value}
|
|
484
|
+
type="button"
|
|
485
|
+
onMouseDown={(e) => {
|
|
486
|
+
e.preventDefault()
|
|
487
|
+
selectOption(opt.value)
|
|
488
|
+
}}
|
|
489
|
+
class={cn(
|
|
490
|
+
'w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer',
|
|
491
|
+
i === highlightedIndex
|
|
492
|
+
? 'bg-white/15 text-white'
|
|
493
|
+
: 'text-white/70 hover:bg-white/10 hover:text-white',
|
|
494
|
+
)}
|
|
495
|
+
data-cms-ui
|
|
496
|
+
>
|
|
497
|
+
<span class="block truncate font-medium">
|
|
498
|
+
<HighlightMatch text={opt.label} query={query} />
|
|
499
|
+
</span>
|
|
500
|
+
{opt.description && (
|
|
501
|
+
<span class="block truncate text-white/40">
|
|
502
|
+
<HighlightMatch text={opt.description} query={query} />
|
|
467
503
|
</span>
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
)}
|
|
473
|
-
</button>
|
|
474
|
-
))}
|
|
475
|
-
</div>
|
|
476
|
-
)}
|
|
504
|
+
)}
|
|
505
|
+
</button>
|
|
506
|
+
))}
|
|
507
|
+
</DropdownPanel>
|
|
477
508
|
</div>
|
|
478
509
|
)
|
|
479
510
|
}
|
|
@@ -510,11 +541,7 @@ export function MultiSelectField({ label, selected, options, onChange, isDirty,
|
|
|
510
541
|
return map
|
|
511
542
|
}, [normalizedOptions])
|
|
512
543
|
|
|
513
|
-
const filtered =
|
|
514
|
-
if (!query) return normalizedOptions
|
|
515
|
-
const q = query.toLowerCase()
|
|
516
|
-
return normalizedOptions.filter(o => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
|
|
517
|
-
}, [query, normalizedOptions])
|
|
544
|
+
const filtered = useSearchFilter(normalizedOptions, query, o => `${o.label} ${o.value}`)
|
|
518
545
|
|
|
519
546
|
const toggleOption = useCallback((value: string) => {
|
|
520
547
|
if (selected.includes(value)) {
|
|
@@ -524,20 +551,10 @@ export function MultiSelectField({ label, selected, options, onChange, isDirty,
|
|
|
524
551
|
}
|
|
525
552
|
}, [selected, onChange])
|
|
526
553
|
|
|
527
|
-
|
|
528
|
-
useEffect(() => {
|
|
529
|
-
if (!isOpen) return
|
|
530
|
-
const handler = (e: MouseEvent) => {
|
|
531
|
-
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
532
|
-
setIsOpen(false)
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
document.addEventListener('mousedown', handler)
|
|
536
|
-
return () => document.removeEventListener('mousedown', handler)
|
|
537
|
-
}, [isOpen])
|
|
554
|
+
const closeDropdown = useCallback(() => setIsOpen(false), [])
|
|
538
555
|
|
|
539
556
|
return (
|
|
540
|
-
<div class="space-y-1.5
|
|
557
|
+
<div class="space-y-1.5" ref={containerRef} data-cms-ui>
|
|
541
558
|
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
|
|
542
559
|
|
|
543
560
|
{/* Selected pills */}
|
|
@@ -580,54 +597,55 @@ export function MultiSelectField({ label, selected, options, onChange, isDirty,
|
|
|
580
597
|
data-cms-ui
|
|
581
598
|
/>
|
|
582
599
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
600
|
+
<DropdownPanel
|
|
601
|
+
triggerRef={inputRef}
|
|
602
|
+
isOpen={isOpen}
|
|
603
|
+
onClose={closeDropdown}
|
|
604
|
+
maxHeight={192}
|
|
605
|
+
className="border border-white/15 rounded-cms-sm"
|
|
606
|
+
exemptRefs={[containerRef]}
|
|
607
|
+
>
|
|
608
|
+
{filtered.length === 0
|
|
609
|
+
? <div class="px-3 py-2 text-xs text-white/40">No options found</div>
|
|
610
|
+
: filtered.map(opt => {
|
|
611
|
+
const isSelected = selected.includes(opt.value)
|
|
612
|
+
return (
|
|
613
|
+
<button
|
|
614
|
+
key={opt.value}
|
|
615
|
+
type="button"
|
|
616
|
+
onMouseDown={(e) => {
|
|
617
|
+
e.preventDefault()
|
|
618
|
+
toggleOption(opt.value)
|
|
619
|
+
}}
|
|
620
|
+
class={cn(
|
|
621
|
+
'w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer flex items-center gap-2',
|
|
622
|
+
isSelected
|
|
623
|
+
? 'bg-cms-primary/10 text-white'
|
|
624
|
+
: 'text-white/70 hover:bg-white/10 hover:text-white',
|
|
625
|
+
)}
|
|
626
|
+
data-cms-ui
|
|
627
|
+
>
|
|
628
|
+
<span
|
|
601
629
|
class={cn(
|
|
602
|
-
'w-
|
|
630
|
+
'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
|
|
603
631
|
isSelected
|
|
604
|
-
? 'bg-cms-primary
|
|
605
|
-
: '
|
|
632
|
+
? 'bg-cms-primary border-cms-primary'
|
|
633
|
+
: 'border-white/30 bg-white/5',
|
|
606
634
|
)}
|
|
607
|
-
data-cms-ui
|
|
608
635
|
>
|
|
609
|
-
|
|
610
|
-
class=
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
</span>
|
|
623
|
-
<span class="truncate font-medium">
|
|
624
|
-
{query ? <HighlightMatch text={opt.label} query={query} /> : opt.label}
|
|
625
|
-
</span>
|
|
626
|
-
</button>
|
|
627
|
-
)
|
|
628
|
-
})}
|
|
629
|
-
</div>
|
|
630
|
-
)}
|
|
636
|
+
{isSelected && (
|
|
637
|
+
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
638
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
|
639
|
+
</svg>
|
|
640
|
+
)}
|
|
641
|
+
</span>
|
|
642
|
+
<span class="truncate font-medium">
|
|
643
|
+
{query ? <HighlightMatch text={opt.label} query={query} /> : opt.label}
|
|
644
|
+
</span>
|
|
645
|
+
</button>
|
|
646
|
+
)
|
|
647
|
+
})}
|
|
648
|
+
</DropdownPanel>
|
|
631
649
|
</div>
|
|
632
650
|
)
|
|
633
651
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
2
|
import { HighlightMatch } from './fields'
|
|
3
|
+
import { PrimaryButton } from './modal-shell'
|
|
3
4
|
|
|
4
5
|
export interface LinkSuggestion {
|
|
5
6
|
value: string
|
|
@@ -189,13 +190,9 @@ export function LinkEditPopover({ initialUrl, suggestions, onApply, onRemove, on
|
|
|
189
190
|
)}
|
|
190
191
|
</div>
|
|
191
192
|
|
|
192
|
-
<
|
|
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
|
-
>
|
|
193
|
+
<PrimaryButton type="submit" className="px-3 py-1.5 text-[12px] rounded-cms-sm shrink-0">
|
|
197
194
|
Apply
|
|
198
|
-
</
|
|
195
|
+
</PrimaryButton>
|
|
199
196
|
|
|
200
197
|
{onRemove && (
|
|
201
198
|
<button
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
import { CreateModeFrontmatter, EditModeFrontmatter } from './frontmatter-fields'
|
|
21
21
|
import { FrontmatterSidebar, partitionFields } from './frontmatter-sidebar'
|
|
22
22
|
import { MarkdownInlineEditor } from './markdown-inline-editor'
|
|
23
|
+
import { CancelButton, PrimaryButton } from './modal-shell'
|
|
24
|
+
import { Spinner } from './spinner'
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Wrapper component that renders the editor in place of markdown content.
|
|
@@ -55,6 +57,17 @@ export function MarkdownEditorOverlay() {
|
|
|
55
57
|
const previewTargetRef = useRef<HTMLElement | null>(null)
|
|
56
58
|
const editorInstanceRef = useRef<Editor | null>(null)
|
|
57
59
|
|
|
60
|
+
// Lock page scroll while the modal overlay is visible (not during preview)
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!page || isPreview) return
|
|
63
|
+
const html = document.documentElement
|
|
64
|
+
const prevOverflow = html.style.overflow
|
|
65
|
+
html.style.overflow = 'hidden'
|
|
66
|
+
return () => {
|
|
67
|
+
html.style.overflow = prevOverflow
|
|
68
|
+
}
|
|
69
|
+
}, [!!page, isPreview])
|
|
70
|
+
|
|
58
71
|
useEffect(() => {
|
|
59
72
|
if (isCreateMode || isDataCollection) {
|
|
60
73
|
setShowFrontmatter(true)
|
|
@@ -76,6 +89,13 @@ export function MarkdownEditorOverlay() {
|
|
|
76
89
|
|
|
77
90
|
/** Find the [data-cms-markdown] wrapper element on the actual page (not CMS UI). */
|
|
78
91
|
const findMarkdownWrapper = useCallback((): HTMLElement | null => {
|
|
92
|
+
// Use the active element ID to target the correct wrapper directly
|
|
93
|
+
const activeId = markdownEditorState.value.activeElementId
|
|
94
|
+
if (activeId) {
|
|
95
|
+
const el = document.querySelector(`[data-cms-id="${activeId}"]`) as HTMLElement | null
|
|
96
|
+
if (el) return el
|
|
97
|
+
}
|
|
98
|
+
// Fallback: find any markdown wrapper
|
|
79
99
|
const SKIP_TAGS = new Set(['BODY', 'HTML', 'BUTTON', 'SPAN', 'A'])
|
|
80
100
|
const candidates = document.querySelectorAll('[data-cms-markdown]:not([data-cms-ui])')
|
|
81
101
|
for (const c of candidates) {
|
|
@@ -348,8 +368,7 @@ export function MarkdownEditorOverlay() {
|
|
|
348
368
|
>
|
|
349
369
|
Back to Editor
|
|
350
370
|
</button>
|
|
351
|
-
<
|
|
352
|
-
type="button"
|
|
371
|
+
<PrimaryButton
|
|
353
372
|
onClick={() => {
|
|
354
373
|
const currentContent = currentMarkdownPage.value?.content
|
|
355
374
|
if (currentContent !== undefined) {
|
|
@@ -357,12 +376,11 @@ export function MarkdownEditorOverlay() {
|
|
|
357
376
|
}
|
|
358
377
|
}}
|
|
359
378
|
disabled={isSaving}
|
|
360
|
-
|
|
361
|
-
data-cms-ui
|
|
379
|
+
className="px-3 py-1.5 flex items-center gap-1.5"
|
|
362
380
|
>
|
|
363
|
-
{isSaving && <
|
|
381
|
+
{isSaving && <Spinner size="xs" className="text-cms-primary-text" />}
|
|
364
382
|
{isSaving ? 'Saving...' : 'Save'}
|
|
365
|
-
</
|
|
383
|
+
</PrimaryButton>
|
|
366
384
|
</div>
|
|
367
385
|
)
|
|
368
386
|
}
|
|
@@ -495,37 +513,13 @@ export function MarkdownEditorOverlay() {
|
|
|
495
513
|
Preview
|
|
496
514
|
</button>
|
|
497
515
|
)}
|
|
498
|
-
<
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
</button>
|
|
506
|
-
{isCreateMode
|
|
507
|
-
? (
|
|
508
|
-
<button
|
|
509
|
-
type="submit"
|
|
510
|
-
disabled={isSaving}
|
|
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"
|
|
512
|
-
data-cms-ui
|
|
513
|
-
>
|
|
514
|
-
{isSaving && <div class="animate-spin rounded-full h-3.5 w-3.5 border-2 border-cms-primary-text/30 border-t-cms-primary-text" />}
|
|
515
|
-
{isSaving ? 'Creating...' : `Create ${collectionLabel}`}
|
|
516
|
-
</button>
|
|
517
|
-
)
|
|
518
|
-
: (
|
|
519
|
-
<button
|
|
520
|
-
type="submit"
|
|
521
|
-
disabled={isSaving}
|
|
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"
|
|
523
|
-
data-cms-ui
|
|
524
|
-
>
|
|
525
|
-
{isSaving && <div class="animate-spin rounded-full h-3.5 w-3.5 border-2 border-cms-primary-text/30 border-t-cms-primary-text" />}
|
|
526
|
-
{isSaving ? 'Saving...' : 'Save'}
|
|
527
|
-
</button>
|
|
528
|
-
)}
|
|
516
|
+
<CancelButton onClick={handleCancel} />
|
|
517
|
+
<PrimaryButton type="submit" disabled={isSaving} className="px-4 py-2 flex items-center gap-2">
|
|
518
|
+
{isSaving && <Spinner size="sm" className="text-cms-primary-text" />}
|
|
519
|
+
{isSaving
|
|
520
|
+
? (isCreateMode ? 'Creating...' : 'Saving...')
|
|
521
|
+
: (isCreateMode ? `Create ${collectionLabel}` : 'Save')}
|
|
522
|
+
</PrimaryButton>
|
|
529
523
|
</div>
|
|
530
524
|
</div>
|
|
531
525
|
|