@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.
@@ -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
@@ -20,11 +20,13 @@ import * as signals from './signals'
20
20
  import {
21
21
  clearAllEditsFromStorage,
22
22
  loadAttributeEditsFromStorage,
23
+ loadBgImageEditsFromStorage,
23
24
  loadColorEditsFromStorage,
24
25
  loadEditsFromStorage,
25
26
  loadImageEditsFromStorage,
26
27
  loadPendingEntryNavigation,
27
28
  saveAttributeEditsToStorage,
29
+ saveBgImageEditsToStorage,
28
30
  saveColorEditsToStorage,
29
31
  saveEditingState,
30
32
  saveEditsToStorage,
@@ -37,6 +39,8 @@ import type { AttributeChangePayload, ChangePayload, CmsConfig, DeploymentStatus
37
39
  const MARKDOWN_ATTRIBUTE = 'data-cms-markdown'
38
40
  // CSS attribute for image elements
39
41
  const IMAGE_ATTRIBUTE = 'data-cms-img'
42
+ // CSS attribute for background image elements
43
+ const BG_IMAGE_ATTRIBUTE = 'data-cms-bg-img'
40
44
 
41
45
  /**
42
46
  * Inline HTML elements that indicate styled/formatted content.
@@ -112,6 +116,7 @@ export async function startEditMode(
112
116
  const savedImageEdits = loadImageEditsFromStorage()
113
117
  const savedColorEdits = loadColorEditsFromStorage()
114
118
  const savedAttributeEdits = loadAttributeEditsFromStorage()
119
+ const savedBgImageEdits = loadBgImageEditsFromStorage()
115
120
  const currentManifest = signals.manifest.value
116
121
 
117
122
  getAllCmsElements().forEach(el => {
@@ -151,6 +156,15 @@ export async function startEditMode(
151
156
  return
152
157
  }
153
158
 
159
+ // Check if this is a background image element
160
+ // Background image elements are edited via the bg image overlay panel
161
+ if (el.hasAttribute(BG_IMAGE_ATTRIBUTE)) {
162
+ logDebug(config.debug, 'Background image element detected:', cmsId)
163
+ makeElementNonEditable(el)
164
+ setupBgImageTracking(config, el, cmsId, savedBgImageEdits[cmsId])
165
+ return
166
+ }
167
+
154
168
  makeElementEditable(el)
155
169
 
156
170
  // Suppress browser native contentEditable undo/redo (we handle it ourselves)
@@ -502,11 +516,42 @@ export function discardAllChanges(onStateChange?: () => void): void {
502
516
  applyAttributesToElement(element, originalAttributes)
503
517
  })
504
518
 
519
+ // Restore original background image classes
520
+ signals.pendingBgImageChanges.value.forEach((change) => {
521
+ const {
522
+ element,
523
+ originalBgImageClass,
524
+ newBgImageClass,
525
+ originalBgSize,
526
+ newBgSize,
527
+ originalBgPosition,
528
+ newBgPosition,
529
+ originalBgRepeat,
530
+ newBgRepeat,
531
+ } = change
532
+ const classes = element.className.split(/\s+/).filter(Boolean)
533
+ const newClassValues = new Set([newBgImageClass, newBgSize, newBgPosition, newBgRepeat].filter(Boolean))
534
+ const originalClassValues = [originalBgImageClass, originalBgSize, originalBgPosition, originalBgRepeat].filter(Boolean)
535
+
536
+ const filtered = classes.filter(c => !newClassValues.has(c))
537
+ for (const c of originalClassValues) {
538
+ if (!filtered.includes(c)) {
539
+ filtered.push(c)
540
+ }
541
+ }
542
+ element.className = filtered.join(' ')
543
+
544
+ // Clear inline bg style overrides
545
+ element.style.backgroundImage = ''
546
+ element.style.backgroundSize = ''
547
+ element.style.backgroundPosition = ''
548
+ element.style.backgroundRepeat = ''
549
+ })
550
+
505
551
  cleanupHighlightSystem()
506
- signals.clearPendingChanges()
507
- signals.clearPendingImageChanges()
508
- signals.clearPendingColorChanges()
509
- signals.clearPendingAttributeChanges()
552
+ for (const entry of Object.values(signals.changeRegistry)) {
553
+ entry.mapSignal.value = new Map()
554
+ }
510
555
  clearAllEditsFromStorage()
511
556
  clearHistory()
512
557
  stopEditMode(onStateChange)
@@ -550,12 +595,13 @@ export async function saveAllChanges(
550
595
  const dirtyChanges = signals.dirtyChanges.value
551
596
  const dirtyImageChanges = signals.dirtyImageChanges.value
552
597
  const dirtyColorChanges = signals.dirtyColorChanges.value
598
+ const dirtyBgImageChanges = signals.dirtyBgImageChanges.value
553
599
  const dirtyAttributeChanges = signals.dirtyAttributeChanges.value
554
600
  const dirtySeoChanges = signals.dirtySeoChanges.value
555
601
 
556
602
  if (
557
- dirtyChanges.length === 0 && dirtyImageChanges.length === 0 && dirtyColorChanges.length === 0 && dirtyAttributeChanges.length === 0
558
- && dirtySeoChanges.length === 0
603
+ dirtyChanges.length === 0 && dirtyImageChanges.length === 0 && dirtyColorChanges.length === 0 && dirtyBgImageChanges.length === 0
604
+ && dirtyAttributeChanges.length === 0 && dirtySeoChanges.length === 0
559
605
  ) {
560
606
  return { success: true, updated: 0 }
561
607
  }
@@ -653,7 +699,7 @@ export async function saveAllChanges(
653
699
  sourcePath: bestSourcePath ?? entry?.sourcePath ?? '',
654
700
  sourceLine: bestSourceLine ?? entry?.sourceLine ?? 0,
655
701
  sourceSnippet: bestSourceSnippet ?? entry?.sourceSnippet ?? '',
656
- colorChange: {
702
+ styleChange: {
657
703
  oldClass: origAttr?.value || '',
658
704
  newClass: newAttr.value,
659
705
  type: classType,
@@ -666,6 +712,44 @@ export async function saveAllChanges(
666
712
  }
667
713
  })
668
714
 
715
+ // Add background image changes to the payload
716
+ dirtyBgImageChanges.forEach(([cmsId, change]) => {
717
+ const entry = manifest.entries[cmsId]
718
+ const bgChanges: Array<{ oldClass: string; newClass: string; type: 'bgImage' | 'bgSize' | 'bgPosition' | 'bgRepeat' }> = []
719
+
720
+ if (change.newBgImageClass !== change.originalBgImageClass) {
721
+ bgChanges.push({ oldClass: change.originalBgImageClass, newClass: change.newBgImageClass, type: 'bgImage' })
722
+ }
723
+ if (change.newBgSize !== change.originalBgSize) {
724
+ bgChanges.push({ oldClass: change.originalBgSize, newClass: change.newBgSize, type: 'bgSize' })
725
+ }
726
+ if (change.newBgPosition !== change.originalBgPosition) {
727
+ bgChanges.push({ oldClass: change.originalBgPosition, newClass: change.newBgPosition, type: 'bgPosition' })
728
+ }
729
+ if (change.newBgRepeat !== change.originalBgRepeat) {
730
+ bgChanges.push({ oldClass: change.originalBgRepeat, newClass: change.newBgRepeat, type: 'bgRepeat' })
731
+ }
732
+
733
+ for (const bgChange of bgChanges) {
734
+ changes.push({
735
+ cmsId,
736
+ newValue: '',
737
+ originalValue: '',
738
+ sourcePath: entry?.sourcePath ?? '',
739
+ sourceLine: entry?.sourceLine ?? 0,
740
+ sourceSnippet: entry?.sourceSnippet ?? '',
741
+ styleChange: {
742
+ oldClass: bgChange.oldClass,
743
+ newClass: bgChange.newClass,
744
+ type: bgChange.type,
745
+ sourcePath: entry?.sourcePath,
746
+ sourceLine: entry?.sourceLine,
747
+ sourceSnippet: entry?.sourceSnippet,
748
+ },
749
+ })
750
+ }
751
+ })
752
+
669
753
  // Add attribute changes to the payload
670
754
  dirtyAttributeChanges.forEach(([cmsId, change]) => {
671
755
  const { originalAttributes, newAttributes } = change
@@ -740,6 +824,18 @@ export async function saveAllChanges(
740
824
  }))
741
825
  })
742
826
 
827
+ // Update all dirty bg image changes to mark as saved
828
+ dirtyBgImageChanges.forEach(([cmsId, change]) => {
829
+ signals.updatePendingBgImageChange(cmsId, (c) => ({
830
+ ...c,
831
+ originalBgImageClass: c.newBgImageClass,
832
+ originalBgSize: c.newBgSize,
833
+ originalBgPosition: c.newBgPosition,
834
+ originalBgRepeat: c.newBgRepeat,
835
+ isDirty: false,
836
+ }))
837
+ })
838
+
743
839
  // Update all dirty attribute changes to mark as saved
744
840
  dirtyAttributeChanges.forEach(([cmsId, change]) => {
745
841
  signals.updatePendingAttributeChange(cmsId, (c) => ({
@@ -786,6 +882,7 @@ export async function saveAllChanges(
786
882
  saveEditsToStorage(signals.pendingChanges.value)
787
883
  saveImageEditsToStorage(signals.pendingImageChanges.value)
788
884
  saveColorEditsToStorage(signals.pendingColorChanges.value)
885
+ saveBgImageEditsToStorage(signals.pendingBgImageChanges.value)
789
886
  saveAttributeEditsToStorage(signals.pendingAttributeChanges.value)
790
887
  throw err
791
888
  } finally {
@@ -1026,6 +1123,75 @@ function setupColorTracking(
1026
1123
  }
1027
1124
  }
1028
1125
 
1126
+ /**
1127
+ * Initialize background image change tracking for elements with bg-[url()] classes.
1128
+ * Background image editing is triggered via the bg image overlay panel.
1129
+ */
1130
+ function setupBgImageTracking(
1131
+ config: CmsConfig,
1132
+ el: HTMLElement,
1133
+ cmsId: string,
1134
+ savedEdit: import('./types').SavedBackgroundImageEdit | undefined,
1135
+ ): void {
1136
+ const manifest = signals.manifest.value
1137
+ const entry = manifest.entries[cmsId]
1138
+
1139
+ if (!entry?.backgroundImage) {
1140
+ return
1141
+ }
1142
+
1143
+ logDebug(config.debug, 'Setting up bg image tracking for:', cmsId, entry.backgroundImage)
1144
+
1145
+ if (!signals.pendingBgImageChanges.value.has(cmsId)) {
1146
+ if (savedEdit) {
1147
+ // Restore saved bg image classes on the element
1148
+ const classes = el.className.split(/\s+/).filter(Boolean)
1149
+ const originals = [savedEdit.originalBgImageClass, savedEdit.originalBgSize, savedEdit.originalBgPosition, savedEdit.originalBgRepeat].filter(
1150
+ Boolean,
1151
+ )
1152
+ const news = [savedEdit.newBgImageClass, savedEdit.newBgSize, savedEdit.newBgPosition, savedEdit.newBgRepeat].filter(Boolean)
1153
+
1154
+ const filtered = classes.filter(c => !originals.includes(c))
1155
+ for (const c of news) {
1156
+ if (!filtered.includes(c)) {
1157
+ filtered.push(c)
1158
+ }
1159
+ }
1160
+ el.className = filtered.join(' ')
1161
+
1162
+ signals.setPendingBgImageChange(cmsId, {
1163
+ element: el,
1164
+ cmsId,
1165
+ originalBgImageClass: savedEdit.originalBgImageClass,
1166
+ newBgImageClass: savedEdit.newBgImageClass,
1167
+ originalBgSize: savedEdit.originalBgSize,
1168
+ newBgSize: savedEdit.newBgSize,
1169
+ originalBgPosition: savedEdit.originalBgPosition,
1170
+ newBgPosition: savedEdit.newBgPosition,
1171
+ originalBgRepeat: savedEdit.originalBgRepeat,
1172
+ newBgRepeat: savedEdit.newBgRepeat,
1173
+ isDirty: true,
1174
+ })
1175
+ logDebug(config.debug, 'Restored saved bg image edit:', cmsId, savedEdit)
1176
+ } else {
1177
+ const bg = entry.backgroundImage
1178
+ signals.setPendingBgImageChange(cmsId, {
1179
+ element: el,
1180
+ cmsId,
1181
+ originalBgImageClass: bg.bgImageClass,
1182
+ newBgImageClass: bg.bgImageClass,
1183
+ originalBgSize: bg.bgSize ?? '',
1184
+ newBgSize: bg.bgSize ?? '',
1185
+ originalBgPosition: bg.bgPosition ?? '',
1186
+ newBgPosition: bg.bgPosition ?? '',
1187
+ originalBgRepeat: bg.bgRepeat ?? '',
1188
+ newBgRepeat: bg.bgRepeat ?? '',
1189
+ isDirty: false,
1190
+ })
1191
+ }
1192
+ }
1193
+ }
1194
+
1029
1195
  /**
1030
1196
  * Handle color change from the color toolbar.
1031
1197
  * Called when user selects a new color.
@@ -1,6 +1,12 @@
1
1
  import { computed, signal } from '@preact/signals'
2
2
  import * as signals from './signals'
3
- import { saveAttributeEditsToStorage, saveColorEditsToStorage, saveEditsToStorage, saveImageEditsToStorage } from './storage'
3
+ import {
4
+ saveAttributeEditsToStorage,
5
+ saveBgImageEditsToStorage,
6
+ saveColorEditsToStorage,
7
+ saveEditsToStorage,
8
+ saveImageEditsToStorage,
9
+ } from './storage'
4
10
  import type { Attribute, UndoAction, UndoTextAction } from './types'
5
11
 
6
12
  // ============================================================================
@@ -159,52 +165,104 @@ function scrollToElement(element: HTMLElement): void {
159
165
  }
160
166
  }
161
167
 
162
- function applyReverse(action: UndoAction): void {
163
- switch (action.type) {
164
- case 'text':
168
+ /** Registry of undo/redo handlers keyed by action type.
169
+ * Adding a new UndoAction variant forces a TypeScript error until its handlers are added here. */
170
+ type UndoHandlers = {
171
+ [K in UndoAction['type']]: {
172
+ applyReverse: (action: Extract<UndoAction, { type: K }>) => void
173
+ applyForward: (action: Extract<UndoAction, { type: K }>) => void
174
+ }
175
+ }
176
+
177
+ const undoRegistry: UndoHandlers = {
178
+ text: {
179
+ applyReverse: (action) => {
165
180
  scrollToElement(action.element)
166
181
  applyTextState(action.cmsId, action.element, action.previousHTML, action.previousText, action.wasDirty)
167
- break
168
- case 'image':
182
+ },
183
+ applyForward: (action) => {
184
+ scrollToElement(action.element)
185
+ applyTextState(action.cmsId, action.element, action.currentHTML, action.currentText, true)
186
+ },
187
+ },
188
+ image: {
189
+ applyReverse: (action) => {
169
190
  scrollToElement(action.element)
170
191
  applyImageState(action.cmsId, action.element, action.previousSrc, action.previousAlt, action.wasDirty)
171
- break
172
- case 'color':
192
+ },
193
+ applyForward: (action) => {
194
+ scrollToElement(action.element)
195
+ applyImageState(action.cmsId, action.element, action.currentSrc, action.currentAlt, true)
196
+ },
197
+ },
198
+ color: {
199
+ applyReverse: (action) => {
173
200
  scrollToElement(action.element)
174
201
  applyColorState(action.cmsId, action.element, action.previousClassName, action.previousStyleCssText, action.previousClasses, action.wasDirty)
175
- break
176
- case 'attribute':
202
+ },
203
+ applyForward: (action) => {
177
204
  scrollToElement(action.element)
178
- applyAttributeState(action.cmsId, action.element, action.previousAttributes, action.wasDirty)
179
- break
180
- case 'seo':
181
- applySeoState(action.cmsId, action.previousValue, action.originalValue, action.wasDirty)
182
- break
183
- }
184
- }
185
-
186
- function applyForward(action: UndoAction): void {
187
- switch (action.type) {
188
- case 'text':
205
+ applyColorState(action.cmsId, action.element, action.currentClassName, action.currentStyleCssText, action.currentClasses, true)
206
+ },
207
+ },
208
+ bgImage: {
209
+ applyReverse: (action) => {
189
210
  scrollToElement(action.element)
190
- applyTextState(action.cmsId, action.element, action.currentHTML, action.currentText, true)
191
- break
192
- case 'image':
211
+ applyBgImageState(
212
+ action.cmsId,
213
+ action.element,
214
+ action.previousClassName,
215
+ action.previousStyleCssText,
216
+ action.previousBgImageClass,
217
+ action.previousBgSize,
218
+ action.previousBgPosition,
219
+ action.previousBgRepeat,
220
+ action.wasDirty,
221
+ )
222
+ },
223
+ applyForward: (action) => {
193
224
  scrollToElement(action.element)
194
- applyImageState(action.cmsId, action.element, action.currentSrc, action.currentAlt, true)
195
- break
196
- case 'color':
225
+ applyBgImageState(
226
+ action.cmsId,
227
+ action.element,
228
+ action.currentClassName,
229
+ action.currentStyleCssText,
230
+ action.currentBgImageClass,
231
+ action.currentBgSize,
232
+ action.currentBgPosition,
233
+ action.currentBgRepeat,
234
+ true,
235
+ )
236
+ },
237
+ },
238
+ attribute: {
239
+ applyReverse: (action) => {
197
240
  scrollToElement(action.element)
198
- applyColorState(action.cmsId, action.element, action.currentClassName, action.currentStyleCssText, action.currentClasses, true)
199
- break
200
- case 'attribute':
241
+ applyAttributeState(action.cmsId, action.element, action.previousAttributes, action.wasDirty)
242
+ },
243
+ applyForward: (action) => {
201
244
  scrollToElement(action.element)
202
245
  applyAttributeState(action.cmsId, action.element, action.currentAttributes, true)
203
- break
204
- case 'seo':
246
+ },
247
+ },
248
+ seo: {
249
+ applyReverse: (action) => {
250
+ applySeoState(action.cmsId, action.previousValue, action.originalValue, action.wasDirty)
251
+ },
252
+ applyForward: (action) => {
205
253
  applySeoState(action.cmsId, action.currentValue, action.originalValue, true)
206
- break
207
- }
254
+ },
255
+ },
256
+ }
257
+
258
+ function applyReverse(action: UndoAction): void {
259
+ const handlers = undoRegistry[action.type] as UndoHandlers[typeof action.type]
260
+ handlers.applyReverse(action as any)
261
+ }
262
+
263
+ function applyForward(action: UndoAction): void {
264
+ const handlers = undoRegistry[action.type] as UndoHandlers[typeof action.type]
265
+ handlers.applyForward(action as any)
208
266
  }
209
267
 
210
268
  // ============================================================================
@@ -323,6 +381,34 @@ function applySeoState(
323
381
  })
324
382
  }
325
383
 
384
+ function applyBgImageState(
385
+ cmsId: string,
386
+ element: HTMLElement,
387
+ className: string,
388
+ styleCssText: string,
389
+ bgImageClass: string,
390
+ bgSize: string,
391
+ bgPosition: string,
392
+ bgRepeat: string,
393
+ isDirty: boolean,
394
+ ): void {
395
+ if (element.isConnected) {
396
+ element.className = className
397
+ element.style.cssText = styleCssText
398
+ }
399
+
400
+ signals.updatePendingBgImageChange(cmsId, (c) => ({
401
+ ...c,
402
+ newBgImageClass: bgImageClass,
403
+ newBgSize: bgSize,
404
+ newBgPosition: bgPosition,
405
+ newBgRepeat: bgRepeat,
406
+ isDirty,
407
+ }))
408
+
409
+ saveBgImageEditsToStorage(signals.pendingBgImageChanges.value)
410
+ }
411
+
326
412
  // ============================================================================
327
413
  // Clear History
328
414
  // ============================================================================
@@ -17,3 +17,6 @@ export type { TextSelectionState } from './useTextSelection'
17
17
 
18
18
  export { useImageHoverDetection } from './useImageHoverDetection'
19
19
  export type { ImageHoverState } from './useImageHoverDetection'
20
+
21
+ export { useBgImageHoverDetection } from './useBgImageHoverDetection'
22
+ export type { BgImageHoverState } from './useBgImageHoverDetection'
@@ -0,0 +1,101 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
2
+ import { CSS, TIMING } from '../constants'
3
+ import * as signals from '../signals'
4
+ import { isEventOnCmsUI, usePositionTracking } from './utils'
5
+
6
+ export interface BgImageHoverState {
7
+ visible: boolean
8
+ rect: DOMRect | null
9
+ element: HTMLElement | null
10
+ cmsId: string | null
11
+ }
12
+
13
+ const INITIAL_STATE: BgImageHoverState = {
14
+ visible: false,
15
+ rect: null,
16
+ element: null,
17
+ cmsId: null,
18
+ }
19
+
20
+ /**
21
+ * Hook for detecting and tracking hovered CMS background image elements.
22
+ * Shows a visual overlay when hovering over elements marked with data-cms-bg-img.
23
+ */
24
+ export function useBgImageHoverDetection(): BgImageHoverState {
25
+ const [bgImageHoverState, setBgImageHoverState] = useState<BgImageHoverState>(INITIAL_STATE)
26
+
27
+ // Throttle ref for element detection
28
+ const lastDetectionTime = useRef<number>(0)
29
+
30
+ // Handle position updates on scroll/resize
31
+ const handlePositionChange = useCallback((rect: DOMRect | null) => {
32
+ if (rect) {
33
+ setBgImageHoverState(prev => ({ ...prev, rect }))
34
+ } else {
35
+ setBgImageHoverState(INITIAL_STATE)
36
+ }
37
+ }, [])
38
+
39
+ // Track element position on scroll/resize
40
+ usePositionTracking(
41
+ bgImageHoverState.element,
42
+ handlePositionChange,
43
+ bgImageHoverState.visible,
44
+ )
45
+
46
+ // Setup hover detection for background image elements
47
+ useEffect(() => {
48
+ const handleMouseMove = (ev: MouseEvent) => {
49
+ const isEditing = signals.isEditing.value
50
+
51
+ if (!isEditing) {
52
+ setBgImageHoverState(prev => prev.visible ? INITIAL_STATE : prev)
53
+ return
54
+ }
55
+
56
+ // Check if hovering over CMS UI - keep current state
57
+ if (isEventOnCmsUI(ev)) {
58
+ return
59
+ }
60
+
61
+ // Throttle detection for performance
62
+ const now = Date.now()
63
+ if (now - lastDetectionTime.current < TIMING.ELEMENT_DETECTION_THROTTLE_MS) {
64
+ return
65
+ }
66
+ lastDetectionTime.current = now
67
+
68
+ // Check if hovering over an element with data-cms-bg-img attribute
69
+ const elements = document.elementsFromPoint(ev.clientX, ev.clientY)
70
+
71
+ for (const el of elements) {
72
+ // If there's a contentEditable element above, don't show overlay
73
+ if (el instanceof HTMLElement && el.contentEditable === 'true') {
74
+ setBgImageHoverState(INITIAL_STATE)
75
+ return
76
+ }
77
+
78
+ if (el instanceof HTMLElement && el.hasAttribute(CSS.BG_IMAGE_ATTRIBUTE)) {
79
+ const cmsId = el.getAttribute(CSS.ID_ATTRIBUTE)
80
+ const rect = el.getBoundingClientRect()
81
+
82
+ setBgImageHoverState({
83
+ visible: true,
84
+ rect,
85
+ element: el,
86
+ cmsId,
87
+ })
88
+ return
89
+ }
90
+ }
91
+
92
+ // No bg image element found, hide overlay
93
+ setBgImageHoverState(INITIAL_STATE)
94
+ }
95
+
96
+ document.addEventListener('mousemove', handleMouseMove, true)
97
+ return () => document.removeEventListener('mousemove', handleMouseMove, true)
98
+ }, [])
99
+
100
+ return bgImageHoverState
101
+ }
@@ -4,6 +4,7 @@ import { fetchManifest } from './api'
4
4
  import { AIChat } from './components/ai-chat'
5
5
  import { AITooltip } from './components/ai-tooltip'
6
6
  import { AttributeEditor } from './components/attribute-editor'
7
+ import { BgImageOverlay } from './components/bg-image-overlay'
7
8
  import { BlockEditor } from './components/block-editor'
8
9
  import { CollectionsBrowser } from './components/collections-browser'
9
10
  import { ColorToolbar } from './components/color-toolbar'
@@ -34,6 +35,7 @@ import {
34
35
  import { performRedo, performUndo } from './history'
35
36
  import {
36
37
  useAIHandlers,
38
+ useBgImageHoverDetection,
37
39
  useBlockEditorHandlers,
38
40
  useComponentClickHandler,
39
41
  useElementDetection,
@@ -79,6 +81,7 @@ const CmsUI = () => {
79
81
  const config = signals.config.value
80
82
  const outlineState = useElementDetection()
81
83
  const imageHoverState = useImageHoverDetection()
84
+ const bgImageHoverState = useBgImageHoverDetection()
82
85
  const textSelectionState = useTextSelection()
83
86
  const { tooltipState, showTooltipForElement, hideTooltip } = useTooltipState()
84
87
  const updateUI = useCallback(() => {
@@ -365,6 +368,15 @@ const CmsUI = () => {
365
368
  />
366
369
  </ErrorBoundary>
367
370
 
371
+ <ErrorBoundary componentName="BgImageOverlay">
372
+ <BgImageOverlay
373
+ visible={bgImageHoverState.visible && isEditing}
374
+ rect={bgImageHoverState.rect}
375
+ element={bgImageHoverState.element}
376
+ cmsId={bgImageHoverState.cmsId}
377
+ />
378
+ </ErrorBoundary>
379
+
368
380
  <ErrorBoundary componentName="Toolbar">
369
381
  <Toolbar
370
382
  callbacks={{