@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/dist/editor.js +6105 -5980
- package/package.json +1 -1
- package/src/editor/components/selection-highlight.tsx +31 -18
- package/src/editor/components/toolbar.tsx +17 -1
- package/src/editor/constants.ts +9 -3
- package/src/editor/editor.ts +4 -1
- package/src/editor/hooks/useElementDetection.ts +201 -14
- package/src/editor/index.tsx +20 -6
- package/src/editor/signals.ts +14 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
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.
|
|
117
|
-
|
|
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 =
|
|
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,
|
|
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.
|
|
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 */}
|
package/src/editor/constants.ts
CHANGED
|
@@ -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:
|
|
14
|
-
/**
|
|
15
|
-
OVERLAY:
|
|
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 */
|
package/src/editor/editor.ts
CHANGED
|
@@ -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
|
-
|
|
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 ||
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
429
|
+
// Escape key deselects
|
|
248
430
|
const handleKeyDown = (ev: KeyboardEvent) => {
|
|
249
|
-
if (ev.key === 'Escape'
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
package/src/editor/index.tsx
CHANGED
|
@@ -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
|
-
|
|
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}
|
package/src/editor/signals.ts
CHANGED
|
@@ -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>>(
|