@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/README.md +21 -0
- package/dist/editor.js +7104 -6726
- package/package.json +1 -1
- package/src/editor/components/fields.tsx +48 -0
- package/src/editor/components/selection-highlight.tsx +213 -0
- package/src/editor/components/seo-editor.tsx +114 -17
- package/src/editor/components/toolbar.tsx +32 -16
- package/src/editor/constants.ts +9 -3
- package/src/editor/editor.ts +4 -1
- package/src/editor/hooks/useBlockEditorHandlers.ts +8 -1
- package/src/editor/hooks/useElementDetection.ts +222 -12
- package/src/editor/index.tsx +41 -4
- package/src/editor/signals.ts +14 -0
- package/src/index.ts +2 -0
- package/src/seo-processor.ts +12 -0
- package/src/types.ts +16 -0
package/package.json
CHANGED
|
@@ -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
|
-
<
|
|
435
|
+
<ImageField
|
|
359
436
|
label="OG Image"
|
|
360
|
-
|
|
361
|
-
value={seoData.openGraph.image.content}
|
|
437
|
+
value={ogImage.current}
|
|
362
438
|
placeholder="/images/og-image.jpg"
|
|
363
|
-
onChange={
|
|
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
|
-
<
|
|
460
|
+
<ComboBoxField
|
|
377
461
|
label="OG Type"
|
|
378
|
-
|
|
379
|
-
value={seoData.openGraph.type.content}
|
|
462
|
+
value={ogType.current}
|
|
380
463
|
placeholder="website"
|
|
381
|
-
|
|
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
|
-
<
|
|
487
|
+
<ComboBoxField
|
|
401
488
|
label="Card Type"
|
|
402
|
-
|
|
403
|
-
value={seoData.twitterCard.card.content}
|
|
489
|
+
value={twitterCard.current}
|
|
404
490
|
placeholder="summary_large_image"
|
|
405
|
-
|
|
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
|
-
<
|
|
518
|
+
<ImageField
|
|
429
519
|
label="Twitter Image"
|
|
430
|
-
|
|
431
|
-
value={seoData.twitterCard.image.content}
|
|
520
|
+
value={twitterImage.current}
|
|
432
521
|
placeholder="/images/twitter-image.jpg"
|
|
433
|
-
onChange={
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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 */}
|
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
|
|
|
@@ -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)
|