@nuasite/cms 0.29.0 → 0.31.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 +11397 -11351
- package/package.json +1 -1
- package/src/collection-scanner.ts +87 -25
- 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/image-overlay.tsx +3 -14
- 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/plain-text-chip-utils.ts +14 -0
- package/src/editor/components/plain-text-chip.tsx +61 -0
- 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/text-style-toolbar.tsx +2 -15
- package/src/editor/components/toolbar.tsx +2 -1
- package/src/editor/constants.ts +33 -0
- package/src/editor/dom.ts +37 -0
- package/src/editor/editor.ts +90 -5
- 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/editor/index.tsx +9 -0
- package/src/handlers/source-writer.ts +75 -21
- package/src/html-processor.ts +75 -94
- package/src/index.ts +5 -0
- package/src/rehype-cms-marker.ts +15 -0
- package/src/source-finder/ast-extractors.ts +37 -0
- package/src/source-finder/cache.ts +23 -0
- package/src/source-finder/search-index.ts +304 -13
- package/src/source-finder/snippet-utils.ts +179 -2
- package/src/source-finder/types.ts +3 -0
- package/src/source-finder/variable-extraction.ts +8 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentChildren } from 'preact'
|
|
2
2
|
import { Z_INDEX } from '../constants'
|
|
3
|
+
import { cn } from '../lib/cn'
|
|
3
4
|
|
|
4
5
|
export function ModalBackdrop({ onClose, maxWidth = 'max-w-lg', extraClass, children }: {
|
|
5
6
|
onClose: () => void
|
|
@@ -15,7 +16,7 @@ export function ModalBackdrop({ onClose, maxWidth = 'max-w-lg', extraClass, chil
|
|
|
15
16
|
data-cms-ui
|
|
16
17
|
>
|
|
17
18
|
<div
|
|
18
|
-
class={
|
|
19
|
+
class={cn('bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] w-full border border-white/10', maxWidth, extraClass)}
|
|
19
20
|
onClick={(e) => e.stopPropagation()}
|
|
20
21
|
data-cms-ui
|
|
21
22
|
>
|
|
@@ -58,7 +59,7 @@ export function ModalFooter({ children }: { children: ComponentChildren }) {
|
|
|
58
59
|
)
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
export function CloseButton({ onClick }: { onClick: () => void }) {
|
|
62
|
+
export function CloseButton({ onClick, size = 'md' }: { onClick: () => void; size?: 'sm' | 'md' }) {
|
|
62
63
|
return (
|
|
63
64
|
<button
|
|
64
65
|
type="button"
|
|
@@ -66,22 +67,50 @@ export function CloseButton({ onClick }: { onClick: () => void }) {
|
|
|
66
67
|
class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors cursor-pointer"
|
|
67
68
|
data-cms-ui
|
|
68
69
|
>
|
|
69
|
-
<svg class=
|
|
70
|
+
<svg class={size === 'sm' ? 'w-4 h-4' : 'w-5 h-5'} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
70
71
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
71
72
|
</svg>
|
|
72
73
|
</button>
|
|
73
74
|
)
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
export function CancelButton({ onClick, label = 'Cancel' }: { onClick: () => void; label?: string }) {
|
|
77
|
+
export function CancelButton({ onClick, label = 'Cancel', className }: { onClick: () => void; label?: string; className?: string }) {
|
|
77
78
|
return (
|
|
78
79
|
<button
|
|
79
80
|
type="button"
|
|
80
81
|
onClick={onClick}
|
|
81
|
-
class=
|
|
82
|
+
class={cn(
|
|
83
|
+
'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',
|
|
84
|
+
className,
|
|
85
|
+
)}
|
|
82
86
|
data-cms-ui
|
|
83
87
|
>
|
|
84
88
|
{label}
|
|
85
89
|
</button>
|
|
86
90
|
)
|
|
87
91
|
}
|
|
92
|
+
|
|
93
|
+
export function PrimaryButton({ onClick, children, disabled, type = 'button', className }: {
|
|
94
|
+
onClick?: () => void
|
|
95
|
+
children: ComponentChildren
|
|
96
|
+
disabled?: boolean
|
|
97
|
+
type?: 'button' | 'submit'
|
|
98
|
+
className?: string
|
|
99
|
+
}) {
|
|
100
|
+
return (
|
|
101
|
+
<button
|
|
102
|
+
type={type}
|
|
103
|
+
onClick={onClick}
|
|
104
|
+
disabled={disabled}
|
|
105
|
+
class={cn(
|
|
106
|
+
'px-5 py-2.5 text-sm font-medium rounded-cms-pill transition-colors cursor-pointer',
|
|
107
|
+
'bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover',
|
|
108
|
+
'disabled:opacity-40 disabled:cursor-not-allowed',
|
|
109
|
+
className,
|
|
110
|
+
)}
|
|
111
|
+
data-cms-ui
|
|
112
|
+
>
|
|
113
|
+
{children}
|
|
114
|
+
</button>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ManifestEntry } from '../../types'
|
|
2
|
+
|
|
3
|
+
export function describeSource(entry: ManifestEntry | undefined): string {
|
|
4
|
+
if (!entry) return 'no formatting'
|
|
5
|
+
if (entry.collectionName) {
|
|
6
|
+
return `${entry.collectionName} collection field`
|
|
7
|
+
}
|
|
8
|
+
if (entry.variableName) {
|
|
9
|
+
return `${entry.variableName} prop`
|
|
10
|
+
}
|
|
11
|
+
// Entry marked non-styleable but missing both collection and variable context —
|
|
12
|
+
// visible fallback so the edge case surfaces instead of masquerading as a missing entry.
|
|
13
|
+
return 'unknown source'
|
|
14
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ManifestEntry } from '../../types'
|
|
2
|
+
import { Z_INDEX } from '../constants'
|
|
3
|
+
import { positionFloatingChip } from '../dom'
|
|
4
|
+
import { describeSource } from './plain-text-chip-utils'
|
|
5
|
+
|
|
6
|
+
export interface PlainTextChipProps {
|
|
7
|
+
visible: boolean
|
|
8
|
+
rect: DOMRect | null
|
|
9
|
+
entry: ManifestEntry | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function PlainTextChip({ visible, rect, entry }: PlainTextChipProps) {
|
|
13
|
+
if (!visible || !rect) {
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const maxChipWidth = 280
|
|
18
|
+
const { left, top } = positionFloatingChip(rect, { width: maxChipWidth, height: 28 })
|
|
19
|
+
|
|
20
|
+
const source = describeSource(entry)
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
data-cms-ui
|
|
25
|
+
onMouseDown={(e) => {
|
|
26
|
+
e.preventDefault()
|
|
27
|
+
e.stopPropagation()
|
|
28
|
+
}}
|
|
29
|
+
onClick={(e) => e.stopPropagation()}
|
|
30
|
+
style={{
|
|
31
|
+
position: 'fixed',
|
|
32
|
+
left: `${left}px`,
|
|
33
|
+
top: `${top}px`,
|
|
34
|
+
zIndex: Z_INDEX.MODAL,
|
|
35
|
+
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
|
|
36
|
+
fontSize: '11px',
|
|
37
|
+
maxWidth: `${maxChipWidth}px`,
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
<div class="flex items-center gap-2 px-3 py-1.5 bg-cms-dark border border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.3)] rounded-cms-xl text-white/80 min-w-0">
|
|
41
|
+
<svg
|
|
42
|
+
class="shrink-0"
|
|
43
|
+
width="12"
|
|
44
|
+
height="12"
|
|
45
|
+
viewBox="0 0 24 24"
|
|
46
|
+
fill="none"
|
|
47
|
+
stroke="currentColor"
|
|
48
|
+
stroke-width="2"
|
|
49
|
+
stroke-linecap="round"
|
|
50
|
+
stroke-linejoin="round"
|
|
51
|
+
aria-hidden="true"
|
|
52
|
+
>
|
|
53
|
+
<path d="M4 7V4h16v3" />
|
|
54
|
+
<path d="M9 20h6" />
|
|
55
|
+
<path d="M12 4v16" />
|
|
56
|
+
</svg>
|
|
57
|
+
<span class="truncate" title={`Plain text · ${source}`}>Plain text · {source}</span>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { useMemo, useRef, useState } from 'preact/hooks'
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
2
|
import { slugify } from '../../shared'
|
|
3
|
+
import { useSearchFilter } from '../hooks/useSearchFilter'
|
|
3
4
|
import { getCollectionEntryOptions } from '../manifest'
|
|
4
5
|
import { manifest, openMediaLibraryWithCallback, pendingCollectionEntries } from '../signals'
|
|
5
6
|
import type { ComponentProp } from '../types'
|
|
7
|
+
import { DropdownPanel } from './fields'
|
|
6
8
|
import { SchemaFrontmatterField } from './frontmatter-fields'
|
|
7
9
|
|
|
8
10
|
export interface PropEditorProps {
|
|
@@ -181,20 +183,14 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
181
183
|
[collection, currentManifest],
|
|
182
184
|
)
|
|
183
185
|
const collectionDef = currentManifest?.collectionDefinitions?.[collection]
|
|
184
|
-
const
|
|
186
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
185
187
|
const [search, setSearch] = useState('')
|
|
186
188
|
const [isOpen, setIsOpen] = useState(false)
|
|
187
189
|
const [isCreating, setIsCreating] = useState(false)
|
|
188
190
|
const [newName, setNewName] = useState('')
|
|
189
191
|
const [formData, setFormData] = useState<Record<string, unknown>>({})
|
|
190
192
|
|
|
191
|
-
const filtered =
|
|
192
|
-
() =>
|
|
193
|
-
search
|
|
194
|
-
? options.filter(o => o.label.toLowerCase().includes(search.toLowerCase()) || o.value.toLowerCase().includes(search.toLowerCase()))
|
|
195
|
-
: options,
|
|
196
|
-
[options, search],
|
|
197
|
-
)
|
|
193
|
+
const filtered = useSearchFilter(options, search, o => `${o.label} ${o.value}`)
|
|
198
194
|
|
|
199
195
|
const selectedLabel = useMemo(
|
|
200
196
|
() => value ? (options.find(o => o.value === value)?.label ?? value) : '',
|
|
@@ -206,6 +202,8 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
206
202
|
[collectionDef],
|
|
207
203
|
)
|
|
208
204
|
|
|
205
|
+
const closeDropdown = useCallback(() => setIsOpen(false), [])
|
|
206
|
+
|
|
209
207
|
const resetCreateForm = () => {
|
|
210
208
|
setIsCreating(false)
|
|
211
209
|
setNewName('')
|
|
@@ -309,8 +307,9 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
309
307
|
}
|
|
310
308
|
|
|
311
309
|
return (
|
|
312
|
-
<div
|
|
310
|
+
<div>
|
|
313
311
|
<input
|
|
312
|
+
ref={inputRef}
|
|
314
313
|
type="text"
|
|
315
314
|
value={isOpen ? search : selectedLabel}
|
|
316
315
|
onInput={(e) => {
|
|
@@ -318,67 +317,67 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
318
317
|
setIsOpen(true)
|
|
319
318
|
}}
|
|
320
319
|
onFocus={() => setIsOpen(true)}
|
|
321
|
-
onBlur={(
|
|
322
|
-
const related = (e as FocusEvent).relatedTarget as Node | null
|
|
323
|
-
if (containerRef.current && related && containerRef.current.contains(related)) return
|
|
324
|
-
setIsOpen(false)
|
|
325
|
-
}}
|
|
320
|
+
onBlur={() => setTimeout(closeDropdown, 150)}
|
|
326
321
|
placeholder={`Select ${collection} entry...`}
|
|
327
322
|
class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
|
|
328
323
|
/>
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
<
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
324
|
+
<DropdownPanel
|
|
325
|
+
triggerRef={inputRef}
|
|
326
|
+
isOpen={isOpen}
|
|
327
|
+
onClose={closeDropdown}
|
|
328
|
+
maxHeight={192}
|
|
329
|
+
className="border border-white/20 rounded-cms-md"
|
|
330
|
+
>
|
|
331
|
+
{!required && (
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
335
|
+
onClick={() => {
|
|
336
|
+
onChange('')
|
|
337
|
+
setSearch('')
|
|
338
|
+
setIsOpen(false)
|
|
339
|
+
}}
|
|
340
|
+
class="w-full px-4 py-2 text-left text-[13px] text-white/50 hover:bg-white/10 transition-colors"
|
|
341
|
+
>
|
|
342
|
+
— None —
|
|
343
|
+
</button>
|
|
344
|
+
)}
|
|
345
|
+
{filtered.map((opt) => (
|
|
346
|
+
<button
|
|
347
|
+
key={opt.value}
|
|
348
|
+
type="button"
|
|
349
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
350
|
+
onClick={() => {
|
|
351
|
+
onChange(opt.value)
|
|
352
|
+
setSearch('')
|
|
353
|
+
setIsOpen(false)
|
|
354
|
+
}}
|
|
355
|
+
class={`w-full px-4 py-2 text-left text-[13px] transition-colors ${
|
|
356
|
+
opt.value === value ? 'bg-cms-primary/20 text-white' : 'text-white/80 hover:bg-white/10'
|
|
357
|
+
}`}
|
|
358
|
+
>
|
|
359
|
+
<div>{opt.label}</div>
|
|
360
|
+
{opt.label !== opt.value && <div class="text-[11px] text-white/40 font-mono">{opt.value}</div>}
|
|
361
|
+
</button>
|
|
362
|
+
))}
|
|
363
|
+
{filtered.length === 0 && <div class="px-4 py-2 text-[13px] text-white/40">No entries found</div>}
|
|
364
|
+
{collectionDef && (
|
|
365
|
+
<button
|
|
366
|
+
type="button"
|
|
367
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
368
|
+
onClick={() => {
|
|
369
|
+
setIsCreating(true)
|
|
370
|
+
setIsOpen(false)
|
|
371
|
+
}}
|
|
372
|
+
class="w-full px-4 py-2 text-left text-[13px] text-cms-primary hover:bg-cms-primary/10 transition-colors border-t border-white/10 flex items-center gap-2"
|
|
373
|
+
>
|
|
374
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
|
375
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
|
376
|
+
</svg>
|
|
377
|
+
Create new {collectionDef.label?.toLowerCase() ?? collection}
|
|
378
|
+
</button>
|
|
379
|
+
)}
|
|
380
|
+
</DropdownPanel>
|
|
382
381
|
</div>
|
|
383
382
|
)
|
|
384
383
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
2
|
import { clampPanelPosition, Z_INDEX } from '../constants'
|
|
3
|
+
import { useClickOutsideEscape } from '../hooks/useClickOutsideEscape'
|
|
4
|
+
import { useSearchFilter } from '../hooks/useSearchFilter'
|
|
3
5
|
import { getCollectionEntryOptions } from '../manifest'
|
|
4
6
|
import { updateMarkdownPage } from '../markdown-api'
|
|
5
7
|
import { closeReferencePicker, config, manifest, referencePickerState, showToast } from '../signals'
|
|
8
|
+
import { Spinner } from './spinner'
|
|
6
9
|
|
|
7
10
|
const PANEL_WIDTH = 320
|
|
8
11
|
|
|
@@ -27,11 +30,7 @@ export function ReferencePicker() {
|
|
|
27
30
|
}
|
|
28
31
|
}, [state.isOpen])
|
|
29
32
|
|
|
30
|
-
const filtered =
|
|
31
|
-
if (!query) return options
|
|
32
|
-
const q = query.toLowerCase()
|
|
33
|
-
return options.filter(o => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
|
|
34
|
-
}, [query, options])
|
|
33
|
+
const filtered = useSearchFilter(options, query, o => `${o.label} ${o.value}`)
|
|
35
34
|
|
|
36
35
|
const currentLabel = useMemo(() => {
|
|
37
36
|
if (state.isArray) return null
|
|
@@ -69,24 +68,7 @@ export function ReferencePicker() {
|
|
|
69
68
|
updateReference([...current])
|
|
70
69
|
}, [state.currentValues, updateReference])
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
if (!state.isOpen) return
|
|
75
|
-
const onMouseDown = (e: MouseEvent) => {
|
|
76
|
-
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
|
77
|
-
closeReferencePicker()
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
const onKeyDown = (e: KeyboardEvent) => {
|
|
81
|
-
if (e.key === 'Escape') closeReferencePicker()
|
|
82
|
-
}
|
|
83
|
-
document.addEventListener('mousedown', onMouseDown)
|
|
84
|
-
document.addEventListener('keydown', onKeyDown)
|
|
85
|
-
return () => {
|
|
86
|
-
document.removeEventListener('mousedown', onMouseDown)
|
|
87
|
-
document.removeEventListener('keydown', onKeyDown)
|
|
88
|
-
}
|
|
89
|
-
}, [state.isOpen])
|
|
71
|
+
useClickOutsideEscape([panelRef], state.isOpen, closeReferencePicker)
|
|
90
72
|
|
|
91
73
|
if (!state.isOpen || !state.cursorPos) return null
|
|
92
74
|
|
|
@@ -113,7 +95,7 @@ export function ReferencePicker() {
|
|
|
113
95
|
{saving
|
|
114
96
|
? (
|
|
115
97
|
<div class="flex items-center justify-center gap-2 px-4 py-6">
|
|
116
|
-
<
|
|
98
|
+
<Spinner className="text-white/80" />
|
|
117
99
|
<span class="text-sm text-white/80">Updating...</span>
|
|
118
100
|
</div>
|
|
119
101
|
)
|
|
@@ -16,7 +16,8 @@ import {
|
|
|
16
16
|
} from '../signals'
|
|
17
17
|
import type { ChangePayload, PageSeoData, PendingSeoChange } from '../types'
|
|
18
18
|
import { ColorField, ComboBoxField, ImageField } from './fields'
|
|
19
|
-
import { CloseButton, ModalBackdrop } from './modal-shell'
|
|
19
|
+
import { CancelButton, CloseButton, ModalBackdrop } from './modal-shell'
|
|
20
|
+
import { Spinner } from './spinner'
|
|
20
21
|
|
|
21
22
|
const OG_TYPE_OPTIONS = [
|
|
22
23
|
{ value: 'website', label: 'Website', description: 'Default type for most pages' },
|
|
@@ -549,14 +550,7 @@ export function SeoEditor() {
|
|
|
549
550
|
{/* Footer */}
|
|
550
551
|
{hasSeoData && (
|
|
551
552
|
<div class="flex items-center justify-end gap-3 px-5 py-4 border-t border-white/10">
|
|
552
|
-
<
|
|
553
|
-
type="button"
|
|
554
|
-
onClick={handleClose}
|
|
555
|
-
class="px-4 py-2 text-sm font-medium text-white/70 hover:text-white hover:bg-white/10 rounded-cms-pill transition-colors"
|
|
556
|
-
data-cms-ui
|
|
557
|
-
>
|
|
558
|
-
Cancel
|
|
559
|
-
</button>
|
|
553
|
+
<CancelButton onClick={handleClose} />
|
|
560
554
|
<button
|
|
561
555
|
type="button"
|
|
562
556
|
onClick={handleSaveAll}
|
|
@@ -568,7 +562,7 @@ export function SeoEditor() {
|
|
|
568
562
|
}`}
|
|
569
563
|
data-cms-ui
|
|
570
564
|
>
|
|
571
|
-
{isSaving && <
|
|
565
|
+
{isSaving && <Spinner />}
|
|
572
566
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
|
573
567
|
</button>
|
|
574
568
|
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { cn } from '../lib/cn'
|
|
2
|
+
|
|
3
|
+
const sizes = {
|
|
4
|
+
xs: 'h-3 w-3',
|
|
5
|
+
sm: 'h-3.5 w-3.5',
|
|
6
|
+
md: 'h-4 w-4',
|
|
7
|
+
lg: 'h-6 w-6',
|
|
8
|
+
xl: 'h-8 w-8',
|
|
9
|
+
} as const
|
|
10
|
+
|
|
11
|
+
export function Spinner({ size = 'md', className }: { size?: keyof typeof sizes; className?: string }) {
|
|
12
|
+
return (
|
|
13
|
+
<span
|
|
14
|
+
class={cn('inline-block animate-spin rounded-full border-2 border-current/30 border-t-current', sizes[size], className)}
|
|
15
|
+
/>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from 'preact/hooks'
|
|
2
2
|
import { CSS, Z_INDEX } from '../constants'
|
|
3
|
+
import { positionFloatingChip } from '../dom'
|
|
3
4
|
import { cn } from '../lib/cn'
|
|
4
5
|
import * as signals from '../signals'
|
|
5
6
|
import {
|
|
@@ -177,21 +178,7 @@ export function TextStyleToolbar({ visible, rect, element, onStyleChange }: Text
|
|
|
177
178
|
return null
|
|
178
179
|
}
|
|
179
180
|
|
|
180
|
-
|
|
181
|
-
const toolbarHeight = 44
|
|
182
|
-
const toolbarWidth = 320
|
|
183
|
-
let left = rect.left + rect.width / 2 - toolbarWidth / 2
|
|
184
|
-
let top = rect.top - toolbarHeight - 8
|
|
185
|
-
|
|
186
|
-
const padding = 10
|
|
187
|
-
const maxLeft = window.innerWidth - toolbarWidth - padding
|
|
188
|
-
const minLeft = padding
|
|
189
|
-
|
|
190
|
-
left = Math.max(minLeft, Math.min(left, maxLeft))
|
|
191
|
-
|
|
192
|
-
if (top < padding) {
|
|
193
|
-
top = rect.bottom + 8
|
|
194
|
-
}
|
|
181
|
+
const { left, top } = positionFloatingChip(rect, { width: 320, height: 44 })
|
|
195
182
|
|
|
196
183
|
return (
|
|
197
184
|
<div
|
|
@@ -5,6 +5,7 @@ import { cn } from '../lib/cn'
|
|
|
5
5
|
import * as signals from '../signals'
|
|
6
6
|
import { showConfirmDialog } from '../signals'
|
|
7
7
|
import type { CollectionDefinition } from '../types'
|
|
8
|
+
import { Spinner } from './spinner'
|
|
8
9
|
|
|
9
10
|
export interface ToolbarCallbacks {
|
|
10
11
|
onEdit: () => void
|
|
@@ -229,7 +230,7 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
|
|
|
229
230
|
{/* Saving indicator */}
|
|
230
231
|
{isSaving && !showingOriginal && (
|
|
231
232
|
<div class="flex items-center gap-1.5 px-3 py-2 sm:px-5 sm:py-2.5 text-sm font-medium text-white/80">
|
|
232
|
-
<
|
|
233
|
+
<Spinner size="sm" className="text-white/80" />
|
|
233
234
|
<span>Saving</span>
|
|
234
235
|
</div>
|
|
235
236
|
)}
|
package/src/editor/constants.ts
CHANGED
|
@@ -136,3 +136,36 @@ export function clampPanelPosition(
|
|
|
136
136
|
maxHeight: `${maxHeight}px`,
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Calculate fixed-position style for a dropdown that needs to escape parent overflow clipping.
|
|
142
|
+
* Positions below the trigger element by default, flipping above when space is insufficient.
|
|
143
|
+
*/
|
|
144
|
+
export function getDropdownPosition(
|
|
145
|
+
trigger: HTMLElement | null,
|
|
146
|
+
maxHeight: number,
|
|
147
|
+
padding = LAYOUT.VIEWPORT_PADDING,
|
|
148
|
+
): Record<string, string> | undefined {
|
|
149
|
+
if (!trigger) return undefined
|
|
150
|
+
const rect = trigger.getBoundingClientRect()
|
|
151
|
+
const spaceBelow = window.innerHeight - rect.bottom - padding
|
|
152
|
+
const spaceAbove = rect.top - padding
|
|
153
|
+
const showAbove = spaceBelow < 80 && spaceAbove > spaceBelow
|
|
154
|
+
|
|
155
|
+
const style: Record<string, string> = {
|
|
156
|
+
position: 'fixed',
|
|
157
|
+
left: `${rect.left}px`,
|
|
158
|
+
width: `${rect.width}px`,
|
|
159
|
+
zIndex: String(Z_INDEX.MODAL),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (showAbove) {
|
|
163
|
+
style.bottom = `${window.innerHeight - rect.top + 4}px`
|
|
164
|
+
style.maxHeight = `${Math.max(Math.min(maxHeight, spaceAbove), 80)}px`
|
|
165
|
+
} else {
|
|
166
|
+
style.top = `${rect.bottom + 4}px`
|
|
167
|
+
style.maxHeight = `${Math.max(Math.min(maxHeight, spaceBelow), 80)}px`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return style
|
|
171
|
+
}
|
package/src/editor/dom.ts
CHANGED
|
@@ -420,3 +420,40 @@ function preventInteraction(event: Event): void {
|
|
|
420
420
|
event.stopImmediatePropagation()
|
|
421
421
|
}
|
|
422
422
|
}
|
|
423
|
+
|
|
424
|
+
// Chromium exposes `caretRangeFromPoint`; Firefox exposes `caretPositionFromPoint`.
|
|
425
|
+
// Neither is universally supported, hence the fallback cascade.
|
|
426
|
+
export function getCaretRangeFromPoint(x: number, y: number): Range | null {
|
|
427
|
+
const doc = document as Document & {
|
|
428
|
+
caretRangeFromPoint?: (x: number, y: number) => Range | null
|
|
429
|
+
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null
|
|
430
|
+
}
|
|
431
|
+
if (typeof doc.caretRangeFromPoint === 'function') {
|
|
432
|
+
return doc.caretRangeFromPoint(x, y)
|
|
433
|
+
}
|
|
434
|
+
if (typeof doc.caretPositionFromPoint === 'function') {
|
|
435
|
+
const pos = doc.caretPositionFromPoint(x, y)
|
|
436
|
+
if (pos) {
|
|
437
|
+
const range = document.createRange()
|
|
438
|
+
range.setStart(pos.offsetNode, pos.offset)
|
|
439
|
+
range.collapse(true)
|
|
440
|
+
return range
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return null
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function positionFloatingChip(
|
|
447
|
+
rect: DOMRect,
|
|
448
|
+
opts: { width: number; height: number; padding?: number; gap?: number },
|
|
449
|
+
): { left: number; top: number } {
|
|
450
|
+
const { width, height } = opts
|
|
451
|
+
const padding = opts.padding ?? 10
|
|
452
|
+
const gap = opts.gap ?? 8
|
|
453
|
+
let left = rect.left + rect.width / 2 - width / 2
|
|
454
|
+
let top = rect.top - height - gap
|
|
455
|
+
const maxLeft = window.innerWidth - width - padding
|
|
456
|
+
left = Math.max(padding, Math.min(left, maxLeft))
|
|
457
|
+
if (top < padding) top = rect.bottom + gap
|
|
458
|
+
return { left, top }
|
|
459
|
+
}
|