@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.
@@ -3,6 +3,7 @@ import { CSS, LAYOUT, TIMING } from '../constants'
3
3
  import { getCmsElementAtPosition, getComponentAtPosition, isNearElementEdge } from '../dom'
4
4
  import { getComponentInstance } from '../manifest'
5
5
  import * as signals from '../signals'
6
+ import type { SelectedElement } from '../signals'
6
7
  import { isEventOnCmsUI, usePositionTracking } from './utils'
7
8
 
8
9
  export interface OutlineState {
@@ -26,6 +27,114 @@ const INITIAL_STATE: OutlineState = {
26
27
  cmsId: null,
27
28
  }
28
29
 
30
+ /**
31
+ * Build a component-style outline state for a given component element.
32
+ */
33
+ function buildComponentOutline(componentEl: HTMLElement, manifest: any): OutlineState {
34
+ const componentId = componentEl.getAttribute(CSS.COMPONENT_ID_ATTRIBUTE)
35
+ const instance = componentId ? getComponentInstance(manifest, componentId) : null
36
+ return {
37
+ visible: true,
38
+ rect: componentEl.getBoundingClientRect(),
39
+ isComponent: true,
40
+ componentName: instance?.componentName,
41
+ tagName: componentEl.tagName.toLowerCase(),
42
+ element: componentEl,
43
+ cmsId: null,
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Check if an element is currently selected (either as component or select-mode element).
49
+ */
50
+ function isElementSelected(el: HTMLElement): boolean {
51
+ // Check component selection
52
+ const componentId = el.getAttribute(CSS.COMPONENT_ID_ATTRIBUTE)
53
+ if (componentId && componentId === signals.currentComponentId.value) return true
54
+
55
+ // Check select-mode selection
56
+ const selectEl = signals.selectModeElement.value
57
+ if (selectEl && selectEl.element === el) return true
58
+
59
+ return false
60
+ }
61
+
62
+ /**
63
+ * Resolve a CMS element to a SelectedElement descriptor for select mode.
64
+ */
65
+ function resolveSelectedElement(el: HTMLElement, manifest: any): SelectedElement | null {
66
+ // Component element
67
+ const componentId = el.getAttribute(CSS.COMPONENT_ID_ATTRIBUTE)
68
+ if (componentId) {
69
+ const instance = getComponentInstance(manifest, componentId)
70
+ return {
71
+ element: el,
72
+ id: componentId,
73
+ label: instance?.componentName ?? el.tagName.toLowerCase(),
74
+ type: 'component',
75
+ }
76
+ }
77
+
78
+ // Image element
79
+ if (el.hasAttribute('data-cms-img')) {
80
+ const cmsId = el.getAttribute(CSS.ID_ATTRIBUTE) ?? el.getAttribute('data-cms-img') ?? ''
81
+ return {
82
+ element: el,
83
+ id: cmsId,
84
+ label: el.getAttribute('alt') || 'Image',
85
+ type: 'image',
86
+ }
87
+ }
88
+
89
+ // Background image element
90
+ if (el.hasAttribute('data-cms-bg-img')) {
91
+ const cmsId = el.getAttribute(CSS.ID_ATTRIBUTE) ?? el.getAttribute('data-cms-bg-img') ?? ''
92
+ return {
93
+ element: el,
94
+ id: cmsId,
95
+ label: 'Background Image',
96
+ type: 'image',
97
+ }
98
+ }
99
+
100
+ // Text/CMS element
101
+ const cmsId = el.getAttribute(CSS.ID_ATTRIBUTE)
102
+ if (cmsId) {
103
+ const tagName = el.tagName.toLowerCase()
104
+ return {
105
+ element: el,
106
+ id: cmsId,
107
+ label: tagName,
108
+ type: 'text',
109
+ }
110
+ }
111
+
112
+ return null
113
+ }
114
+
115
+ /**
116
+ * Find any CMS element at position (text, image, bg-image, or component).
117
+ * Returns the deepest/most specific CMS element at the given point.
118
+ */
119
+ function getAnyCmsElementAtPosition(x: number, y: number): HTMLElement | null {
120
+ const elementsAtPoint = document.elementsFromPoint(x, y)
121
+ for (const el of elementsAtPoint) {
122
+ if (!(el instanceof HTMLElement)) continue
123
+ // Skip CMS UI elements
124
+ if (el.hasAttribute(CSS.UI_ATTRIBUTE) || el.closest(`[${CSS.UI_ATTRIBUTE}]`)) continue
125
+ // Any element with a CMS attribute
126
+ if (
127
+ el.hasAttribute(CSS.ID_ATTRIBUTE)
128
+ || el.hasAttribute(CSS.COMPONENT_ID_ATTRIBUTE)
129
+ || el.hasAttribute('data-cms-img')
130
+ || el.hasAttribute('data-cms-bg-img')
131
+ ) {
132
+ return el
133
+ }
134
+ }
135
+ return null
136
+ }
137
+
29
138
  /**
30
139
  * Hook for detecting and tracking hovered CMS elements.
31
140
  * Uses signals directly for state management.
@@ -54,6 +163,22 @@ export function useElementDetection(): OutlineState {
54
163
  outlineState.visible,
55
164
  )
56
165
 
166
+ // Hide hover outline immediately when a component is selected
167
+ const currentComponentId = signals.currentComponentId.value
168
+ useEffect(() => {
169
+ if (currentComponentId) {
170
+ setOutlineState(INITIAL_STATE)
171
+ }
172
+ }, [currentComponentId])
173
+
174
+ // Hide hover outline immediately when a select-mode element is selected
175
+ const selectModeElement = signals.selectModeElement.value
176
+ useEffect(() => {
177
+ if (selectModeElement) {
178
+ setOutlineState(INITIAL_STATE)
179
+ }
180
+ }, [selectModeElement])
181
+
57
182
  // Setup hover highlight for both elements and components
58
183
  useEffect(() => {
59
184
  const handleMouseMove = (ev: MouseEvent) => {
@@ -89,10 +214,48 @@ export function useElementDetection(): OutlineState {
89
214
  const manifest = signals.manifest.value
90
215
  const entries = manifest.entries
91
216
 
217
+ // ── Select mode: show component-style outline for any CMS element ──
218
+ if (selectMode) {
219
+ const cmsEl = getAnyCmsElementAtPosition(ev.clientX, ev.clientY)
220
+ if (cmsEl) {
221
+ if (isElementSelected(cmsEl)) {
222
+ setOutlineState(INITIAL_STATE)
223
+ return
224
+ }
225
+ if (hideTimeoutRef.current) {
226
+ clearTimeout(hideTimeoutRef.current)
227
+ hideTimeoutRef.current = null
228
+ }
229
+ const resolved = resolveSelectedElement(cmsEl, manifest)
230
+ const rect = cmsEl.getBoundingClientRect()
231
+ setOutlineState({
232
+ visible: true,
233
+ rect,
234
+ isComponent: true,
235
+ componentName: resolved?.label,
236
+ tagName: cmsEl.tagName.toLowerCase(),
237
+ element: cmsEl,
238
+ cmsId: null,
239
+ })
240
+ return
241
+ }
242
+ setOutlineState(INITIAL_STATE)
243
+ return
244
+ }
245
+
246
+ // ── Edit mode: standard detection ──
247
+
92
248
  // Use the improved elementsFromPoint-based detection
93
249
  const cmsEl = getCmsElementAtPosition(ev.clientX, ev.clientY, entries)
94
250
 
95
251
  if (cmsEl && !cmsEl.hasAttribute(CSS.COMPONENT_ID_ATTRIBUTE)) {
252
+ // Hide hover outline if this element is inside the selected component
253
+ const selectedId = signals.currentComponentId.value
254
+ if (selectedId && cmsEl.closest(`[${CSS.COMPONENT_ID_ATTRIBUTE}="${selectedId}"]`)) {
255
+ setOutlineState(INITIAL_STATE)
256
+ return
257
+ }
258
+
96
259
  // Found a text-editable element - cancel any pending hide
97
260
  if (hideTimeoutRef.current) {
98
261
  clearTimeout(hideTimeoutRef.current)
@@ -115,21 +278,21 @@ export function useElementDetection(): OutlineState {
115
278
  // Check for component at position
116
279
  const componentEl = getComponentAtPosition(ev.clientX, ev.clientY)
117
280
  if (componentEl) {
281
+ // Hide hover outline if this component is already selected
282
+ const componentId = componentEl.getAttribute(CSS.COMPONENT_ID_ATTRIBUTE)
283
+ if (componentId && componentId === signals.currentComponentId.value) {
284
+ setOutlineState(INITIAL_STATE)
285
+ return
286
+ }
287
+
118
288
  const rect = componentEl.getBoundingClientRect()
119
- const nearEdge = isNearElementEdge(
120
- ev.clientX,
121
- ev.clientY,
122
- rect,
123
- LAYOUT.COMPONENT_EDGE_THRESHOLD,
124
- )
125
289
 
126
- if (ev.altKey || nearEdge) {
290
+ if (ev.altKey || isNearElementEdge(ev.clientX, ev.clientY, rect, LAYOUT.COMPONENT_EDGE_THRESHOLD)) {
127
291
  // Cancel any pending hide
128
292
  if (hideTimeoutRef.current) {
129
293
  clearTimeout(hideTimeoutRef.current)
130
294
  hideTimeoutRef.current = null
131
295
  }
132
- const componentId = componentEl.getAttribute(CSS.COMPONENT_ID_ATTRIBUTE)
133
296
  const instance = componentId ? getComponentInstance(manifest, componentId) : null
134
297
 
135
298
  setOutlineState({
@@ -182,6 +345,7 @@ export function useElementDetection(): OutlineState {
182
345
 
183
346
  export interface ComponentClickHandlerOptions {
184
347
  onComponentSelect: (componentId: string, cursor: { x: number; y: number }) => void
348
+ onComponentDeselect?: () => void
185
349
  }
186
350
 
187
351
  /**
@@ -190,6 +354,7 @@ export interface ComponentClickHandlerOptions {
190
354
  */
191
355
  export function useComponentClickHandler({
192
356
  onComponentSelect,
357
+ onComponentDeselect,
193
358
  }: ComponentClickHandlerOptions): void {
194
359
  useEffect(() => {
195
360
  const handleClick = (ev: MouseEvent) => {
@@ -203,17 +368,39 @@ export function useComponentClickHandler({
203
368
  const manifest = signals.manifest.value
204
369
  const entries = manifest.entries
205
370
 
206
- // Normal editing mode behavior
371
+ // ── Select mode: select any CMS element ──
372
+ if (selectMode) {
373
+ const cmsEl = getAnyCmsElementAtPosition(ev.clientX, ev.clientY)
374
+ if (cmsEl) {
375
+ const resolved = resolveSelectedElement(cmsEl, manifest)
376
+ if (resolved) {
377
+ ev.preventDefault()
378
+ ev.stopPropagation()
379
+ signals.setSelectModeElement(resolved)
380
+ return
381
+ }
382
+ }
383
+ // Clicking empty space deselects
384
+ signals.setSelectModeElement(null)
385
+ return
386
+ }
387
+
388
+ // ── Edit mode: standard behavior ──
389
+
207
390
  // Check for text element first
208
391
  const textEl = getCmsElementAtPosition(ev.clientX, ev.clientY, entries)
209
392
  if (textEl && !textEl.hasAttribute(CSS.COMPONENT_ID_ATTRIBUTE)) {
210
- // Let the text element handle this click
393
+ // In edit mode, clicking a text element deselects any selected component
394
+ if (signals.currentComponentId.value && onComponentDeselect) {
395
+ onComponentDeselect()
396
+ }
211
397
  return
212
398
  }
213
399
 
214
400
  // Check for component click
215
401
  const componentEl = getComponentAtPosition(ev.clientX, ev.clientY)
216
402
  if (componentEl) {
403
+ // In edit mode, require edge proximity or Alt key
217
404
  const rect = componentEl.getBoundingClientRect()
218
405
  const nearEdge = isNearElementEdge(
219
406
  ev.clientX,
@@ -229,13 +416,36 @@ export function useComponentClickHandler({
229
416
  ev.stopPropagation()
230
417
  onComponentSelect(componentId, { x: ev.clientX, y: ev.clientY })
231
418
  }
419
+ return
420
+ }
421
+ }
422
+
423
+ // Clicking on empty space deselects any selected component
424
+ if (signals.currentComponentId.value && onComponentDeselect) {
425
+ onComponentDeselect()
426
+ }
427
+ }
428
+
429
+ // Escape key deselects
430
+ const handleKeyDown = (ev: KeyboardEvent) => {
431
+ if (ev.key === 'Escape') {
432
+ if (signals.selectModeElement.value) {
433
+ ev.preventDefault()
434
+ signals.setSelectModeElement(null)
435
+ } else if (signals.currentComponentId.value && onComponentDeselect) {
436
+ ev.preventDefault()
437
+ onComponentDeselect()
232
438
  }
233
439
  }
234
440
  }
235
441
 
236
442
  document.addEventListener('click', handleClick, true)
237
- return () => document.removeEventListener('click', handleClick, true)
238
- }, [onComponentSelect])
443
+ document.addEventListener('keydown', handleKeyDown)
444
+ return () => {
445
+ document.removeEventListener('click', handleClick, true)
446
+ document.removeEventListener('keydown', handleKeyDown)
447
+ }
448
+ }, [onComponentSelect, onComponentDeselect])
239
449
  }
240
450
 
241
451
  // 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,12 +16,13 @@ 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'
22
23
  import { Toolbar } from './components/toolbar'
23
24
  import { getConfig } from './config'
24
- import { logDebug } from './dom'
25
+ import { disableAllInteractiveElements, enableAllInteractiveElements, logDebug } from './dom'
25
26
  import {
26
27
  discardAllChanges,
27
28
  dismissDeploymentStatus,
@@ -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 () => {
@@ -295,7 +314,21 @@ const CmsUI = () => {
295
314
  }, [])
296
315
 
297
316
  const handleSelectElementToggle = useCallback(() => {
298
- signals.isSelectMode.value = !signals.isSelectMode.value
317
+ const entering = !signals.isSelectMode.value
318
+ signals.isSelectMode.value = entering
319
+ // Clear select-mode selection when leaving
320
+ if (!entering) {
321
+ signals.setSelectModeElement(null)
322
+ }
323
+ // Disable/enable links and interactive elements in select mode
324
+ // (skip if already in edit mode, which handles its own disabling)
325
+ if (!signals.isEditing.value) {
326
+ if (entering) {
327
+ disableAllInteractiveElements()
328
+ } else {
329
+ enableAllInteractiveElements()
330
+ }
331
+ }
299
332
  }, [])
300
333
 
301
334
  // Color toolbar handlers
@@ -447,6 +480,10 @@ const CmsUI = () => {
447
480
  />
448
481
  </ErrorBoundary>
449
482
 
483
+ <ErrorBoundary componentName="Selection Highlight">
484
+ <SelectionHighlight />
485
+ </ErrorBoundary>
486
+
450
487
  <ErrorBoundary componentName="ImageOverlay">
451
488
  <ImageOverlay
452
489
  visible={imageHoverState.visible && isEditing}
@@ -219,6 +219,20 @@ export const showingOriginal = signal(false)
219
219
  export const currentEditingId = signal<string | null>(null)
220
220
  export const currentComponentId = signal<string | null>(null)
221
221
 
222
+ /** Selected element in select mode (any CMS element — text, image, or component) */
223
+ export interface SelectedElement {
224
+ element: HTMLElement
225
+ /** data-cms-id, data-cms-component-id, or data-cms-img value */
226
+ id: string
227
+ label: string
228
+ type: 'text' | 'image' | 'component'
229
+ }
230
+ export const selectModeElement = signal<SelectedElement | null>(null)
231
+
232
+ export function setSelectModeElement(el: SelectedElement | null): void {
233
+ selectModeElement.value = el
234
+ }
235
+
222
236
  // Complex state - use signals wrapping the full object for atomicity
223
237
  export const pendingChanges = signal<Map<string, PendingChange>>(new Map())
224
238
  export const pendingComponentChanges = signal<Map<string, ComponentInstance>>(
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