@nuasite/cms 0.10.0 → 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.10.0",
17
+ "version": "0.11.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -5,7 +5,8 @@ import { getComponentInstance } from '../manifest'
5
5
  import * as signals from '../signals'
6
6
 
7
7
  /**
8
- * Renders a persistent highlight around the currently selected component.
8
+ * Renders a persistent highlight around the currently selected element.
9
+ * Supports both component selection (edit mode) and any CMS element selection (select mode).
9
10
  * Uses Shadow DOM to avoid style conflicts with page content.
10
11
  */
11
12
  export function SelectionHighlight() {
@@ -15,19 +16,28 @@ export function SelectionHighlight() {
15
16
  const labelRef = useRef<HTMLDivElement | null>(null)
16
17
 
17
18
  const componentId = signals.currentComponentId.value
19
+ const selectModeEl = signals.selectModeElement.value
18
20
  const isEditing = signals.isEditing.value
19
21
  const isSelectMode = signals.isSelectMode.value
20
- const visible = !!componentId && (isEditing || isSelectMode)
21
22
 
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
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
+ }
31
41
 
32
42
  // Initialize Shadow DOM once
33
43
  useEffect(() => {
@@ -41,7 +51,7 @@ export function SelectionHighlight() {
41
51
  top: 0;
42
52
  left: 0;
43
53
  pointer-events: none;
44
- z-index: ${Z_INDEX.OVERLAY};
54
+ z-index: ${Z_INDEX.SELECTION};
45
55
  }
46
56
 
47
57
  .selection-overlay {
@@ -113,8 +123,12 @@ export function SelectionHighlight() {
113
123
 
114
124
  // Handle deselection
115
125
  const handleDeselect = useCallback(() => {
116
- signals.setCurrentComponentId(null)
117
- signals.setBlockEditorOpen(false)
126
+ if (signals.selectModeElement.value) {
127
+ signals.setSelectModeElement(null)
128
+ } else {
129
+ signals.setCurrentComponentId(null)
130
+ signals.setBlockEditorOpen(false)
131
+ }
118
132
  }, [])
119
133
 
120
134
  // Track position on scroll/resize
@@ -160,11 +174,10 @@ export function SelectionHighlight() {
160
174
  }
161
175
 
162
176
  // Build label content
163
- const name = componentName ?? 'Component'
164
177
  labelRef.current.innerHTML = ''
165
178
 
166
179
  const nameSpan = document.createElement('span')
167
- nameSpan.textContent = name
180
+ nameSpan.textContent = label
168
181
  labelRef.current.appendChild(nameSpan)
169
182
 
170
183
  const deselectBtn = document.createElement('button')
@@ -180,7 +193,7 @@ export function SelectionHighlight() {
180
193
  // Set initial position
181
194
  const rect = element.getBoundingClientRect()
182
195
  handlePositionChange(rect)
183
- }, [visible, element, componentName, handleDeselect, handlePositionChange])
196
+ }, [visible, element, label, handleDeselect, handlePositionChange])
184
197
 
185
198
  return (
186
199
  <div
@@ -193,7 +206,7 @@ export function SelectionHighlight() {
193
206
  width: 0,
194
207
  height: 0,
195
208
  pointerEvents: 'none',
196
- zIndex: Z_INDEX.OVERLAY,
209
+ zIndex: Z_INDEX.SELECTION,
197
210
  }}
198
211
  />
199
212
  )
@@ -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
 
@@ -312,12 +315,25 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
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
 
@@ -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({
@@ -205,11 +368,29 @@ export function useComponentClickHandler({
205
368
  const manifest = signals.manifest.value
206
369
  const entries = manifest.entries
207
370
 
208
- // 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
+
209
390
  // Check for text element first
210
391
  const textEl = getCmsElementAtPosition(ev.clientX, ev.clientY, entries)
211
392
  if (textEl && !textEl.hasAttribute(CSS.COMPONENT_ID_ATTRIBUTE)) {
212
- // Clicking a text element deselects any selected component
393
+ // In edit mode, clicking a text element deselects any selected component
213
394
  if (signals.currentComponentId.value && onComponentDeselect) {
214
395
  onComponentDeselect()
215
396
  }
@@ -219,6 +400,7 @@ export function useComponentClickHandler({
219
400
  // Check for component click
220
401
  const componentEl = getComponentAtPosition(ev.clientX, ev.clientY)
221
402
  if (componentEl) {
403
+ // In edit mode, require edge proximity or Alt key
222
404
  const rect = componentEl.getBoundingClientRect()
223
405
  const nearEdge = isNearElementEdge(
224
406
  ev.clientX,
@@ -244,11 +426,16 @@ export function useComponentClickHandler({
244
426
  }
245
427
  }
246
428
 
247
- // Escape key deselects the selected component
429
+ // Escape key deselects
248
430
  const handleKeyDown = (ev: KeyboardEvent) => {
249
- if (ev.key === 'Escape' && signals.currentComponentId.value && onComponentDeselect) {
250
- ev.preventDefault()
251
- onComponentDeselect()
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()
438
+ }
252
439
  }
253
440
  }
254
441
 
@@ -22,7 +22,7 @@ import { TextStyleToolbar } from './components/text-style-toolbar'
22
22
  import { ToastContainer } from './components/toast/toast-container'
23
23
  import { Toolbar } from './components/toolbar'
24
24
  import { getConfig } from './config'
25
- import { logDebug } from './dom'
25
+ import { disableAllInteractiveElements, enableAllInteractiveElements, logDebug } from './dom'
26
26
  import {
27
27
  discardAllChanges,
28
28
  dismissDeploymentStatus,
@@ -314,7 +314,21 @@ const CmsUI = () => {
314
314
  }, [])
315
315
 
316
316
  const handleSelectElementToggle = useCallback(() => {
317
- 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
+ }
318
332
  }, [])
319
333
 
320
334
  // Color toolbar handlers
@@ -450,10 +464,6 @@ const CmsUI = () => {
450
464
  <EditableHighlights visible={showEditableHighlights && isEditing} />
451
465
  </ErrorBoundary>
452
466
 
453
- <ErrorBoundary componentName="Selection Highlight">
454
- <SelectionHighlight />
455
- </ErrorBoundary>
456
-
457
467
  <ErrorBoundary componentName="Outline">
458
468
  <Outline
459
469
  visible={outlineState.visible}
@@ -470,6 +480,10 @@ const CmsUI = () => {
470
480
  />
471
481
  </ErrorBoundary>
472
482
 
483
+ <ErrorBoundary componentName="Selection Highlight">
484
+ <SelectionHighlight />
485
+ </ErrorBoundary>
486
+
473
487
  <ErrorBoundary componentName="ImageOverlay">
474
488
  <ImageOverlay
475
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>>(