@nuasite/cms 0.9.3 → 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/README.md +21 -0
- package/dist/editor.js +6517 -6264
- package/package.json +1 -1
- package/src/editor/components/fields.tsx +48 -0
- package/src/editor/components/selection-highlight.tsx +200 -0
- package/src/editor/components/seo-editor.tsx +114 -17
- package/src/editor/components/toolbar.tsx +15 -15
- package/src/editor/hooks/useBlockEditorHandlers.ts +8 -1
- package/src/editor/hooks/useElementDetection.ts +26 -3
- package/src/editor/index.tsx +25 -2
- 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,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
|
-
<
|
|
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 && (
|
|
@@ -291,21 +291,21 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
|
|
|
291
291
|
</button>
|
|
292
292
|
)
|
|
293
293
|
: isSelectMode
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
)
|
|
308
|
+
: (
|
|
309
309
|
<div class="relative">
|
|
310
310
|
<button
|
|
311
311
|
onClick={(e) => {
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
238
|
-
|
|
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
|
package/src/editor/index.tsx
CHANGED
|
@@ -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({
|
|
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,
|
package/src/seo-processor.ts
CHANGED
|
@@ -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:', '')
|