@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/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.29.0",
17
+ "version": "0.30.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -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
- <button
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
- <button
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 = useMemo(() => {
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
- <button
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
- </button>
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
- <button
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
- {/* Footer */}
31
- <div class="flex items-center justify-end gap-3 p-5 pt-4 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
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
- </div>
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
- // Filter options based on query
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 relative">
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={handleFocus}
432
- onBlur={handleBlur}
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
- {showDropdown && (
444
- <div
445
- ref={listRef}
446
- class="absolute z-50 left-0 right-0 mt-1 max-h-40 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-sm shadow-lg"
447
- data-cms-ui
448
- >
449
- {filtered.map((opt, i) => (
450
- <button
451
- key={opt.value}
452
- type="button"
453
- onMouseDown={(e) => {
454
- e.preventDefault()
455
- selectOption(opt.value)
456
- }}
457
- class={cn(
458
- 'w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer',
459
- i === highlightedIndex
460
- ? 'bg-white/15 text-white'
461
- : 'text-white/70 hover:bg-white/10 hover:text-white',
462
- )}
463
- data-cms-ui
464
- >
465
- <span class="block truncate font-medium">
466
- <HighlightMatch text={opt.label} query={query} />
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
- {opt.description && (
469
- <span class="block truncate text-white/40">
470
- <HighlightMatch text={opt.description} query={query} />
471
- </span>
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 = useMemo(() => {
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
- // Close on outside click
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 relative" ref={containerRef} data-cms-ui>
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
- {/* Dropdown */}
584
- {isOpen && (
585
- <div
586
- 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"
587
- data-cms-ui
588
- >
589
- {filtered.length === 0
590
- ? <div class="px-3 py-2 text-xs text-white/40">No options found</div>
591
- : filtered.map(opt => {
592
- const isSelected = selected.includes(opt.value)
593
- return (
594
- <button
595
- key={opt.value}
596
- type="button"
597
- onMouseDown={(e) => {
598
- e.preventDefault()
599
- toggleOption(opt.value)
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-full text-left px-3 py-2 text-xs transition-colors cursor-pointer flex items-center gap-2',
630
+ 'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
603
631
  isSelected
604
- ? 'bg-cms-primary/10 text-white'
605
- : 'text-white/70 hover:bg-white/10 hover:text-white',
632
+ ? 'bg-cms-primary border-cms-primary'
633
+ : 'border-white/30 bg-white/5',
606
634
  )}
607
- data-cms-ui
608
635
  >
609
- <span
610
- class={cn(
611
- 'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
612
- isSelected
613
- ? 'bg-cms-primary border-cms-primary'
614
- : 'border-white/30 bg-white/5',
615
- )}
616
- >
617
- {isSelected && (
618
- <svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
619
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
620
- </svg>
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
- <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
- >
193
+ <PrimaryButton type="submit" className="px-3 py-1.5 text-[12px] rounded-cms-sm shrink-0">
197
194
  Apply
198
- </button>
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
- <button
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
- class="px-3 py-1.5 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-1.5"
361
- data-cms-ui
379
+ className="px-3 py-1.5 flex items-center gap-1.5"
362
380
  >
363
- {isSaving && <div class="animate-spin rounded-full h-3 w-3 border-2 border-cms-primary-text/30 border-t-cms-primary-text" />}
381
+ {isSaving && <Spinner size="xs" className="text-cms-primary-text" />}
364
382
  {isSaving ? 'Saving...' : 'Save'}
365
- </button>
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
- <button
499
- type="button"
500
- onClick={handleCancel}
501
- class="px-4 py-2 text-sm text-white/70 hover:text-white hover:bg-white/10 rounded-cms-pill transition-colors"
502
- data-cms-ui
503
- >
504
- Cancel
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