@nuasite/cms 0.7.3 → 0.8.2
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 +2 -6
- package/dist/editor.js +8983 -8467
- package/package.json +1 -1
- package/src/color-patterns.ts +68 -1
- package/src/editor/color-utils.ts +8 -8
- package/src/editor/components/bg-image-overlay.tsx +483 -0
- package/src/editor/components/editable-highlights.tsx +14 -0
- package/src/editor/components/frontmatter-fields.tsx +7 -3
- package/src/editor/components/outline.tsx +3 -2
- package/src/editor/components/text-style-toolbar.tsx +4 -1
- package/src/editor/constants.ts +3 -0
- package/src/editor/editor.ts +173 -7
- package/src/editor/history.ts +120 -34
- package/src/editor/hooks/index.ts +3 -0
- package/src/editor/hooks/useBgImageHoverDetection.ts +101 -0
- package/src/editor/index.tsx +12 -0
- package/src/editor/signals.ts +48 -14
- package/src/editor/storage.ts +50 -0
- package/src/editor/types.ts +79 -8
- package/src/handlers/source-writer.ts +61 -69
- package/src/html-processor.ts +75 -5
- package/src/source-finder/snippet-utils.ts +6 -2
- package/src/tailwind-colors.ts +1 -0
- package/src/types.ts +16 -0
package/package.json
CHANGED
package/src/color-patterns.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Attribute } from './types'
|
|
1
|
+
import type { Attribute, BackgroundImageMetadata } from './types'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Default Tailwind CSS v4 color names.
|
|
@@ -310,3 +310,70 @@ export function buildColorClass(
|
|
|
310
310
|
}
|
|
311
311
|
return `${prefix}-${colorName}`
|
|
312
312
|
}
|
|
313
|
+
|
|
314
|
+
// ============================================================================
|
|
315
|
+
// Background Image Class Extraction
|
|
316
|
+
// ============================================================================
|
|
317
|
+
|
|
318
|
+
/** Regex to match bg-[url('...')] classes (single quotes, double quotes, or no quotes) */
|
|
319
|
+
const BG_IMAGE_CLASS_PATTERN = /^bg-\[url\(['"]?([^'")\]]+)['"]?\)\]$/
|
|
320
|
+
|
|
321
|
+
/** Regex to match bg-size utility classes */
|
|
322
|
+
const BG_SIZE_PATTERN = /^bg-(auto|cover|contain)$/
|
|
323
|
+
|
|
324
|
+
/** Regex to match bg-position utility classes */
|
|
325
|
+
const BG_POSITION_PATTERN = /^bg-(center|top|bottom|left|right|top-left|top-right|bottom-left|bottom-right)$/
|
|
326
|
+
|
|
327
|
+
/** Regex to match bg-repeat utility classes */
|
|
328
|
+
const BG_REPEAT_PATTERN = /^bg-(repeat|no-repeat|repeat-x|repeat-y|repeat-round|repeat-space)$/
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Extract background image classes from an element's class attribute.
|
|
332
|
+
* Only returns metadata if a bg-[url()] class is found.
|
|
333
|
+
* Standalone bg-size/position/repeat without a bg image are ignored.
|
|
334
|
+
*/
|
|
335
|
+
export function extractBackgroundImageClasses(classAttr: string | null | undefined): BackgroundImageMetadata | undefined {
|
|
336
|
+
if (!classAttr) return undefined
|
|
337
|
+
|
|
338
|
+
const classes = classAttr.split(/\s+/).filter(Boolean)
|
|
339
|
+
|
|
340
|
+
let bgImageClass: string | undefined
|
|
341
|
+
let imageUrl: string | undefined
|
|
342
|
+
let bgSize: string | undefined
|
|
343
|
+
let bgPosition: string | undefined
|
|
344
|
+
let bgRepeat: string | undefined
|
|
345
|
+
|
|
346
|
+
for (const cls of classes) {
|
|
347
|
+
const imgMatch = cls.match(BG_IMAGE_CLASS_PATTERN)
|
|
348
|
+
if (imgMatch) {
|
|
349
|
+
bgImageClass = cls
|
|
350
|
+
imageUrl = imgMatch[1]!
|
|
351
|
+
continue
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (BG_SIZE_PATTERN.test(cls)) {
|
|
355
|
+
bgSize = cls
|
|
356
|
+
continue
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (BG_POSITION_PATTERN.test(cls)) {
|
|
360
|
+
bgPosition = cls
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (BG_REPEAT_PATTERN.test(cls)) {
|
|
365
|
+
bgRepeat = cls
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Only return metadata if a bg-[url()] class was found
|
|
370
|
+
if (!bgImageClass || !imageUrl) return undefined
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
bgImageClass,
|
|
374
|
+
imageUrl,
|
|
375
|
+
bgSize,
|
|
376
|
+
bgPosition,
|
|
377
|
+
bgRepeat,
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -153,8 +153,8 @@ export function replaceColorClass(
|
|
|
153
153
|
const prefix = colorType === 'hoverBg'
|
|
154
154
|
? 'hover:bg'
|
|
155
155
|
: colorType === 'hoverText'
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
? 'hover:text'
|
|
157
|
+
: colorType
|
|
158
158
|
const newClass = buildColorClass(prefix, newColorName, newShade)
|
|
159
159
|
|
|
160
160
|
let oldClass: string | undefined
|
|
@@ -225,8 +225,8 @@ export function applyColorChange(
|
|
|
225
225
|
const prefix = colorType === 'hoverBg'
|
|
226
226
|
? 'hover:bg'
|
|
227
227
|
: colorType === 'hoverText'
|
|
228
|
-
|
|
229
|
-
|
|
228
|
+
? 'hover:text'
|
|
229
|
+
: colorType
|
|
230
230
|
const newClass = buildColorClass(prefix, newColorName, newShade)
|
|
231
231
|
|
|
232
232
|
let oldClass: string | undefined
|
|
@@ -266,10 +266,10 @@ export function applyColorChange(
|
|
|
266
266
|
const styleProperty = colorType === 'bg'
|
|
267
267
|
? 'backgroundColor'
|
|
268
268
|
: colorType === 'text'
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
269
|
+
? 'color'
|
|
270
|
+
: colorType === 'border'
|
|
271
|
+
? 'borderColor'
|
|
272
|
+
: 'color'
|
|
273
273
|
element.style[styleProperty] = cssValue
|
|
274
274
|
}
|
|
275
275
|
}
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
|
2
|
+
import { Z_INDEX } from '../constants'
|
|
3
|
+
import { isApplyingUndoRedo, recordChange } from '../history'
|
|
4
|
+
import { cn } from '../lib/cn'
|
|
5
|
+
import * as signals from '../signals'
|
|
6
|
+
import { saveBgImageEditsToStorage } from '../storage'
|
|
7
|
+
import { FieldLabel, ImageField, SelectField } from './fields'
|
|
8
|
+
|
|
9
|
+
export interface BgImageOverlayProps {
|
|
10
|
+
visible: boolean
|
|
11
|
+
rect: DOMRect | null
|
|
12
|
+
element: HTMLElement | null
|
|
13
|
+
cmsId: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Map bg-size class to CSS value for inline preview */
|
|
17
|
+
const BG_SIZE_CSS: Record<string, string> = {
|
|
18
|
+
'bg-auto': 'auto',
|
|
19
|
+
'bg-cover': 'cover',
|
|
20
|
+
'bg-contain': 'contain',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Map bg-position class to CSS value for inline preview */
|
|
24
|
+
const BG_POSITION_CSS: Record<string, string> = {
|
|
25
|
+
'bg-center': 'center',
|
|
26
|
+
'bg-top': 'top',
|
|
27
|
+
'bg-bottom': 'bottom',
|
|
28
|
+
'bg-left': 'left',
|
|
29
|
+
'bg-right': 'right',
|
|
30
|
+
'bg-top-left': 'top left',
|
|
31
|
+
'bg-top-right': 'top right',
|
|
32
|
+
'bg-bottom-left': 'bottom left',
|
|
33
|
+
'bg-bottom-right': 'bottom right',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Map bg-repeat class to CSS value for inline preview */
|
|
37
|
+
const BG_REPEAT_CSS: Record<string, string> = {
|
|
38
|
+
'bg-repeat': 'repeat',
|
|
39
|
+
'bg-no-repeat': 'no-repeat',
|
|
40
|
+
'bg-repeat-x': 'repeat-x',
|
|
41
|
+
'bg-repeat-y': 'repeat-y',
|
|
42
|
+
'bg-repeat-round': 'round',
|
|
43
|
+
'bg-repeat-space': 'space',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Extract image URL from bg-[url('...')] class */
|
|
47
|
+
function extractUrlFromClass(cls: string): string {
|
|
48
|
+
const match = cls.match(/^bg-\[url\(['"]?([^'")\]]+)['"]?\)\]$/)
|
|
49
|
+
return match?.[1] ?? ''
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SIZE_OPTIONS = [
|
|
53
|
+
{ value: 'bg-auto', label: 'Auto' },
|
|
54
|
+
{ value: 'bg-contain', label: 'Contain' },
|
|
55
|
+
{ value: 'bg-cover', label: 'Cover' },
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
const POSITION_OPTIONS = [
|
|
59
|
+
{ value: 'bg-top-left', label: 'Top Left' },
|
|
60
|
+
{ value: 'bg-top', label: 'Top' },
|
|
61
|
+
{ value: 'bg-top-right', label: 'Top Right' },
|
|
62
|
+
{ value: 'bg-left', label: 'Left' },
|
|
63
|
+
{ value: 'bg-center', label: 'Center' },
|
|
64
|
+
{ value: 'bg-right', label: 'Right' },
|
|
65
|
+
{ value: 'bg-bottom-left', label: 'Bottom Left' },
|
|
66
|
+
{ value: 'bg-bottom', label: 'Bottom' },
|
|
67
|
+
{ value: 'bg-bottom-right', label: 'Bottom Right' },
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
const REPEAT_OPTIONS = [
|
|
71
|
+
{ value: 'bg-repeat', label: 'Repeat' },
|
|
72
|
+
{ value: 'bg-no-repeat', label: 'No Repeat' },
|
|
73
|
+
{ value: 'bg-repeat-x', label: 'Repeat X' },
|
|
74
|
+
{ value: 'bg-repeat-y', label: 'Repeat Y' },
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Background image overlay component.
|
|
79
|
+
* Shows a floating badge on hover and opens a right-side settings panel on click.
|
|
80
|
+
*/
|
|
81
|
+
export function BgImageOverlay({ visible, rect, element, cmsId }: BgImageOverlayProps) {
|
|
82
|
+
const [panelOpen, setPanelOpen] = useState(false)
|
|
83
|
+
// Capture target when panel opens so it stays stable when hover moves away
|
|
84
|
+
const panelTargetRef = useRef<{ cmsId: string; element: HTMLElement } | null>(null)
|
|
85
|
+
|
|
86
|
+
// Close panel when hovering a different bg-image element
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (cmsId && panelTargetRef.current && cmsId !== panelTargetRef.current.cmsId) {
|
|
89
|
+
setPanelOpen(false)
|
|
90
|
+
panelTargetRef.current = null
|
|
91
|
+
}
|
|
92
|
+
}, [cmsId])
|
|
93
|
+
|
|
94
|
+
// Close on click outside
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!panelOpen) return
|
|
97
|
+
|
|
98
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
99
|
+
const target = e.target as HTMLElement
|
|
100
|
+
if (target.closest('[data-cms-ui]')) return
|
|
101
|
+
setPanelOpen(false)
|
|
102
|
+
panelTargetRef.current = null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const timeout = setTimeout(() => {
|
|
106
|
+
document.addEventListener('click', handleClickOutside)
|
|
107
|
+
}, 100)
|
|
108
|
+
|
|
109
|
+
return () => {
|
|
110
|
+
clearTimeout(timeout)
|
|
111
|
+
document.removeEventListener('click', handleClickOutside)
|
|
112
|
+
}
|
|
113
|
+
}, [panelOpen])
|
|
114
|
+
|
|
115
|
+
const handleBadgeClick = useCallback((e: MouseEvent) => {
|
|
116
|
+
e.preventDefault()
|
|
117
|
+
e.stopPropagation()
|
|
118
|
+
if (panelOpen) {
|
|
119
|
+
setPanelOpen(false)
|
|
120
|
+
panelTargetRef.current = null
|
|
121
|
+
} else if (cmsId && element) {
|
|
122
|
+
setPanelOpen(true)
|
|
123
|
+
panelTargetRef.current = { cmsId, element }
|
|
124
|
+
}
|
|
125
|
+
}, [panelOpen, cmsId, element])
|
|
126
|
+
|
|
127
|
+
const handleClose = useCallback(() => {
|
|
128
|
+
setPanelOpen(false)
|
|
129
|
+
panelTargetRef.current = null
|
|
130
|
+
}, [])
|
|
131
|
+
|
|
132
|
+
// Use panel target (stable) or hover target for reading change data
|
|
133
|
+
const activeCmsId = panelTargetRef.current?.cmsId ?? cmsId
|
|
134
|
+
const activeElement = panelTargetRef.current?.element ?? element
|
|
135
|
+
|
|
136
|
+
const handleImageUrlChange = useCallback((url: string) => {
|
|
137
|
+
if (!activeElement || !activeCmsId) return
|
|
138
|
+
const newBgImageClass = `bg-[url('${url}')]`
|
|
139
|
+
applyBgImageUpdate(activeElement, activeCmsId, { newBgImageClass })
|
|
140
|
+
}, [activeElement, activeCmsId])
|
|
141
|
+
|
|
142
|
+
const handleBrowse = useCallback(() => {
|
|
143
|
+
if (!activeElement || !activeCmsId) return
|
|
144
|
+
signals.openMediaLibraryWithCallback((url: string) => {
|
|
145
|
+
const newBgImageClass = `bg-[url('${url}')]`
|
|
146
|
+
applyBgImageUpdate(activeElement, activeCmsId, { newBgImageClass })
|
|
147
|
+
})
|
|
148
|
+
}, [activeElement, activeCmsId])
|
|
149
|
+
|
|
150
|
+
const handleSizeChange = useCallback((value: string) => {
|
|
151
|
+
if (!activeElement || !activeCmsId) return
|
|
152
|
+
applyBgImageUpdate(activeElement, activeCmsId, { newBgSize: value })
|
|
153
|
+
}, [activeElement, activeCmsId])
|
|
154
|
+
|
|
155
|
+
const handlePositionChange = useCallback((value: string) => {
|
|
156
|
+
if (!activeElement || !activeCmsId) return
|
|
157
|
+
applyBgImageUpdate(activeElement, activeCmsId, { newBgPosition: value })
|
|
158
|
+
}, [activeElement, activeCmsId])
|
|
159
|
+
|
|
160
|
+
const handleRepeatChange = useCallback((value: string) => {
|
|
161
|
+
if (!activeElement || !activeCmsId) return
|
|
162
|
+
applyBgImageUpdate(activeElement, activeCmsId, { newBgRepeat: value })
|
|
163
|
+
}, [activeElement, activeCmsId])
|
|
164
|
+
|
|
165
|
+
// Read change data for the active target
|
|
166
|
+
// Subscribe to signal for reactivity
|
|
167
|
+
const _bgChanges = signals.pendingBgImageChanges.value
|
|
168
|
+
const change = activeCmsId ? signals.getPendingBgImageChange(activeCmsId) : null
|
|
169
|
+
const currentUrl = change ? extractUrlFromClass(change.newBgImageClass) : ''
|
|
170
|
+
|
|
171
|
+
// Per-field dirty tracking
|
|
172
|
+
const isImageDirty = change ? change.newBgImageClass !== change.originalBgImageClass : false
|
|
173
|
+
const isSizeDirty = change ? change.newBgSize !== change.originalBgSize : false
|
|
174
|
+
const isPositionDirty = change ? change.newBgPosition !== change.originalBgPosition : false
|
|
175
|
+
const isRepeatDirty = change ? change.newBgRepeat !== change.originalBgRepeat : false
|
|
176
|
+
|
|
177
|
+
let dirtyCount = 0
|
|
178
|
+
if (isImageDirty) dirtyCount++
|
|
179
|
+
if (isSizeDirty) dirtyCount++
|
|
180
|
+
if (isPositionDirty) dirtyCount++
|
|
181
|
+
if (isRepeatDirty) dirtyCount++
|
|
182
|
+
|
|
183
|
+
// Don't render anything if badge isn't visible and panel isn't open
|
|
184
|
+
if (!visible && !panelOpen) return null
|
|
185
|
+
|
|
186
|
+
// Badge positioning: top-right of element
|
|
187
|
+
const badgeLeft = rect ? rect.right - 110 : 0
|
|
188
|
+
const badgeTop = rect ? rect.top + 6 : 0
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<>
|
|
192
|
+
{/* Badge - floating at element top-right */}
|
|
193
|
+
{visible && rect && (
|
|
194
|
+
<div
|
|
195
|
+
data-cms-ui
|
|
196
|
+
onClick={handleBadgeClick}
|
|
197
|
+
class="fixed flex items-center gap-1.5 px-2.5 py-1 bg-cms-dark/90 border border-white/15 rounded-full text-white text-[11px] font-medium cursor-pointer backdrop-blur-sm transition-all hover:bg-cms-dark hover:border-cms-primary/50 whitespace-nowrap"
|
|
198
|
+
style={{
|
|
199
|
+
left: `${badgeLeft}px`,
|
|
200
|
+
top: `${badgeTop}px`,
|
|
201
|
+
zIndex: Z_INDEX.HIGHLIGHT,
|
|
202
|
+
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
<svg class="w-3.5 h-3.5 fill-current flex-shrink-0" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
206
|
+
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" />
|
|
207
|
+
</svg>
|
|
208
|
+
<span>Background</span>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
{/* Panel - right-side fixed modal */}
|
|
213
|
+
{panelOpen && change && (
|
|
214
|
+
<div
|
|
215
|
+
data-cms-ui
|
|
216
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
217
|
+
onClick={(e) => e.stopPropagation()}
|
|
218
|
+
class="right-8 top-8 bottom-8 fixed text-xs w-80"
|
|
219
|
+
style={{
|
|
220
|
+
zIndex: Z_INDEX.MODAL,
|
|
221
|
+
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
<div class="bg-cms-dark border border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.4)] rounded-cms-lg flex flex-col h-full overflow-hidden">
|
|
225
|
+
{/* Header */}
|
|
226
|
+
<div class="flex items-center justify-between p-4 border-b border-white/10">
|
|
227
|
+
<div class="flex items-center gap-2">
|
|
228
|
+
<span class="font-medium text-white">Background Image</span>
|
|
229
|
+
{dirtyCount > 0 && (
|
|
230
|
+
<span class="px-2 py-0.5 text-xs font-medium bg-cms-primary/20 text-cms-primary rounded-full">
|
|
231
|
+
{dirtyCount}
|
|
232
|
+
</span>
|
|
233
|
+
)}
|
|
234
|
+
</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>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{/* Content */}
|
|
248
|
+
<div class="flex-1 overflow-y-auto p-4">
|
|
249
|
+
<div class="space-y-3">
|
|
250
|
+
{/* Image URL */}
|
|
251
|
+
<ImageField
|
|
252
|
+
label="Image URL"
|
|
253
|
+
value={currentUrl || undefined}
|
|
254
|
+
placeholder="/assets/image.png"
|
|
255
|
+
onChange={handleImageUrlChange}
|
|
256
|
+
onBrowse={handleBrowse}
|
|
257
|
+
isDirty={isImageDirty}
|
|
258
|
+
onReset={isImageDirty
|
|
259
|
+
? () => {
|
|
260
|
+
if (activeElement && activeCmsId) {
|
|
261
|
+
applyBgImageUpdate(activeElement, activeCmsId, { newBgImageClass: change.originalBgImageClass })
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
: undefined}
|
|
265
|
+
/>
|
|
266
|
+
|
|
267
|
+
<div class="h-px bg-white/5" />
|
|
268
|
+
|
|
269
|
+
{/* Size */}
|
|
270
|
+
<div class="space-y-1.5">
|
|
271
|
+
<FieldLabel
|
|
272
|
+
label="Size"
|
|
273
|
+
isDirty={isSizeDirty}
|
|
274
|
+
onReset={isSizeDirty
|
|
275
|
+
? () => {
|
|
276
|
+
if (activeElement && activeCmsId) {
|
|
277
|
+
applyBgImageUpdate(activeElement, activeCmsId, { newBgSize: change.originalBgSize || '' })
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
: undefined}
|
|
281
|
+
/>
|
|
282
|
+
<div class="flex gap-1">
|
|
283
|
+
{SIZE_OPTIONS.map(({ value, label }) => (
|
|
284
|
+
<button
|
|
285
|
+
key={value}
|
|
286
|
+
type="button"
|
|
287
|
+
onClick={() => handleSizeChange(value)}
|
|
288
|
+
data-cms-ui
|
|
289
|
+
class={cn(
|
|
290
|
+
'flex-1 px-3 py-2 rounded-cms-sm text-sm border transition-colors cursor-pointer',
|
|
291
|
+
change.newBgSize === value
|
|
292
|
+
? 'bg-white/10 border-cms-primary text-white'
|
|
293
|
+
: 'bg-white/10 border-white/20 text-white/70 hover:border-white/40 hover:text-white',
|
|
294
|
+
)}
|
|
295
|
+
>
|
|
296
|
+
{label}
|
|
297
|
+
</button>
|
|
298
|
+
))}
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
<div class="h-px bg-white/5" />
|
|
303
|
+
|
|
304
|
+
{/* Position */}
|
|
305
|
+
<div class="space-y-1.5">
|
|
306
|
+
<FieldLabel
|
|
307
|
+
label="Position"
|
|
308
|
+
isDirty={isPositionDirty}
|
|
309
|
+
onReset={isPositionDirty
|
|
310
|
+
? () => {
|
|
311
|
+
if (activeElement && activeCmsId) {
|
|
312
|
+
applyBgImageUpdate(activeElement, activeCmsId, { newBgPosition: change.originalBgPosition || '' })
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
: undefined}
|
|
316
|
+
/>
|
|
317
|
+
<div class="inline-grid grid-cols-3 gap-0.5 bg-white/10 border border-white/20 rounded-cms-sm p-1">
|
|
318
|
+
{POSITION_OPTIONS.map(({ value, label }) => (
|
|
319
|
+
<button
|
|
320
|
+
key={value}
|
|
321
|
+
type="button"
|
|
322
|
+
onClick={() => handlePositionChange(value)}
|
|
323
|
+
title={label}
|
|
324
|
+
data-cms-ui
|
|
325
|
+
class={cn(
|
|
326
|
+
'w-7 h-7 flex items-center justify-center rounded-sm transition-colors cursor-pointer',
|
|
327
|
+
change.newBgPosition === value
|
|
328
|
+
? 'bg-cms-primary'
|
|
329
|
+
: 'hover:bg-white/15',
|
|
330
|
+
)}
|
|
331
|
+
>
|
|
332
|
+
<div
|
|
333
|
+
class={cn(
|
|
334
|
+
'w-1.5 h-1.5 rounded-full',
|
|
335
|
+
change.newBgPosition === value ? 'bg-cms-primary-text' : 'bg-white/40',
|
|
336
|
+
)}
|
|
337
|
+
/>
|
|
338
|
+
</button>
|
|
339
|
+
))}
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
<div class="h-px bg-white/5" />
|
|
344
|
+
|
|
345
|
+
{/* Repeat */}
|
|
346
|
+
<SelectField
|
|
347
|
+
label="Repeat"
|
|
348
|
+
value={change.newBgRepeat || undefined}
|
|
349
|
+
options={REPEAT_OPTIONS}
|
|
350
|
+
onChange={handleRepeatChange}
|
|
351
|
+
isDirty={isRepeatDirty}
|
|
352
|
+
onReset={isRepeatDirty
|
|
353
|
+
? () => {
|
|
354
|
+
if (activeElement && activeCmsId) {
|
|
355
|
+
applyBgImageUpdate(activeElement, activeCmsId, { newBgRepeat: change.originalBgRepeat || '' })
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
: undefined}
|
|
359
|
+
allowEmpty={false}
|
|
360
|
+
/>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
)}
|
|
366
|
+
</>
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Safely swap a class on an element using string manipulation.
|
|
372
|
+
* Avoids classList which can corrupt bg-[url('...')] tokens containing
|
|
373
|
+
* brackets, quotes, and other special characters.
|
|
374
|
+
*/
|
|
375
|
+
function swapClass(el: HTMLElement, oldClass: string | undefined, newClass: string): void {
|
|
376
|
+
let classes = el.className
|
|
377
|
+
|
|
378
|
+
// Remove old class using padded exact-match to avoid partial matches
|
|
379
|
+
if (oldClass) {
|
|
380
|
+
const padded = ` ${classes} `
|
|
381
|
+
const idx = padded.indexOf(` ${oldClass} `)
|
|
382
|
+
if (idx !== -1) {
|
|
383
|
+
classes = (padded.slice(0, idx) + ' ' + padded.slice(idx + oldClass.length + 2)).trim().replace(/\s+/g, ' ')
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Add new class if not already present
|
|
388
|
+
if (newClass) {
|
|
389
|
+
const padded = ` ${classes} `
|
|
390
|
+
if (!padded.includes(` ${newClass} `)) {
|
|
391
|
+
classes = classes ? `${classes} ${newClass}` : newClass
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
el.className = classes
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Apply a partial bg image update to the element and signals.
|
|
400
|
+
*/
|
|
401
|
+
function applyBgImageUpdate(
|
|
402
|
+
element: HTMLElement,
|
|
403
|
+
cmsId: string,
|
|
404
|
+
updates: Partial<{
|
|
405
|
+
newBgImageClass: string
|
|
406
|
+
newBgSize: string
|
|
407
|
+
newBgPosition: string
|
|
408
|
+
newBgRepeat: string
|
|
409
|
+
}>,
|
|
410
|
+
): void {
|
|
411
|
+
const change = signals.getPendingBgImageChange(cmsId)
|
|
412
|
+
if (!change) return
|
|
413
|
+
|
|
414
|
+
// Capture pre-mutation state for undo
|
|
415
|
+
const previousClassName = element.className
|
|
416
|
+
const previousStyleCssText = element.style.cssText
|
|
417
|
+
|
|
418
|
+
const newChange = { ...change }
|
|
419
|
+
|
|
420
|
+
// Apply bg image class change
|
|
421
|
+
if (updates.newBgImageClass !== undefined) {
|
|
422
|
+
swapClass(element, newChange.newBgImageClass, updates.newBgImageClass)
|
|
423
|
+
newChange.newBgImageClass = updates.newBgImageClass
|
|
424
|
+
|
|
425
|
+
const url = extractUrlFromClass(updates.newBgImageClass)
|
|
426
|
+
element.style.backgroundImage = `url('${url}')`
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Apply bg size change
|
|
430
|
+
if (updates.newBgSize !== undefined) {
|
|
431
|
+
swapClass(element, newChange.newBgSize, updates.newBgSize)
|
|
432
|
+
newChange.newBgSize = updates.newBgSize
|
|
433
|
+
|
|
434
|
+
element.style.backgroundSize = BG_SIZE_CSS[updates.newBgSize] ?? ''
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Apply bg position change
|
|
438
|
+
if (updates.newBgPosition !== undefined) {
|
|
439
|
+
swapClass(element, newChange.newBgPosition, updates.newBgPosition)
|
|
440
|
+
newChange.newBgPosition = updates.newBgPosition
|
|
441
|
+
|
|
442
|
+
element.style.backgroundPosition = BG_POSITION_CSS[updates.newBgPosition] ?? ''
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Apply bg repeat change
|
|
446
|
+
if (updates.newBgRepeat !== undefined) {
|
|
447
|
+
swapClass(element, newChange.newBgRepeat, updates.newBgRepeat)
|
|
448
|
+
newChange.newBgRepeat = updates.newBgRepeat
|
|
449
|
+
|
|
450
|
+
element.style.backgroundRepeat = BG_REPEAT_CSS[updates.newBgRepeat] ?? ''
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Check dirty state
|
|
454
|
+
newChange.isDirty = newChange.newBgImageClass !== newChange.originalBgImageClass
|
|
455
|
+
|| newChange.newBgSize !== newChange.originalBgSize
|
|
456
|
+
|| newChange.newBgPosition !== newChange.originalBgPosition
|
|
457
|
+
|| newChange.newBgRepeat !== newChange.originalBgRepeat
|
|
458
|
+
|
|
459
|
+
// Record undo action after DOM is mutated
|
|
460
|
+
if (!isApplyingUndoRedo) {
|
|
461
|
+
recordChange({
|
|
462
|
+
type: 'bgImage',
|
|
463
|
+
cmsId,
|
|
464
|
+
element,
|
|
465
|
+
previousClassName,
|
|
466
|
+
currentClassName: element.className,
|
|
467
|
+
previousStyleCssText,
|
|
468
|
+
currentStyleCssText: element.style.cssText,
|
|
469
|
+
previousBgImageClass: change.newBgImageClass,
|
|
470
|
+
currentBgImageClass: newChange.newBgImageClass,
|
|
471
|
+
previousBgSize: change.newBgSize,
|
|
472
|
+
currentBgSize: newChange.newBgSize,
|
|
473
|
+
previousBgPosition: change.newBgPosition,
|
|
474
|
+
currentBgPosition: newChange.newBgPosition,
|
|
475
|
+
previousBgRepeat: change.newBgRepeat,
|
|
476
|
+
currentBgRepeat: newChange.newBgRepeat,
|
|
477
|
+
wasDirty: change.isDirty,
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
signals.setPendingBgImageChange(cmsId, newChange)
|
|
482
|
+
saveBgImageEditsToStorage(signals.pendingBgImageChanges.value)
|
|
483
|
+
}
|
|
@@ -168,6 +168,7 @@ function collectEditableElements(): HighlightRect[] {
|
|
|
168
168
|
const textElements = document.querySelectorAll('[data-cms-id]')
|
|
169
169
|
const componentElements = document.querySelectorAll('[data-cms-component-id]')
|
|
170
170
|
const imageElements = document.querySelectorAll('img[data-cms-img]')
|
|
171
|
+
const bgImageElements = document.querySelectorAll('[data-cms-bg-img]')
|
|
171
172
|
|
|
172
173
|
// Process text elements
|
|
173
174
|
textElements.forEach((el) => {
|
|
@@ -218,6 +219,19 @@ function collectEditableElements(): HighlightRect[] {
|
|
|
218
219
|
highlights.push({ cmsId, type: 'image', rect })
|
|
219
220
|
})
|
|
220
221
|
|
|
222
|
+
// Process background image elements
|
|
223
|
+
bgImageElements.forEach((el) => {
|
|
224
|
+
const cmsId = el.getAttribute('data-cms-id')
|
|
225
|
+
if (!cmsId) return
|
|
226
|
+
|
|
227
|
+
const rect = el.getBoundingClientRect()
|
|
228
|
+
if (rect.width < 10 || rect.height < 10) return
|
|
229
|
+
if (rect.bottom < 0 || rect.top > window.innerHeight) return
|
|
230
|
+
if (rect.right < 0 || rect.left > window.innerWidth) return
|
|
231
|
+
|
|
232
|
+
highlights.push({ cmsId, type: 'image', rect })
|
|
233
|
+
})
|
|
234
|
+
|
|
221
235
|
return highlights
|
|
222
236
|
}
|
|
223
237
|
|
|
@@ -556,15 +556,19 @@ function ObjectFields({ label, value, onChange, schemaFields, extraKeys }: Objec
|
|
|
556
556
|
<RemoveIcon />
|
|
557
557
|
</button>
|
|
558
558
|
</div>
|
|
559
|
-
))
|
|
560
|
-
}
|
|
559
|
+
))}
|
|
561
560
|
{/* Add new key */}
|
|
562
561
|
<div class="flex items-center gap-2 pt-1">
|
|
563
562
|
<input
|
|
564
563
|
type="text"
|
|
565
564
|
value={newKey}
|
|
566
565
|
onInput={(e) => setNewKey((e.target as HTMLInputElement).value)}
|
|
567
|
-
onKeyDown={(e) => {
|
|
566
|
+
onKeyDown={(e) => {
|
|
567
|
+
if (e.key === 'Enter') {
|
|
568
|
+
e.preventDefault()
|
|
569
|
+
handleAddKey()
|
|
570
|
+
}
|
|
571
|
+
}}
|
|
568
572
|
placeholder="New field name..."
|
|
569
573
|
class="flex-1 px-2 py-1 text-xs bg-white/5 border border-white/10 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30"
|
|
570
574
|
data-cms-ui
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'preact/hooks'
|
|
2
|
+
import type { Attribute } from '../../types'
|
|
2
3
|
import { getColorPreview, parseColorClass } from '../color-utils'
|
|
3
4
|
import { Z_INDEX } from '../constants'
|
|
4
5
|
import { isPageDark } from '../dom'
|
|
5
6
|
import * as signals from '../signals'
|
|
6
|
-
import type { Attribute } from '../../types'
|
|
7
7
|
|
|
8
8
|
export interface OutlineProps {
|
|
9
9
|
visible: boolean
|
|
@@ -35,7 +35,8 @@ const STICKY_PADDING = 8
|
|
|
35
35
|
* Uses a custom element with Shadow DOM to avoid style conflicts.
|
|
36
36
|
*/
|
|
37
37
|
export function Outline(
|
|
38
|
-
{ visible, rect, isComponent = false, componentName, tagName, element, cmsId, textStyleClasses, onColorClick, onAttributeClick, onTextStyleChange }:
|
|
38
|
+
{ visible, rect, isComponent = false, componentName, tagName, element, cmsId, textStyleClasses, onColorClick, onAttributeClick, onTextStyleChange }:
|
|
39
|
+
OutlineProps,
|
|
39
40
|
) {
|
|
40
41
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
41
42
|
const shadowRootRef = useRef<ShadowRoot | null>(null)
|
|
@@ -196,7 +196,10 @@ export function TextStyleToolbar({ visible, rect, element, onStyleChange }: Text
|
|
|
196
196
|
return (
|
|
197
197
|
<div
|
|
198
198
|
data-cms-ui
|
|
199
|
-
onMouseDown={(e) => {
|
|
199
|
+
onMouseDown={(e) => {
|
|
200
|
+
e.preventDefault()
|
|
201
|
+
e.stopPropagation()
|
|
202
|
+
}}
|
|
200
203
|
onClick={(e) => e.stopPropagation()}
|
|
201
204
|
style={{
|
|
202
205
|
position: 'fixed',
|