@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/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.3",
17
+ "version": "0.8.2",
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,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) => { 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',