@nuasite/cms 0.9.3 → 0.11.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.9.3",
17
+ "version": "0.11.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -110,6 +110,54 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
110
110
  )
111
111
  }
112
112
 
113
+ // ============================================================================
114
+ // Color Field (color picker + hex text input)
115
+ // ============================================================================
116
+
117
+ export interface ColorFieldProps {
118
+ label: string
119
+ value: string | undefined
120
+ placeholder?: string
121
+ onChange: (value: string) => void
122
+ isDirty?: boolean
123
+ onReset?: () => void
124
+ }
125
+
126
+ export function ColorField({ label, value, placeholder, onChange, isDirty, onReset }: ColorFieldProps) {
127
+ const colorValue = value || '#000000'
128
+ // Validate hex for the native picker (must be #rrggbb)
129
+ const isValidHex = /^#[0-9a-fA-F]{6}$/.test(colorValue)
130
+ const pickerValue = isValidHex ? colorValue : '#000000'
131
+
132
+ return (
133
+ <div class="space-y-1.5">
134
+ <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
135
+ <div class="flex gap-2">
136
+ <input
137
+ type="color"
138
+ value={pickerValue}
139
+ onInput={(e) => onChange((e.target as HTMLInputElement).value)}
140
+ class="w-10 h-[38px] p-0.5 bg-white/10 border border-white/20 rounded-cms-sm cursor-pointer"
141
+ data-cms-ui
142
+ />
143
+ <input
144
+ type="text"
145
+ value={value ?? ''}
146
+ placeholder={placeholder ?? '#000000'}
147
+ onInput={(e) => onChange((e.target as HTMLInputElement).value)}
148
+ class={cn(
149
+ 'flex-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
150
+ isDirty
151
+ ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
152
+ : 'border-white/20 focus:border-white/40 focus:ring-white/10',
153
+ )}
154
+ data-cms-ui
155
+ />
156
+ </div>
157
+ </div>
158
+ )
159
+ }
160
+
113
161
  // ============================================================================
114
162
  // Select Field (native select)
115
163
  // ============================================================================
@@ -0,0 +1,213 @@
1
+ import { useCallback, useEffect, useRef } from 'preact/hooks'
2
+ import { Z_INDEX } from '../constants'
3
+ import { usePositionTracking } from '../hooks/utils'
4
+ import { getComponentInstance } from '../manifest'
5
+ import * as signals from '../signals'
6
+
7
+ /**
8
+ * Renders a persistent highlight around the currently selected element.
9
+ * Supports both component selection (edit mode) and any CMS element selection (select mode).
10
+ * Uses Shadow DOM to avoid style conflicts with page content.
11
+ */
12
+ export function SelectionHighlight() {
13
+ const containerRef = useRef<HTMLDivElement>(null)
14
+ const shadowRootRef = useRef<ShadowRoot | null>(null)
15
+ const overlayRef = useRef<HTMLDivElement | null>(null)
16
+ const labelRef = useRef<HTMLDivElement | null>(null)
17
+
18
+ const componentId = signals.currentComponentId.value
19
+ const selectModeEl = signals.selectModeElement.value
20
+ const isEditing = signals.isEditing.value
21
+ const isSelectMode = signals.isSelectMode.value
22
+
23
+ // Determine which element to highlight
24
+ const hasComponentSelection = !!componentId && (isEditing || isSelectMode)
25
+ const hasSelectModeSelection = isSelectMode && !!selectModeEl
26
+ const visible = hasComponentSelection || hasSelectModeSelection
27
+
28
+ // Resolve the DOM element and label
29
+ let element: HTMLElement | null = null
30
+ let label: string = 'Component'
31
+
32
+ if (hasSelectModeSelection && selectModeEl) {
33
+ element = selectModeEl.element
34
+ label = selectModeEl.label
35
+ } else if (hasComponentSelection && componentId) {
36
+ element = document.querySelector(`[data-cms-component-id="${componentId}"]`) as HTMLElement | null
37
+ const manifest = signals.manifest.value
38
+ const instance = getComponentInstance(manifest, componentId)
39
+ label = instance?.componentName ?? 'Component'
40
+ }
41
+
42
+ // Initialize Shadow DOM once
43
+ useEffect(() => {
44
+ if (containerRef.current && !shadowRootRef.current) {
45
+ shadowRootRef.current = containerRef.current.attachShadow({ mode: 'open' })
46
+
47
+ const style = document.createElement('style')
48
+ style.textContent = `
49
+ :host {
50
+ position: fixed;
51
+ top: 0;
52
+ left: 0;
53
+ pointer-events: none;
54
+ z-index: ${Z_INDEX.SELECTION};
55
+ }
56
+
57
+ .selection-overlay {
58
+ position: fixed;
59
+ border: 2px solid #DFFF40;
60
+ border-radius: 16px;
61
+ box-sizing: border-box;
62
+ background: rgba(223, 255, 64, 0.03);
63
+ box-shadow: 0 0 0 1px rgba(223, 255, 64, 0.15);
64
+ transition: opacity 150ms ease;
65
+ pointer-events: none;
66
+ }
67
+
68
+ .selection-overlay.hidden {
69
+ opacity: 0;
70
+ }
71
+
72
+ .selection-label {
73
+ position: fixed;
74
+ padding: 5px 8px 5px 12px;
75
+ border-radius: 9999px;
76
+ font-size: 11px;
77
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
78
+ font-weight: 600;
79
+ white-space: nowrap;
80
+ background: #DFFF40;
81
+ color: #1A1A1A;
82
+ display: none;
83
+ align-items: center;
84
+ gap: 6px;
85
+ pointer-events: auto;
86
+ z-index: ${Z_INDEX.MODAL};
87
+ box-shadow: 0 4px 12px rgba(0,0,0,0.2);
88
+ }
89
+
90
+ .deselect-btn {
91
+ width: 18px;
92
+ height: 18px;
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ background: rgba(0,0,0,0.1);
97
+ border: none;
98
+ border-radius: 50%;
99
+ cursor: pointer;
100
+ padding: 0;
101
+ color: #1A1A1A;
102
+ font-size: 10px;
103
+ line-height: 1;
104
+ transition: background 150ms ease;
105
+ }
106
+
107
+ .deselect-btn:hover {
108
+ background: rgba(0,0,0,0.25);
109
+ }
110
+ `
111
+
112
+ overlayRef.current = document.createElement('div')
113
+ overlayRef.current.className = 'selection-overlay hidden'
114
+
115
+ labelRef.current = document.createElement('div')
116
+ labelRef.current.className = 'selection-label'
117
+
118
+ shadowRootRef.current.appendChild(style)
119
+ shadowRootRef.current.appendChild(overlayRef.current)
120
+ shadowRootRef.current.appendChild(labelRef.current)
121
+ }
122
+ }, [])
123
+
124
+ // Handle deselection
125
+ const handleDeselect = useCallback(() => {
126
+ if (signals.selectModeElement.value) {
127
+ signals.setSelectModeElement(null)
128
+ } else {
129
+ signals.setCurrentComponentId(null)
130
+ signals.setBlockEditorOpen(false)
131
+ }
132
+ }, [])
133
+
134
+ // Track position on scroll/resize
135
+ const handlePositionChange = useCallback((rect: DOMRect | null) => {
136
+ if (!overlayRef.current || !labelRef.current) return
137
+
138
+ if (!rect) {
139
+ overlayRef.current.className = 'selection-overlay hidden'
140
+ labelRef.current.style.display = 'none'
141
+ return
142
+ }
143
+
144
+ overlayRef.current.className = 'selection-overlay'
145
+ overlayRef.current.style.left = `${rect.left - 6}px`
146
+ overlayRef.current.style.top = `${rect.top - 6}px`
147
+ overlayRef.current.style.width = `${rect.width + 12}px`
148
+ overlayRef.current.style.height = `${rect.height + 12}px`
149
+
150
+ // Position label above the element
151
+ const labelTop = Math.max(8, rect.top - 32)
152
+ const labelLeft = Math.max(8, rect.left)
153
+
154
+ // Hide label if element is out of viewport
155
+ if (rect.bottom < 0 || rect.top > window.innerHeight) {
156
+ labelRef.current.style.display = 'none'
157
+ } else {
158
+ labelRef.current.style.display = 'flex'
159
+ labelRef.current.style.top = `${labelTop}px`
160
+ labelRef.current.style.left = `${labelLeft}px`
161
+ }
162
+ }, [])
163
+
164
+ usePositionTracking(element, handlePositionChange, visible)
165
+
166
+ // Update content and initial position when selection changes
167
+ useEffect(() => {
168
+ if (!overlayRef.current || !labelRef.current) return
169
+
170
+ if (!visible || !element) {
171
+ overlayRef.current.className = 'selection-overlay hidden'
172
+ labelRef.current.style.display = 'none'
173
+ return
174
+ }
175
+
176
+ // Build label content
177
+ labelRef.current.innerHTML = ''
178
+
179
+ const nameSpan = document.createElement('span')
180
+ nameSpan.textContent = label
181
+ labelRef.current.appendChild(nameSpan)
182
+
183
+ const deselectBtn = document.createElement('button')
184
+ deselectBtn.className = 'deselect-btn'
185
+ deselectBtn.innerHTML = '✕'
186
+ deselectBtn.title = 'Deselect'
187
+ deselectBtn.onclick = (e) => {
188
+ e.stopPropagation()
189
+ handleDeselect()
190
+ }
191
+ labelRef.current.appendChild(deselectBtn)
192
+
193
+ // Set initial position
194
+ const rect = element.getBoundingClientRect()
195
+ handlePositionChange(rect)
196
+ }, [visible, element, label, handleDeselect, handlePositionChange])
197
+
198
+ return (
199
+ <div
200
+ ref={containerRef}
201
+ data-cms-ui
202
+ style={{
203
+ position: 'fixed',
204
+ top: 0,
205
+ left: 0,
206
+ width: 0,
207
+ height: 0,
208
+ pointerEvents: 'none',
209
+ zIndex: Z_INDEX.SELECTION,
210
+ }}
211
+ />
212
+ )
213
+ }
@@ -15,7 +15,37 @@ import {
15
15
  showToast,
16
16
  } from '../signals'
17
17
  import type { ChangePayload, PageSeoData, PendingSeoChange } from '../types'
18
- import { ImageField } from './fields'
18
+ import { ColorField, ComboBoxField, ImageField } from './fields'
19
+
20
+ const OG_TYPE_OPTIONS = [
21
+ { value: 'website', label: 'Website', description: 'Default type for most pages' },
22
+ { value: 'article', label: 'Article', description: 'Blog posts, news articles' },
23
+ { value: 'profile', label: 'Profile', description: 'User or author profile page' },
24
+ { value: 'video.movie', label: 'Video (Movie)', description: 'Movie or film' },
25
+ { value: 'video.episode', label: 'Video (Episode)', description: 'TV show episode' },
26
+ { value: 'video.other', label: 'Video (Other)', description: 'Other video content' },
27
+ { value: 'music.song', label: 'Music (Song)', description: 'Individual song' },
28
+ { value: 'music.album', label: 'Music (Album)', description: 'Music album' },
29
+ { value: 'book', label: 'Book', description: 'Book or publication' },
30
+ { value: 'product', label: 'Product', description: 'Product page' },
31
+ ]
32
+
33
+ const TWITTER_CARD_OPTIONS = [
34
+ { value: 'summary', label: 'Summary', description: 'Small square image with title and description' },
35
+ { value: 'summary_large_image', label: 'Summary Large Image', description: 'Large banner image above title' },
36
+ { value: 'app', label: 'App', description: 'Mobile app download card' },
37
+ { value: 'player', label: 'Player', description: 'Embedded media player card' },
38
+ ]
39
+
40
+ const ROBOTS_OPTIONS = [
41
+ { value: 'index, follow', label: 'Index, Follow', description: 'Allow indexing and link following (default)' },
42
+ { value: 'noindex, follow', label: 'No Index, Follow', description: 'Block indexing but follow links' },
43
+ { value: 'index, nofollow', label: 'Index, No Follow', description: "Allow indexing but don't follow links" },
44
+ { value: 'noindex, nofollow', label: 'No Index, No Follow', description: 'Block indexing and link following' },
45
+ { value: 'noarchive', label: 'No Archive', description: 'Prevent cached copies in search results' },
46
+ { value: 'nosnippet', label: 'No Snippet', description: "Don't show text snippets in results" },
47
+ { value: 'max-image-preview:large', label: 'Max Image Preview: Large', description: 'Allow large image previews' },
48
+ ]
19
49
 
20
50
  interface SeoFieldProps {
21
51
  label: string
@@ -63,6 +93,18 @@ function SeoField({ label, id, value, placeholder, multiline, onChange }: SeoFie
63
93
  )
64
94
  }
65
95
 
96
+ /** Helper to resolve pending value/dirty state for a SEO meta tag */
97
+ function useSeoMeta(tag: { id?: string; content: string } | undefined) {
98
+ if (!tag) return { id: undefined, original: '', current: '', dirty: false }
99
+ const pending = tag.id ? getPendingSeoChange(tag.id) : undefined
100
+ return {
101
+ id: tag.id,
102
+ original: tag.content,
103
+ current: pending?.newValue ?? tag.content ?? '',
104
+ dirty: pending?.isDirty ?? false,
105
+ }
106
+ }
107
+
66
108
  interface SeoSectionProps {
67
109
  title: string
68
110
  children: preact.ComponentChildren
@@ -130,6 +172,8 @@ export function SeoEditor() {
130
172
  seoData.description,
131
173
  seoData.keywords,
132
174
  seoData.canonical,
175
+ seoData.themeColor,
176
+ seoData.robots,
133
177
  ...(seoData.openGraph ? Object.values(seoData.openGraph) : []),
134
178
  ...(seoData.twitterCard ? Object.values(seoData.twitterCard) : []),
135
179
  ...(seoData.favicons || []),
@@ -189,6 +233,14 @@ export function SeoEditor() {
189
233
  }
190
234
  }, [findSeoElementById])
191
235
 
236
+ // Resolve pending state for specialized fields
237
+ const ogImage = useSeoMeta(seoData?.openGraph?.image)
238
+ const ogType = useSeoMeta(seoData?.openGraph?.type)
239
+ const twitterCard = useSeoMeta(seoData?.twitterCard?.card)
240
+ const twitterImage = useSeoMeta(seoData?.twitterCard?.image)
241
+ const themeColor = useSeoMeta(seoData?.themeColor)
242
+ const robots = useSeoMeta(seoData?.robots)
243
+
192
244
  if (!visible) return null
193
245
 
194
246
  const hasSeoData = seoData && (
@@ -196,6 +248,8 @@ export function SeoEditor() {
196
248
  || seoData.description
197
249
  || seoData.keywords
198
250
  || seoData.canonical
251
+ || seoData.themeColor
252
+ || seoData.robots
199
253
  || seoData.openGraph
200
254
  || seoData.twitterCard
201
255
  || (seoData.favicons && seoData.favicons.length > 0)
@@ -292,6 +346,29 @@ export function SeoEditor() {
292
346
  onChange={handleFieldChange}
293
347
  />
294
348
  )}
349
+ {seoData.robots && (
350
+ <ComboBoxField
351
+ label="Robots"
352
+ value={robots.current}
353
+ placeholder="index, follow"
354
+ options={ROBOTS_OPTIONS}
355
+ onChange={(v) => {
356
+ if (robots.id) handleFieldChange(robots.id, v, robots.original)
357
+ }}
358
+ isDirty={robots.dirty}
359
+ />
360
+ )}
361
+ {seoData.themeColor && (
362
+ <ColorField
363
+ label="Theme Color"
364
+ value={themeColor.current}
365
+ placeholder="#000000"
366
+ onChange={(v) => {
367
+ if (themeColor.id) handleFieldChange(themeColor.id, v, themeColor.original)
368
+ }}
369
+ isDirty={themeColor.dirty}
370
+ />
371
+ )}
295
372
  </SeoSection>
296
373
 
297
374
  {/* Favicons */}
@@ -355,12 +432,19 @@ export function SeoEditor() {
355
432
  />
356
433
  )}
357
434
  {seoData.openGraph.image && (
358
- <SeoField
435
+ <ImageField
359
436
  label="OG Image"
360
- id={seoData.openGraph.image.id}
361
- value={seoData.openGraph.image.content}
437
+ value={ogImage.current}
362
438
  placeholder="/images/og-image.jpg"
363
- onChange={handleFieldChange}
439
+ onChange={(v) => {
440
+ if (ogImage.id) handleFieldChange(ogImage.id, v, ogImage.original)
441
+ }}
442
+ onBrowse={() => {
443
+ openMediaLibraryWithCallback((url: string) => {
444
+ if (ogImage.id) handleFieldChange(ogImage.id, url, ogImage.original)
445
+ })
446
+ }}
447
+ isDirty={ogImage.dirty}
364
448
  />
365
449
  )}
366
450
  {seoData.openGraph.url && (
@@ -373,12 +457,15 @@ export function SeoEditor() {
373
457
  />
374
458
  )}
375
459
  {seoData.openGraph.type && (
376
- <SeoField
460
+ <ComboBoxField
377
461
  label="OG Type"
378
- id={seoData.openGraph.type.id}
379
- value={seoData.openGraph.type.content}
462
+ value={ogType.current}
380
463
  placeholder="website"
381
- onChange={handleFieldChange}
464
+ options={OG_TYPE_OPTIONS}
465
+ onChange={(v) => {
466
+ if (ogType.id) handleFieldChange(ogType.id, v, ogType.original)
467
+ }}
468
+ isDirty={ogType.dirty}
382
469
  />
383
470
  )}
384
471
  {seoData.openGraph.siteName && (
@@ -397,12 +484,15 @@ export function SeoEditor() {
397
484
  {seoData.twitterCard && Object.keys(seoData.twitterCard).length > 0 && (
398
485
  <SeoSection title="Twitter Card">
399
486
  {seoData.twitterCard.card && (
400
- <SeoField
487
+ <ComboBoxField
401
488
  label="Card Type"
402
- id={seoData.twitterCard.card.id}
403
- value={seoData.twitterCard.card.content}
489
+ value={twitterCard.current}
404
490
  placeholder="summary_large_image"
405
- onChange={handleFieldChange}
491
+ options={TWITTER_CARD_OPTIONS}
492
+ onChange={(v) => {
493
+ if (twitterCard.id) handleFieldChange(twitterCard.id, v, twitterCard.original)
494
+ }}
495
+ isDirty={twitterCard.dirty}
406
496
  />
407
497
  )}
408
498
  {seoData.twitterCard.title && (
@@ -425,12 +515,19 @@ export function SeoEditor() {
425
515
  />
426
516
  )}
427
517
  {seoData.twitterCard.image && (
428
- <SeoField
518
+ <ImageField
429
519
  label="Twitter Image"
430
- id={seoData.twitterCard.image.id}
431
- value={seoData.twitterCard.image.content}
520
+ value={twitterImage.current}
432
521
  placeholder="/images/twitter-image.jpg"
433
- onChange={handleFieldChange}
522
+ onChange={(v) => {
523
+ if (twitterImage.id) handleFieldChange(twitterImage.id, v, twitterImage.original)
524
+ }}
525
+ onBrowse={() => {
526
+ openMediaLibraryWithCallback((url: string) => {
527
+ if (twitterImage.id) handleFieldChange(twitterImage.id, url, twitterImage.original)
528
+ })
529
+ }}
530
+ isDirty={twitterImage.dirty}
434
531
  />
435
532
  )}
436
533
  {seoData.twitterCard.site && (
@@ -1,5 +1,6 @@
1
1
  import type { ComponentChildren, FunctionComponent } from 'preact'
2
- import { useState } from 'preact/hooks'
2
+ import { useRef, useState } from 'preact/hooks'
3
+ import { CMS_VERSION } from '../constants'
3
4
  import { cn } from '../lib/cn'
4
5
  import * as signals from '../signals'
5
6
  import { showConfirmDialog } from '../signals'
@@ -104,6 +105,8 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
104
105
  const isPreviewingMarkdown = signals.isMarkdownPreview.value
105
106
  const currentPageCollection = signals.currentPageCollection.value
106
107
  const [isMenuOpen, setIsMenuOpen] = useState(false)
108
+ const [showVersion, setShowVersion] = useState(false)
109
+ const versionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
107
110
 
108
111
  if (isPreviewingMarkdown) return null
109
112
 
@@ -291,33 +294,46 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
291
294
  </button>
292
295
  )
293
296
  : isSelectMode
294
- ? (
295
- <button
296
- onClick={(e) => {
297
- e.stopPropagation()
298
- callbacks.onSelectElement?.()
299
- }}
300
- class="w-10 h-10 flex items-center justify-center rounded-full text-white/60 hover:text-white hover:bg-white/10 transition-all duration-150 cursor-pointer"
301
- title="Done selecting"
302
- >
303
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
304
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
305
- </svg>
306
- </button>
307
- )
308
- : (
297
+ ? (
298
+ <button
299
+ onClick={(e) => {
300
+ e.stopPropagation()
301
+ callbacks.onSelectElement?.()
302
+ }}
303
+ class="w-10 h-10 flex items-center justify-center rounded-full text-white/60 hover:text-white hover:bg-white/10 transition-all duration-150 cursor-pointer"
304
+ title="Done selecting"
305
+ >
306
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
307
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
308
+ </svg>
309
+ </button>
310
+ )
311
+ : (
309
312
  <div class="relative">
310
313
  <button
311
314
  onClick={(e) => {
312
315
  e.stopPropagation()
313
316
  setIsMenuOpen(!isMenuOpen)
314
317
  }}
318
+ onDblClick={(e) => {
319
+ e.stopPropagation()
320
+ setIsMenuOpen(false)
321
+ setShowVersion(true)
322
+ if (versionTimeoutRef.current) clearTimeout(versionTimeoutRef.current)
323
+ versionTimeoutRef.current = setTimeout(() => setShowVersion(false), 3000)
324
+ }}
315
325
  class="w-10 h-10 rounded-full bg-cms-primary flex items-center justify-center cursor-pointer transition-all duration-150 hover:bg-cms-primary-hover"
316
326
  aria-label="Menu"
317
327
  >
318
328
  <span class="w-3 h-3 rounded-full bg-black" />
319
329
  </button>
320
330
 
331
+ {showVersion && (
332
+ <div class="absolute bottom-full right-0 mb-2 px-3.5 py-2 text-sm text-white/70 bg-cms-dark rounded-cms-lg shadow-lg border border-white/10 whitespace-nowrap animate-[fadeIn_150ms_ease-out]">
333
+ v{CMS_VERSION}
334
+ </div>
335
+ )}
336
+
321
337
  {isMenuOpen && (
322
338
  <>
323
339
  {/* Backdrop to close menu */}
@@ -1,8 +1,12 @@
1
+ import { version } from '../../package.json'
2
+
1
3
  /**
2
4
  * Constants for the CMS editor
3
5
  * Centralizes magic numbers and configuration values
4
6
  */
5
7
 
8
+ export const CMS_VERSION = version
9
+
6
10
  /**
7
11
  * Z-index layers for CMS UI elements.
8
12
  * Uses high values to ensure CMS UI appears above all page content.
@@ -10,9 +14,11 @@
10
14
  */
11
15
  export const Z_INDEX = {
12
16
  /** Highlight overlay for hovered elements */
13
- HIGHLIGHT: 2147483645,
14
- /** Overlay backdrop for modals */
15
- OVERLAY: 2147483646,
17
+ HIGHLIGHT: 2147483644,
18
+ /** Hover outline for elements/components */
19
+ OVERLAY: 2147483645,
20
+ /** Persistent selection highlight for selected component */
21
+ SELECTION: 2147483646,
16
22
  /** Modal panels (block editor, AI chat) */
17
23
  MODAL: 2147483647,
18
24
  /** Toast notifications - always on top */
@@ -332,7 +332,10 @@ export function stopEditMode(onStateChange?: () => void): void {
332
332
  signals.setEditing(false)
333
333
  saveEditingState(false)
334
334
  signals.setShowingOriginal(false)
335
- enableAllInteractiveElements()
335
+ // Only re-enable interactive elements if select mode is not active
336
+ if (!signals.isSelectMode.value) {
337
+ enableAllInteractiveElements()
338
+ }
336
339
  cleanupHighlightSystem()
337
340
  onStateChange?.()
338
341
 
@@ -53,10 +53,17 @@ export function useBlockEditorHandlers({
53
53
  const [blockEditorCursor, setBlockEditorCursor] = useState<{ x: number; y: number } | null>(null)
54
54
 
55
55
  /**
56
- * Open block editor for a component
56
+ * Open block editor for a component, or deselect if already selected
57
57
  */
58
58
  const handleComponentSelect = useCallback(
59
59
  (componentId: string, cursor: { x: number; y: number }) => {
60
+ // Toggle: clicking the same component deselects it
61
+ if (signals.currentComponentId.value === componentId) {
62
+ signals.setCurrentComponentId(null)
63
+ signals.setBlockEditorOpen(false)
64
+ setBlockEditorCursor(null)
65
+ return
66
+ }
60
67
  signals.setCurrentComponentId(componentId)
61
68
  signals.setBlockEditorOpen(true)
62
69
  setBlockEditorCursor(cursor)