@nuasite/cms 0.7.2 → 0.8.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.7.2",
17
+ "version": "0.8.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -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
- ? 'hover:text'
157
- : colorType
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
- ? 'hover:text'
229
- : colorType
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
- ? 'color'
270
- : colorType === 'border'
271
- ? 'borderColor'
272
- : 'color'
269
+ ? 'color'
270
+ : colorType === 'border'
271
+ ? 'borderColor'
272
+ : 'color'
273
273
  element.style[styleProperty] = cssValue
274
274
  }
275
275
  }
@@ -0,0 +1,456 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
2
+ import { Z_INDEX } from '../constants'
3
+ import { cn } from '../lib/cn'
4
+ import * as signals from '../signals'
5
+ import { saveBgImageEditsToStorage } from '../storage'
6
+ import { FieldLabel, ImageField, SelectField } from './fields'
7
+
8
+ export interface BgImageOverlayProps {
9
+ visible: boolean
10
+ rect: DOMRect | null
11
+ element: HTMLElement | null
12
+ cmsId: string | null
13
+ }
14
+
15
+ /** Map bg-size class to CSS value for inline preview */
16
+ const BG_SIZE_CSS: Record<string, string> = {
17
+ 'bg-auto': 'auto',
18
+ 'bg-cover': 'cover',
19
+ 'bg-contain': 'contain',
20
+ }
21
+
22
+ /** Map bg-position class to CSS value for inline preview */
23
+ const BG_POSITION_CSS: Record<string, string> = {
24
+ 'bg-center': 'center',
25
+ 'bg-top': 'top',
26
+ 'bg-bottom': 'bottom',
27
+ 'bg-left': 'left',
28
+ 'bg-right': 'right',
29
+ 'bg-top-left': 'top left',
30
+ 'bg-top-right': 'top right',
31
+ 'bg-bottom-left': 'bottom left',
32
+ 'bg-bottom-right': 'bottom right',
33
+ }
34
+
35
+ /** Map bg-repeat class to CSS value for inline preview */
36
+ const BG_REPEAT_CSS: Record<string, string> = {
37
+ 'bg-repeat': 'repeat',
38
+ 'bg-no-repeat': 'no-repeat',
39
+ 'bg-repeat-x': 'repeat-x',
40
+ 'bg-repeat-y': 'repeat-y',
41
+ 'bg-repeat-round': 'round',
42
+ 'bg-repeat-space': 'space',
43
+ }
44
+
45
+ /** Extract image URL from bg-[url('...')] class */
46
+ function extractUrlFromClass(cls: string): string {
47
+ const match = cls.match(/^bg-\[url\(['"]?([^'")\]]+)['"]?\)\]$/)
48
+ return match?.[1] ?? ''
49
+ }
50
+
51
+ const SIZE_OPTIONS = [
52
+ { value: 'bg-auto', label: 'Auto' },
53
+ { value: 'bg-contain', label: 'Contain' },
54
+ { value: 'bg-cover', label: 'Cover' },
55
+ ]
56
+
57
+ const POSITION_OPTIONS = [
58
+ { value: 'bg-top-left', label: 'Top Left' },
59
+ { value: 'bg-top', label: 'Top' },
60
+ { value: 'bg-top-right', label: 'Top Right' },
61
+ { value: 'bg-left', label: 'Left' },
62
+ { value: 'bg-center', label: 'Center' },
63
+ { value: 'bg-right', label: 'Right' },
64
+ { value: 'bg-bottom-left', label: 'Bottom Left' },
65
+ { value: 'bg-bottom', label: 'Bottom' },
66
+ { value: 'bg-bottom-right', label: 'Bottom Right' },
67
+ ]
68
+
69
+ const REPEAT_OPTIONS = [
70
+ { value: 'bg-repeat', label: 'Repeat' },
71
+ { value: 'bg-no-repeat', label: 'No Repeat' },
72
+ { value: 'bg-repeat-x', label: 'Repeat X' },
73
+ { value: 'bg-repeat-y', label: 'Repeat Y' },
74
+ ]
75
+
76
+ /**
77
+ * Background image overlay component.
78
+ * Shows a floating badge on hover and opens a right-side settings panel on click.
79
+ */
80
+ export function BgImageOverlay({ visible, rect, element, cmsId }: BgImageOverlayProps) {
81
+ const [panelOpen, setPanelOpen] = useState(false)
82
+ // Capture target when panel opens so it stays stable when hover moves away
83
+ const panelTargetRef = useRef<{ cmsId: string; element: HTMLElement } | null>(null)
84
+
85
+ // Close panel when hovering a different bg-image element
86
+ useEffect(() => {
87
+ if (cmsId && panelTargetRef.current && cmsId !== panelTargetRef.current.cmsId) {
88
+ setPanelOpen(false)
89
+ panelTargetRef.current = null
90
+ }
91
+ }, [cmsId])
92
+
93
+ // Close on click outside
94
+ useEffect(() => {
95
+ if (!panelOpen) return
96
+
97
+ const handleClickOutside = (e: MouseEvent) => {
98
+ const target = e.target as HTMLElement
99
+ if (target.closest('[data-cms-ui]')) return
100
+ setPanelOpen(false)
101
+ panelTargetRef.current = null
102
+ }
103
+
104
+ const timeout = setTimeout(() => {
105
+ document.addEventListener('click', handleClickOutside)
106
+ }, 100)
107
+
108
+ return () => {
109
+ clearTimeout(timeout)
110
+ document.removeEventListener('click', handleClickOutside)
111
+ }
112
+ }, [panelOpen])
113
+
114
+ const handleBadgeClick = useCallback((e: MouseEvent) => {
115
+ e.preventDefault()
116
+ e.stopPropagation()
117
+ if (panelOpen) {
118
+ setPanelOpen(false)
119
+ panelTargetRef.current = null
120
+ } else if (cmsId && element) {
121
+ setPanelOpen(true)
122
+ panelTargetRef.current = { cmsId, element }
123
+ }
124
+ }, [panelOpen, cmsId, element])
125
+
126
+ const handleClose = useCallback(() => {
127
+ setPanelOpen(false)
128
+ panelTargetRef.current = null
129
+ }, [])
130
+
131
+ // Use panel target (stable) or hover target for reading change data
132
+ const activeCmsId = panelTargetRef.current?.cmsId ?? cmsId
133
+ const activeElement = panelTargetRef.current?.element ?? element
134
+
135
+ const handleImageUrlChange = useCallback((url: string) => {
136
+ if (!activeElement || !activeCmsId) return
137
+ const newBgImageClass = `bg-[url('${url}')]`
138
+ applyBgImageUpdate(activeElement, activeCmsId, { newBgImageClass })
139
+ }, [activeElement, activeCmsId])
140
+
141
+ const handleBrowse = useCallback(() => {
142
+ if (!activeElement || !activeCmsId) return
143
+ signals.openMediaLibraryWithCallback((url: string) => {
144
+ const newBgImageClass = `bg-[url('${url}')]`
145
+ applyBgImageUpdate(activeElement, activeCmsId, { newBgImageClass })
146
+ })
147
+ }, [activeElement, activeCmsId])
148
+
149
+ const handleSizeChange = useCallback((value: string) => {
150
+ if (!activeElement || !activeCmsId) return
151
+ applyBgImageUpdate(activeElement, activeCmsId, { newBgSize: value })
152
+ }, [activeElement, activeCmsId])
153
+
154
+ const handlePositionChange = useCallback((value: string) => {
155
+ if (!activeElement || !activeCmsId) return
156
+ applyBgImageUpdate(activeElement, activeCmsId, { newBgPosition: value })
157
+ }, [activeElement, activeCmsId])
158
+
159
+ const handleRepeatChange = useCallback((value: string) => {
160
+ if (!activeElement || !activeCmsId) return
161
+ applyBgImageUpdate(activeElement, activeCmsId, { newBgRepeat: value })
162
+ }, [activeElement, activeCmsId])
163
+
164
+ // Read change data for the active target
165
+ // Subscribe to signal for reactivity
166
+ const _bgChanges = signals.pendingBgImageChanges.value
167
+ const change = activeCmsId ? signals.getPendingBgImageChange(activeCmsId) : null
168
+ const currentUrl = change ? extractUrlFromClass(change.newBgImageClass) : ''
169
+
170
+ // Per-field dirty tracking
171
+ const isImageDirty = change ? change.newBgImageClass !== change.originalBgImageClass : false
172
+ const isSizeDirty = change ? change.newBgSize !== change.originalBgSize : false
173
+ const isPositionDirty = change ? change.newBgPosition !== change.originalBgPosition : false
174
+ const isRepeatDirty = change ? change.newBgRepeat !== change.originalBgRepeat : false
175
+
176
+ let dirtyCount = 0
177
+ if (isImageDirty) dirtyCount++
178
+ if (isSizeDirty) dirtyCount++
179
+ if (isPositionDirty) dirtyCount++
180
+ if (isRepeatDirty) dirtyCount++
181
+
182
+ // Don't render anything if badge isn't visible and panel isn't open
183
+ if (!visible && !panelOpen) return null
184
+
185
+ // Badge positioning: top-right of element
186
+ const badgeLeft = rect ? rect.right - 110 : 0
187
+ const badgeTop = rect ? rect.top + 6 : 0
188
+
189
+ return (
190
+ <>
191
+ {/* Badge - floating at element top-right */}
192
+ {visible && rect && (
193
+ <div
194
+ data-cms-ui
195
+ onClick={handleBadgeClick}
196
+ 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"
197
+ style={{
198
+ left: `${badgeLeft}px`,
199
+ top: `${badgeTop}px`,
200
+ zIndex: Z_INDEX.HIGHLIGHT,
201
+ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
202
+ }}
203
+ >
204
+ <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">
205
+ <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" />
206
+ </svg>
207
+ <span>Background</span>
208
+ </div>
209
+ )}
210
+
211
+ {/* Panel - right-side fixed modal */}
212
+ {panelOpen && change && (
213
+ <div
214
+ data-cms-ui
215
+ onMouseDown={(e) => e.stopPropagation()}
216
+ onClick={(e) => e.stopPropagation()}
217
+ class="right-8 top-8 bottom-8 fixed text-xs w-80"
218
+ style={{
219
+ zIndex: Z_INDEX.MODAL,
220
+ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
221
+ }}
222
+ >
223
+ <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">
224
+ {/* Header */}
225
+ <div class="flex items-center justify-between p-4 border-b border-white/10">
226
+ <div class="flex items-center gap-2">
227
+ <span class="font-medium text-white">Background Image</span>
228
+ {dirtyCount > 0 && (
229
+ <span class="px-2 py-0.5 text-xs font-medium bg-cms-primary/20 text-cms-primary rounded-full">
230
+ {dirtyCount}
231
+ </span>
232
+ )}
233
+ </div>
234
+ <button
235
+ type="button"
236
+ onClick={handleClose}
237
+ class="text-white/50 hover:text-white cursor-pointer p-1.5 hover:bg-white/10 rounded-full transition-colors"
238
+ data-cms-ui
239
+ >
240
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
241
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
242
+ </svg>
243
+ </button>
244
+ </div>
245
+
246
+ {/* Content */}
247
+ <div class="flex-1 overflow-y-auto p-4">
248
+ <div class="space-y-3">
249
+ {/* Image URL */}
250
+ <ImageField
251
+ label="Image URL"
252
+ value={currentUrl || undefined}
253
+ placeholder="/assets/image.png"
254
+ onChange={handleImageUrlChange}
255
+ onBrowse={handleBrowse}
256
+ isDirty={isImageDirty}
257
+ onReset={isImageDirty
258
+ ? () => {
259
+ if (activeElement && activeCmsId) {
260
+ applyBgImageUpdate(activeElement, activeCmsId, { newBgImageClass: change.originalBgImageClass })
261
+ }
262
+ }
263
+ : undefined}
264
+ />
265
+
266
+ <div class="h-px bg-white/5" />
267
+
268
+ {/* Size */}
269
+ <div class="space-y-1.5">
270
+ <FieldLabel
271
+ label="Size"
272
+ isDirty={isSizeDirty}
273
+ onReset={isSizeDirty
274
+ ? () => {
275
+ if (activeElement && activeCmsId) {
276
+ applyBgImageUpdate(activeElement, activeCmsId, { newBgSize: change.originalBgSize || '' })
277
+ }
278
+ }
279
+ : undefined}
280
+ />
281
+ <div class="flex gap-1">
282
+ {SIZE_OPTIONS.map(({ value, label }) => (
283
+ <button
284
+ key={value}
285
+ type="button"
286
+ onClick={() => handleSizeChange(value)}
287
+ data-cms-ui
288
+ class={cn(
289
+ 'flex-1 px-3 py-2 rounded-cms-sm text-sm border transition-colors cursor-pointer',
290
+ change.newBgSize === value
291
+ ? 'bg-white/10 border-cms-primary text-white'
292
+ : 'bg-white/10 border-white/20 text-white/70 hover:border-white/40 hover:text-white',
293
+ )}
294
+ >
295
+ {label}
296
+ </button>
297
+ ))}
298
+ </div>
299
+ </div>
300
+
301
+ <div class="h-px bg-white/5" />
302
+
303
+ {/* Position */}
304
+ <div class="space-y-1.5">
305
+ <FieldLabel
306
+ label="Position"
307
+ isDirty={isPositionDirty}
308
+ onReset={isPositionDirty
309
+ ? () => {
310
+ if (activeElement && activeCmsId) {
311
+ applyBgImageUpdate(activeElement, activeCmsId, { newBgPosition: change.originalBgPosition || '' })
312
+ }
313
+ }
314
+ : undefined}
315
+ />
316
+ <div class="inline-grid grid-cols-3 gap-0.5 bg-white/10 border border-white/20 rounded-cms-sm p-1">
317
+ {POSITION_OPTIONS.map(({ value, label }) => (
318
+ <button
319
+ key={value}
320
+ type="button"
321
+ onClick={() => handlePositionChange(value)}
322
+ title={label}
323
+ data-cms-ui
324
+ class={cn(
325
+ 'w-7 h-7 flex items-center justify-center rounded-sm transition-colors cursor-pointer',
326
+ change.newBgPosition === value
327
+ ? 'bg-cms-primary'
328
+ : 'hover:bg-white/15',
329
+ )}
330
+ >
331
+ <div
332
+ class={cn(
333
+ 'w-1.5 h-1.5 rounded-full',
334
+ change.newBgPosition === value ? 'bg-cms-primary-text' : 'bg-white/40',
335
+ )}
336
+ />
337
+ </button>
338
+ ))}
339
+ </div>
340
+ </div>
341
+
342
+ <div class="h-px bg-white/5" />
343
+
344
+ {/* Repeat */}
345
+ <SelectField
346
+ label="Repeat"
347
+ value={change.newBgRepeat || undefined}
348
+ options={REPEAT_OPTIONS}
349
+ onChange={handleRepeatChange}
350
+ isDirty={isRepeatDirty}
351
+ onReset={isRepeatDirty
352
+ ? () => {
353
+ if (activeElement && activeCmsId) {
354
+ applyBgImageUpdate(activeElement, activeCmsId, { newBgRepeat: change.originalBgRepeat || '' })
355
+ }
356
+ }
357
+ : undefined}
358
+ allowEmpty={false}
359
+ />
360
+ </div>
361
+ </div>
362
+ </div>
363
+ </div>
364
+ )}
365
+ </>
366
+ )
367
+ }
368
+
369
+ /**
370
+ * Safely swap a class on an element using string manipulation.
371
+ * Avoids classList which can corrupt bg-[url('...')] tokens containing
372
+ * brackets, quotes, and other special characters.
373
+ */
374
+ function swapClass(el: HTMLElement, oldClass: string | undefined, newClass: string): void {
375
+ let classes = el.className
376
+
377
+ // Remove old class using padded exact-match to avoid partial matches
378
+ if (oldClass) {
379
+ const padded = ` ${classes} `
380
+ const idx = padded.indexOf(` ${oldClass} `)
381
+ if (idx !== -1) {
382
+ classes = (padded.slice(0, idx) + ' ' + padded.slice(idx + oldClass.length + 2)).trim().replace(/\s+/g, ' ')
383
+ }
384
+ }
385
+
386
+ // Add new class if not already present
387
+ if (newClass) {
388
+ const padded = ` ${classes} `
389
+ if (!padded.includes(` ${newClass} `)) {
390
+ classes = classes ? `${classes} ${newClass}` : newClass
391
+ }
392
+ }
393
+
394
+ el.className = classes
395
+ }
396
+
397
+ /**
398
+ * Apply a partial bg image update to the element and signals.
399
+ */
400
+ function applyBgImageUpdate(
401
+ element: HTMLElement,
402
+ cmsId: string,
403
+ updates: Partial<{
404
+ newBgImageClass: string
405
+ newBgSize: string
406
+ newBgPosition: string
407
+ newBgRepeat: string
408
+ }>,
409
+ ): void {
410
+ const change = signals.getPendingBgImageChange(cmsId)
411
+ if (!change) return
412
+
413
+ const newChange = { ...change }
414
+
415
+ // Apply bg image class change
416
+ if (updates.newBgImageClass !== undefined) {
417
+ swapClass(element, newChange.newBgImageClass, updates.newBgImageClass)
418
+ newChange.newBgImageClass = updates.newBgImageClass
419
+
420
+ const url = extractUrlFromClass(updates.newBgImageClass)
421
+ element.style.backgroundImage = `url('${url}')`
422
+ }
423
+
424
+ // Apply bg size change
425
+ if (updates.newBgSize !== undefined) {
426
+ swapClass(element, newChange.newBgSize, updates.newBgSize)
427
+ newChange.newBgSize = updates.newBgSize
428
+
429
+ element.style.backgroundSize = BG_SIZE_CSS[updates.newBgSize] ?? ''
430
+ }
431
+
432
+ // Apply bg position change
433
+ if (updates.newBgPosition !== undefined) {
434
+ swapClass(element, newChange.newBgPosition, updates.newBgPosition)
435
+ newChange.newBgPosition = updates.newBgPosition
436
+
437
+ element.style.backgroundPosition = BG_POSITION_CSS[updates.newBgPosition] ?? ''
438
+ }
439
+
440
+ // Apply bg repeat change
441
+ if (updates.newBgRepeat !== undefined) {
442
+ swapClass(element, newChange.newBgRepeat, updates.newBgRepeat)
443
+ newChange.newBgRepeat = updates.newBgRepeat
444
+
445
+ element.style.backgroundRepeat = BG_REPEAT_CSS[updates.newBgRepeat] ?? ''
446
+ }
447
+
448
+ // Check dirty state
449
+ newChange.isDirty = newChange.newBgImageClass !== newChange.originalBgImageClass
450
+ || newChange.newBgSize !== newChange.originalBgSize
451
+ || newChange.newBgPosition !== newChange.originalBgPosition
452
+ || newChange.newBgRepeat !== newChange.originalBgRepeat
453
+
454
+ signals.setPendingBgImageChange(cmsId, newChange)
455
+ saveBgImageEditsToStorage(signals.pendingBgImageChanges.value)
456
+ }
@@ -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) => { if (e.key === 'Enter') { e.preventDefault(); handleAddKey() } }}
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 }: OutlineProps,
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) => { e.preventDefault(); e.stopPropagation() }}
199
+ onMouseDown={(e) => {
200
+ e.preventDefault()
201
+ e.stopPropagation()
202
+ }}
200
203
  onClick={(e) => e.stopPropagation()}
201
204
  style={{
202
205
  position: 'fixed',
@@ -87,6 +87,7 @@ export const STORAGE_KEYS = {
87
87
  PENDING_IMAGE_EDITS: 'cms-pending-image-edits',
88
88
  PENDING_COLOR_EDITS: 'cms-pending-color-edits',
89
89
  PENDING_ATTRIBUTE_EDITS: 'cms-pending-attribute-edits',
90
+ PENDING_BG_IMAGE_EDITS: 'cms-pending-bg-image-edits',
90
91
  SETTINGS: 'cms-settings',
91
92
  PENDING_ENTRY_NAVIGATION: 'cms-pending-entry-navigation',
92
93
  IS_EDITING: 'cms-is-editing',
@@ -104,4 +105,6 @@ export const CSS = {
104
105
  COMPONENT_ID_ATTRIBUTE: 'data-cms-component-id',
105
106
  /** Custom element tag for highlight overlay */
106
107
  HIGHLIGHT_ELEMENT: 'cms-highlight-overlay',
108
+ /** Data attribute for background image elements */
109
+ BG_IMAGE_ATTRIBUTE: 'data-cms-bg-img',
107
110
  } as const