@nuasite/cms 0.9.2 → 0.10.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.2",
17
+ "version": "0.10.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,200 @@
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 component.
9
+ * Uses Shadow DOM to avoid style conflicts with page content.
10
+ */
11
+ export function SelectionHighlight() {
12
+ const containerRef = useRef<HTMLDivElement>(null)
13
+ const shadowRootRef = useRef<ShadowRoot | null>(null)
14
+ const overlayRef = useRef<HTMLDivElement | null>(null)
15
+ const labelRef = useRef<HTMLDivElement | null>(null)
16
+
17
+ const componentId = signals.currentComponentId.value
18
+ const isEditing = signals.isEditing.value
19
+ const isSelectMode = signals.isSelectMode.value
20
+ const visible = !!componentId && (isEditing || isSelectMode)
21
+
22
+ // Find the DOM element for the selected component
23
+ const element = componentId
24
+ ? (document.querySelector(`[data-cms-component-id="${componentId}"]`) as HTMLElement | null)
25
+ : null
26
+
27
+ // Get component name from manifest
28
+ const manifest = signals.manifest.value
29
+ const instance = componentId ? getComponentInstance(manifest, componentId) : null
30
+ const componentName = instance?.componentName
31
+
32
+ // Initialize Shadow DOM once
33
+ useEffect(() => {
34
+ if (containerRef.current && !shadowRootRef.current) {
35
+ shadowRootRef.current = containerRef.current.attachShadow({ mode: 'open' })
36
+
37
+ const style = document.createElement('style')
38
+ style.textContent = `
39
+ :host {
40
+ position: fixed;
41
+ top: 0;
42
+ left: 0;
43
+ pointer-events: none;
44
+ z-index: ${Z_INDEX.OVERLAY};
45
+ }
46
+
47
+ .selection-overlay {
48
+ position: fixed;
49
+ border: 2px solid #DFFF40;
50
+ border-radius: 16px;
51
+ box-sizing: border-box;
52
+ background: rgba(223, 255, 64, 0.03);
53
+ box-shadow: 0 0 0 1px rgba(223, 255, 64, 0.15);
54
+ transition: opacity 150ms ease;
55
+ pointer-events: none;
56
+ }
57
+
58
+ .selection-overlay.hidden {
59
+ opacity: 0;
60
+ }
61
+
62
+ .selection-label {
63
+ position: fixed;
64
+ padding: 5px 8px 5px 12px;
65
+ border-radius: 9999px;
66
+ font-size: 11px;
67
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
68
+ font-weight: 600;
69
+ white-space: nowrap;
70
+ background: #DFFF40;
71
+ color: #1A1A1A;
72
+ display: none;
73
+ align-items: center;
74
+ gap: 6px;
75
+ pointer-events: auto;
76
+ z-index: ${Z_INDEX.MODAL};
77
+ box-shadow: 0 4px 12px rgba(0,0,0,0.2);
78
+ }
79
+
80
+ .deselect-btn {
81
+ width: 18px;
82
+ height: 18px;
83
+ display: flex;
84
+ align-items: center;
85
+ justify-content: center;
86
+ background: rgba(0,0,0,0.1);
87
+ border: none;
88
+ border-radius: 50%;
89
+ cursor: pointer;
90
+ padding: 0;
91
+ color: #1A1A1A;
92
+ font-size: 10px;
93
+ line-height: 1;
94
+ transition: background 150ms ease;
95
+ }
96
+
97
+ .deselect-btn:hover {
98
+ background: rgba(0,0,0,0.25);
99
+ }
100
+ `
101
+
102
+ overlayRef.current = document.createElement('div')
103
+ overlayRef.current.className = 'selection-overlay hidden'
104
+
105
+ labelRef.current = document.createElement('div')
106
+ labelRef.current.className = 'selection-label'
107
+
108
+ shadowRootRef.current.appendChild(style)
109
+ shadowRootRef.current.appendChild(overlayRef.current)
110
+ shadowRootRef.current.appendChild(labelRef.current)
111
+ }
112
+ }, [])
113
+
114
+ // Handle deselection
115
+ const handleDeselect = useCallback(() => {
116
+ signals.setCurrentComponentId(null)
117
+ signals.setBlockEditorOpen(false)
118
+ }, [])
119
+
120
+ // Track position on scroll/resize
121
+ const handlePositionChange = useCallback((rect: DOMRect | null) => {
122
+ if (!overlayRef.current || !labelRef.current) return
123
+
124
+ if (!rect) {
125
+ overlayRef.current.className = 'selection-overlay hidden'
126
+ labelRef.current.style.display = 'none'
127
+ return
128
+ }
129
+
130
+ overlayRef.current.className = 'selection-overlay'
131
+ overlayRef.current.style.left = `${rect.left - 6}px`
132
+ overlayRef.current.style.top = `${rect.top - 6}px`
133
+ overlayRef.current.style.width = `${rect.width + 12}px`
134
+ overlayRef.current.style.height = `${rect.height + 12}px`
135
+
136
+ // Position label above the element
137
+ const labelTop = Math.max(8, rect.top - 32)
138
+ const labelLeft = Math.max(8, rect.left)
139
+
140
+ // Hide label if element is out of viewport
141
+ if (rect.bottom < 0 || rect.top > window.innerHeight) {
142
+ labelRef.current.style.display = 'none'
143
+ } else {
144
+ labelRef.current.style.display = 'flex'
145
+ labelRef.current.style.top = `${labelTop}px`
146
+ labelRef.current.style.left = `${labelLeft}px`
147
+ }
148
+ }, [])
149
+
150
+ usePositionTracking(element, handlePositionChange, visible)
151
+
152
+ // Update content and initial position when selection changes
153
+ useEffect(() => {
154
+ if (!overlayRef.current || !labelRef.current) return
155
+
156
+ if (!visible || !element) {
157
+ overlayRef.current.className = 'selection-overlay hidden'
158
+ labelRef.current.style.display = 'none'
159
+ return
160
+ }
161
+
162
+ // Build label content
163
+ const name = componentName ?? 'Component'
164
+ labelRef.current.innerHTML = ''
165
+
166
+ const nameSpan = document.createElement('span')
167
+ nameSpan.textContent = name
168
+ labelRef.current.appendChild(nameSpan)
169
+
170
+ const deselectBtn = document.createElement('button')
171
+ deselectBtn.className = 'deselect-btn'
172
+ deselectBtn.innerHTML = '✕'
173
+ deselectBtn.title = 'Deselect'
174
+ deselectBtn.onclick = (e) => {
175
+ e.stopPropagation()
176
+ handleDeselect()
177
+ }
178
+ labelRef.current.appendChild(deselectBtn)
179
+
180
+ // Set initial position
181
+ const rect = element.getBoundingClientRect()
182
+ handlePositionChange(rect)
183
+ }, [visible, element, componentName, handleDeselect, handlePositionChange])
184
+
185
+ return (
186
+ <div
187
+ ref={containerRef}
188
+ data-cms-ui
189
+ style={{
190
+ position: 'fixed',
191
+ top: 0,
192
+ left: 0,
193
+ width: 0,
194
+ height: 0,
195
+ pointerEvents: 'none',
196
+ zIndex: Z_INDEX.OVERLAY,
197
+ }}
198
+ />
199
+ )
200
+ }
@@ -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 && (
@@ -290,6 +290,21 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
290
290
  </svg>
291
291
  </button>
292
292
  )
293
+ : 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
+ )
293
308
  : (
294
309
  <div class="relative">
295
310
  <button
@@ -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)
@@ -182,6 +182,7 @@ export function useElementDetection(): OutlineState {
182
182
 
183
183
  export interface ComponentClickHandlerOptions {
184
184
  onComponentSelect: (componentId: string, cursor: { x: number; y: number }) => void
185
+ onComponentDeselect?: () => void
185
186
  }
186
187
 
187
188
  /**
@@ -190,6 +191,7 @@ export interface ComponentClickHandlerOptions {
190
191
  */
191
192
  export function useComponentClickHandler({
192
193
  onComponentSelect,
194
+ onComponentDeselect,
193
195
  }: ComponentClickHandlerOptions): void {
194
196
  useEffect(() => {
195
197
  const handleClick = (ev: MouseEvent) => {
@@ -207,7 +209,10 @@ export function useComponentClickHandler({
207
209
  // Check for text element first
208
210
  const textEl = getCmsElementAtPosition(ev.clientX, ev.clientY, entries)
209
211
  if (textEl && !textEl.hasAttribute(CSS.COMPONENT_ID_ATTRIBUTE)) {
210
- // Let the text element handle this click
212
+ // Clicking a text element deselects any selected component
213
+ if (signals.currentComponentId.value && onComponentDeselect) {
214
+ onComponentDeselect()
215
+ }
211
216
  return
212
217
  }
213
218
 
@@ -229,13 +234,31 @@ export function useComponentClickHandler({
229
234
  ev.stopPropagation()
230
235
  onComponentSelect(componentId, { x: ev.clientX, y: ev.clientY })
231
236
  }
237
+ return
232
238
  }
233
239
  }
240
+
241
+ // Clicking on empty space deselects any selected component
242
+ if (signals.currentComponentId.value && onComponentDeselect) {
243
+ onComponentDeselect()
244
+ }
245
+ }
246
+
247
+ // Escape key deselects the selected component
248
+ const handleKeyDown = (ev: KeyboardEvent) => {
249
+ if (ev.key === 'Escape' && signals.currentComponentId.value && onComponentDeselect) {
250
+ ev.preventDefault()
251
+ onComponentDeselect()
252
+ }
234
253
  }
235
254
 
236
255
  document.addEventListener('click', handleClick, true)
237
- return () => document.removeEventListener('click', handleClick, true)
238
- }, [onComponentSelect])
256
+ document.addEventListener('keydown', handleKeyDown)
257
+ return () => {
258
+ document.removeEventListener('click', handleClick, true)
259
+ document.removeEventListener('keydown', handleKeyDown)
260
+ }
261
+ }, [onComponentSelect, onComponentDeselect])
239
262
  }
240
263
 
241
264
  // Re-export utilities for backwards compatibility
@@ -1,6 +1,6 @@
1
1
  import { render } from 'preact'
2
2
  import { useCallback, useEffect, useRef } from 'preact/hooks'
3
- import type { CmsElementDeselectedMessage, CmsElementSelectedMessage } from '../types'
3
+ import type { CmsElementDeselectedMessage, CmsElementSelectedMessage, CmsInboundMessage } from '../types'
4
4
  import { fetchManifest } from './api'
5
5
  import { AttributeEditor } from './components/attribute-editor'
6
6
  import { BgImageOverlay } from './components/bg-image-overlay'
@@ -16,6 +16,7 @@ import { MarkdownEditorOverlay } from './components/markdown-editor-overlay'
16
16
  import { MediaLibrary } from './components/media-library'
17
17
  import { Outline } from './components/outline'
18
18
  import { RedirectCountdown } from './components/redirect-countdown'
19
+ import { SelectionHighlight } from './components/selection-highlight'
19
20
  import { SeoEditor } from './components/seo-editor'
20
21
  import { TextStyleToolbar } from './components/text-style-toolbar'
21
22
  import { ToastContainer } from './components/toast/toast-container'
@@ -227,7 +228,25 @@ const CmsUI = () => {
227
228
  showToast: signals.showToast,
228
229
  })
229
230
 
230
- useComponentClickHandler({ onComponentSelect: handleComponentSelect })
231
+ useComponentClickHandler({
232
+ onComponentSelect: handleComponentSelect,
233
+ onComponentDeselect: handleBlockEditorClose,
234
+ })
235
+
236
+ // Listen for inbound messages from parent window (when inside an iframe)
237
+ useEffect(() => {
238
+ const handleMessage = (ev: MessageEvent) => {
239
+ const msg = ev.data as CmsInboundMessage
240
+ if (!msg || typeof msg.type !== 'string') return
241
+
242
+ if (msg.type === 'cms-deselect-element') {
243
+ handleBlockEditorClose()
244
+ }
245
+ }
246
+
247
+ window.addEventListener('message', handleMessage)
248
+ return () => window.removeEventListener('message', handleMessage)
249
+ }, [handleBlockEditorClose])
231
250
 
232
251
  // Editor control handlers
233
252
  const handleEditToggle = useCallback(async () => {
@@ -431,6 +450,10 @@ const CmsUI = () => {
431
450
  <EditableHighlights visible={showEditableHighlights && isEditing} />
432
451
  </ErrorBoundary>
433
452
 
453
+ <ErrorBoundary componentName="Selection Highlight">
454
+ <SelectionHighlight />
455
+ </ErrorBoundary>
456
+
434
457
  <ErrorBoundary componentName="Outline">
435
458
  <Outline
436
459
  visible={outlineState.visible}
package/src/index.ts CHANGED
@@ -287,9 +287,11 @@ export type {
287
287
  AvailableColors,
288
288
  AvailableTextStyles,
289
289
  CanonicalUrl,
290
+ CmsDeselectElementMessage,
290
291
  CmsEditorState,
291
292
  CmsElementDeselectedMessage,
292
293
  CmsElementSelectedMessage,
294
+ CmsInboundMessage,
293
295
  CmsManifest,
294
296
  CmsMarkerOptions,
295
297
  CmsPageNavigatedMessage,
@@ -215,6 +215,18 @@ function categorizeMetaTags(metaTags: SeoMetaTag[], seo: PageSeoData): void {
215
215
  continue
216
216
  }
217
217
 
218
+ // Theme color
219
+ if (name === 'theme-color') {
220
+ seo.themeColor = meta
221
+ continue
222
+ }
223
+
224
+ // Robots
225
+ if (name === 'robots') {
226
+ seo.robots = meta
227
+ continue
228
+ }
229
+
218
230
  // Open Graph tags
219
231
  if (property?.startsWith('og:')) {
220
232
  const ogKey = property.replace('og:', '')
package/src/types.ts CHANGED
@@ -420,6 +420,10 @@ export interface PageSeoData {
420
420
  openGraph?: OpenGraphData
421
421
  /** Twitter Card metadata */
422
422
  twitterCard?: TwitterCardData
423
+ /** Browser theme color (meta name="theme-color") */
424
+ themeColor?: SeoMetaTag
425
+ /** Robots directives (meta name="robots") */
426
+ robots?: SeoMetaTag
423
427
  /** JSON-LD structured data blocks */
424
428
  jsonLd?: JsonLdEntry[]
425
429
  }
@@ -568,3 +572,15 @@ export type CmsPostMessage =
568
572
  | CmsReadyMessage
569
573
  | CmsStateChangedMessage
570
574
  | CmsPageNavigatedMessage
575
+
576
+ // ============================================================================
577
+ // Inbound messages (parent → editor iframe)
578
+ // ============================================================================
579
+
580
+ /** Message sent from parent to deselect the currently selected element/component */
581
+ export interface CmsDeselectElementMessage {
582
+ type: 'cms-deselect-element'
583
+ }
584
+
585
+ /** All possible CMS postMessage types sent from the parent to the editor iframe */
586
+ export type CmsInboundMessage = CmsDeselectElementMessage