@nuasite/cms 0.2.2 → 0.3.1
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 +81 -73
- package/dist/src/build-processor.d.ts.map +1 -1
- package/dist/src/component-registry.d.ts +6 -2
- package/dist/src/component-registry.d.ts.map +1 -1
- package/dist/src/dev-middleware.d.ts.map +1 -1
- package/dist/src/editor/api.d.ts +14 -0
- package/dist/src/editor/api.d.ts.map +1 -1
- package/dist/src/editor/components/ai-chat.d.ts.map +1 -1
- package/dist/src/editor/components/block-editor.d.ts.map +1 -1
- package/dist/src/editor/components/color-toolbar.d.ts.map +1 -1
- package/dist/src/editor/components/editable-highlights.d.ts.map +1 -1
- package/dist/src/editor/components/outline.d.ts.map +1 -1
- package/dist/src/editor/constants.d.ts +1 -0
- package/dist/src/editor/constants.d.ts.map +1 -1
- package/dist/src/editor/dom.d.ts +9 -0
- package/dist/src/editor/dom.d.ts.map +1 -1
- package/dist/src/editor/editor.d.ts.map +1 -1
- package/dist/src/editor/history.d.ts.map +1 -1
- package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts.map +1 -1
- package/dist/src/editor/index.d.ts.map +1 -1
- package/dist/src/editor/storage.d.ts +2 -0
- package/dist/src/editor/storage.d.ts.map +1 -1
- package/dist/src/handlers/array-ops.d.ts +59 -0
- package/dist/src/handlers/array-ops.d.ts.map +1 -0
- package/dist/src/handlers/component-ops.d.ts +26 -0
- package/dist/src/handlers/component-ops.d.ts.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/source-finder/cross-file-tracker.d.ts.map +1 -1
- package/dist/src/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/build-processor.ts +27 -0
- package/src/component-registry.ts +125 -76
- package/src/dev-middleware.ts +85 -16
- package/src/editor/api.ts +72 -0
- package/src/editor/components/ai-chat.tsx +0 -1
- package/src/editor/components/block-editor.tsx +92 -17
- package/src/editor/components/color-toolbar.tsx +7 -1
- package/src/editor/components/editable-highlights.tsx +4 -1
- package/src/editor/components/outline.tsx +11 -6
- package/src/editor/constants.ts +1 -0
- package/src/editor/dom.ts +46 -1
- package/src/editor/editor.ts +5 -2
- package/src/editor/history.ts +1 -6
- package/src/editor/hooks/useBlockEditorHandlers.ts +86 -29
- package/src/editor/index.tsx +24 -8
- package/src/editor/storage.ts +24 -0
- package/src/handlers/array-ops.ts +452 -0
- package/src/handlers/component-ops.ts +269 -18
- package/src/handlers/markdown-ops.ts +7 -4
- package/src/handlers/request-utils.ts +1 -1
- package/src/handlers/source-writer.ts +4 -5
- package/src/index.ts +15 -10
- package/src/manifest-writer.ts +1 -1
- package/src/source-finder/cross-file-tracker.ts +1 -1
- package/src/source-finder/search-index.ts +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
2
|
-
import { manifest } from '../signals'
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
3
2
|
import { LAYOUT } from '../constants'
|
|
4
|
-
import { getComponentDefinition, getComponentDefinitions, getComponentInstance } from '../manifest'
|
|
3
|
+
import { getComponentDefinition, getComponentDefinitions, getComponentInstance, getComponentInstances } from '../manifest'
|
|
4
|
+
import { manifest } from '../signals'
|
|
5
5
|
import type { ComponentProp, InsertPosition } from '../types'
|
|
6
6
|
|
|
7
7
|
export interface BlockEditorProps {
|
|
@@ -37,11 +37,37 @@ export function BlockEditor({
|
|
|
37
37
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
38
38
|
const mockPreviewRef = useRef<HTMLElement | null>(null)
|
|
39
39
|
const removeOverlayRef = useRef<HTMLElement | null>(null)
|
|
40
|
-
const [editorPosition, setEditorPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
|
|
40
|
+
const [editorPosition, setEditorPosition] = useState<{ top: number; left: number; maxHeight: number }>({ top: 0, left: 0, maxHeight: 0 })
|
|
41
41
|
const componentDefinitions = getComponentDefinitions(manifest.value)
|
|
42
42
|
const currentInstance = componentId ? getComponentInstance(manifest.value, componentId) : null
|
|
43
43
|
const currentDefinition = currentInstance ? getComponentDefinition(manifest.value, currentInstance.componentName) : null
|
|
44
44
|
|
|
45
|
+
// Detect if this component is rendered from a data array (.map pattern)
|
|
46
|
+
const isArrayItem = useMemo(() => {
|
|
47
|
+
if (!currentInstance) return false
|
|
48
|
+
const instances = getComponentInstances(manifest.value)
|
|
49
|
+
let count = 0
|
|
50
|
+
for (const c of Object.values(instances)) {
|
|
51
|
+
if (
|
|
52
|
+
c.componentName === currentInstance.componentName
|
|
53
|
+
&& c.invocationSourcePath === currentInstance.invocationSourcePath
|
|
54
|
+
) {
|
|
55
|
+
count++
|
|
56
|
+
if (count > 1) return true
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return false
|
|
60
|
+
}, [currentInstance])
|
|
61
|
+
|
|
62
|
+
// Reset internal state when modal opens or a different component is selected
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (visible) {
|
|
65
|
+
setMode('edit')
|
|
66
|
+
setSelectedComponent(null)
|
|
67
|
+
setInsertPosition('after')
|
|
68
|
+
}
|
|
69
|
+
}, [visible])
|
|
70
|
+
|
|
45
71
|
useEffect(() => {
|
|
46
72
|
if (currentInstance) {
|
|
47
73
|
setPropValues(currentInstance.props || {})
|
|
@@ -84,7 +110,11 @@ export function BlockEditor({
|
|
|
84
110
|
left = (viewportWidth - editorWidth) / 2
|
|
85
111
|
}
|
|
86
112
|
|
|
87
|
-
|
|
113
|
+
// Clamp top so the panel never extends past the viewport bottom
|
|
114
|
+
top = Math.max(padding, Math.min(top, viewportHeight - padding - 100))
|
|
115
|
+
const maxHeight = viewportHeight - top - padding
|
|
116
|
+
|
|
117
|
+
setEditorPosition({ top, left, maxHeight })
|
|
88
118
|
}
|
|
89
119
|
|
|
90
120
|
updatePosition()
|
|
@@ -217,6 +247,26 @@ export function BlockEditor({
|
|
|
217
247
|
|
|
218
248
|
const handleStartInsert = (position: InsertPosition) => {
|
|
219
249
|
setInsertPosition(position)
|
|
250
|
+
|
|
251
|
+
if (isArrayItem && currentInstance) {
|
|
252
|
+
// For array items, skip the component picker — use the same component type
|
|
253
|
+
const definition = componentDefinitions[currentInstance.componentName]
|
|
254
|
+
if (definition) {
|
|
255
|
+
const defaultProps: Record<string, any> = {}
|
|
256
|
+
for (const prop of definition.props) {
|
|
257
|
+
if (prop.defaultValue !== undefined) {
|
|
258
|
+
defaultProps[prop.name] = prop.defaultValue
|
|
259
|
+
} else if (prop.required) {
|
|
260
|
+
defaultProps[prop.name] = ''
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
setSelectedComponent(currentInstance.componentName)
|
|
264
|
+
setPropValues(defaultProps)
|
|
265
|
+
setMode('insert-props')
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
220
270
|
setMode('insert-picker')
|
|
221
271
|
setSelectedComponent(null)
|
|
222
272
|
setPropValues({})
|
|
@@ -272,10 +322,11 @@ export function BlockEditor({
|
|
|
272
322
|
data-cms-ui
|
|
273
323
|
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
|
274
324
|
onClick={(e: MouseEvent) => e.stopPropagation()}
|
|
275
|
-
class="fixed z-2147483647 w-100 max-w-[calc(100vw-32px)]
|
|
325
|
+
class="fixed z-2147483647 w-100 max-w-[calc(100vw-32px)] bg-cms-dark shadow-[0_8px_32px_rgba(0,0,0,0.4)] font-sans text-sm overflow-hidden flex flex-col rounded-cms-xl border border-white/10"
|
|
276
326
|
style={{
|
|
277
327
|
top: `${editorPosition.top}px`,
|
|
278
328
|
left: `${editorPosition.left}px`,
|
|
329
|
+
maxHeight: `${editorPosition.maxHeight}px`,
|
|
279
330
|
}}
|
|
280
331
|
>
|
|
281
332
|
{/* Header */}
|
|
@@ -283,18 +334,22 @@ export function BlockEditor({
|
|
|
283
334
|
<span class="font-semibold text-white">
|
|
284
335
|
{mode === 'edit'
|
|
285
336
|
? (
|
|
286
|
-
currentDefinition
|
|
337
|
+
currentDefinition
|
|
338
|
+
? (isArrayItem ? `Edit ${currentDefinition.name} Item` : `Edit ${currentDefinition.name}`)
|
|
339
|
+
: 'Block Editor'
|
|
287
340
|
)
|
|
288
341
|
: mode === 'confirm-remove'
|
|
289
342
|
? (
|
|
290
|
-
|
|
343
|
+
isArrayItem
|
|
344
|
+
? `Remove ${currentDefinition?.name ?? ''} Item`
|
|
345
|
+
: `Remove ${currentDefinition?.name ?? 'Component'}`
|
|
291
346
|
)
|
|
292
347
|
: mode === 'insert-picker'
|
|
293
348
|
? (
|
|
294
349
|
`Insert ${insertPosition === 'before' ? 'Before' : 'After'}`
|
|
295
350
|
)
|
|
296
351
|
: (
|
|
297
|
-
`Add ${selectedComponent}`
|
|
352
|
+
isArrayItem ? `Add ${selectedComponent} Item` : `Add ${selectedComponent}`
|
|
298
353
|
)}
|
|
299
354
|
</span>
|
|
300
355
|
<button
|
|
@@ -316,13 +371,13 @@ export function BlockEditor({
|
|
|
316
371
|
onClick={() => handleStartInsert('before')}
|
|
317
372
|
class="flex-1 py-2.5 px-3 bg-white/10 text-white/80 rounded-cms-md cursor-pointer text-[13px] font-medium flex items-center justify-center gap-1.5 hover:bg-white/20 hover:text-white transition-colors"
|
|
318
373
|
>
|
|
319
|
-
<span class="text-base">↑</span> Insert before
|
|
374
|
+
<span class="text-base">↑</span> {isArrayItem ? 'Add item before' : 'Insert before'}
|
|
320
375
|
</button>
|
|
321
376
|
<button
|
|
322
377
|
onClick={() => handleStartInsert('after')}
|
|
323
378
|
class="flex-1 py-2.5 px-3 bg-white/10 text-white/80 rounded-cms-md cursor-pointer text-[13px] font-medium flex items-center justify-center gap-1.5 hover:bg-white/20 hover:text-white transition-colors"
|
|
324
379
|
>
|
|
325
|
-
<span class="text-base">↓</span> Insert after
|
|
380
|
+
<span class="text-base">↓</span> {isArrayItem ? 'Add item after' : 'Insert after'}
|
|
326
381
|
</button>
|
|
327
382
|
</div>
|
|
328
383
|
|
|
@@ -347,7 +402,7 @@ export function BlockEditor({
|
|
|
347
402
|
onClick={() => setMode('confirm-remove')}
|
|
348
403
|
class="px-4 py-2.5 bg-cms-error text-white rounded-cms-pill cursor-pointer hover:bg-red-600 transition-colors font-medium"
|
|
349
404
|
>
|
|
350
|
-
Remove
|
|
405
|
+
{isArrayItem ? 'Remove item' : 'Remove'}
|
|
351
406
|
</button>
|
|
352
407
|
<div class="flex gap-2">
|
|
353
408
|
<button
|
|
@@ -370,7 +425,17 @@ export function BlockEditor({
|
|
|
370
425
|
? (
|
|
371
426
|
<div class="text-center py-4">
|
|
372
427
|
<div class="px-4 py-3 bg-red-500/10 border border-red-500/30 rounded-cms-md mb-5 text-[13px] text-white">
|
|
373
|
-
|
|
428
|
+
{isArrayItem
|
|
429
|
+
? (
|
|
430
|
+
<>
|
|
431
|
+
This <strong>{currentDefinition?.name}</strong> item will be removed from the data array. This cannot be undone.
|
|
432
|
+
</>
|
|
433
|
+
)
|
|
434
|
+
: (
|
|
435
|
+
<>
|
|
436
|
+
The <strong>{currentDefinition?.name}</strong> component highlighted in the page will be removed. This cannot be undone.
|
|
437
|
+
</>
|
|
438
|
+
)}
|
|
374
439
|
</div>
|
|
375
440
|
<div class="flex gap-2 justify-end pt-4 border-t border-white/10 mt-4">
|
|
376
441
|
<button
|
|
@@ -388,7 +453,7 @@ export function BlockEditor({
|
|
|
388
453
|
}}
|
|
389
454
|
class="px-4 py-2.5 bg-cms-error text-white rounded-cms-pill cursor-pointer hover:bg-red-600 transition-colors font-medium"
|
|
390
455
|
>
|
|
391
|
-
Confirm remove
|
|
456
|
+
{isArrayItem ? 'Confirm remove item' : 'Confirm remove'}
|
|
392
457
|
</button>
|
|
393
458
|
</div>
|
|
394
459
|
</div>
|
|
@@ -399,7 +464,17 @@ export function BlockEditor({
|
|
|
399
464
|
{/* New component props */}
|
|
400
465
|
<div class="mb-5">
|
|
401
466
|
<div class="px-4 py-3 bg-white/10 rounded-cms-md mb-4 text-[13px] text-white">
|
|
402
|
-
|
|
467
|
+
{isArrayItem
|
|
468
|
+
? (
|
|
469
|
+
<>
|
|
470
|
+
Adding new <strong>{selectedComponent}</strong> item {insertPosition} current item
|
|
471
|
+
</>
|
|
472
|
+
)
|
|
473
|
+
: (
|
|
474
|
+
<>
|
|
475
|
+
Inserting <strong>{selectedComponent}</strong> {insertPosition} current component
|
|
476
|
+
</>
|
|
477
|
+
)}
|
|
403
478
|
</div>
|
|
404
479
|
{componentDefinitions[selectedComponent]?.props.map((prop) => (
|
|
405
480
|
<PropEditor
|
|
@@ -413,7 +488,7 @@ export function BlockEditor({
|
|
|
413
488
|
|
|
414
489
|
<div class="flex gap-2 justify-end pt-4 border-t border-white/10 mt-4">
|
|
415
490
|
<button
|
|
416
|
-
onClick={() => setMode('insert-picker')}
|
|
491
|
+
onClick={() => isArrayItem ? handleBackToEdit() : setMode('insert-picker')}
|
|
417
492
|
class="px-4 py-2.5 bg-white/10 text-white/80 rounded-cms-pill cursor-pointer hover:bg-white/20 hover:text-white transition-colors font-medium"
|
|
418
493
|
>
|
|
419
494
|
Back
|
|
@@ -422,7 +497,7 @@ export function BlockEditor({
|
|
|
422
497
|
onClick={handleConfirmInsert}
|
|
423
498
|
class="px-4 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill cursor-pointer hover:bg-cms-primary-hover transition-all font-medium"
|
|
424
499
|
>
|
|
425
|
-
Insert component
|
|
500
|
+
{isArrayItem ? 'Add item' : 'Insert component'}
|
|
426
501
|
</button>
|
|
427
502
|
</div>
|
|
428
503
|
</>
|
|
@@ -19,7 +19,13 @@ export interface ColorToolbarProps {
|
|
|
19
19
|
element: HTMLElement | null
|
|
20
20
|
availableColors: AvailableColors | undefined
|
|
21
21
|
currentClasses: Record<string, Attribute> | undefined
|
|
22
|
-
onColorChange?: (
|
|
22
|
+
onColorChange?: (
|
|
23
|
+
type: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText',
|
|
24
|
+
oldClass: string,
|
|
25
|
+
newClass: string,
|
|
26
|
+
previousClassName: string,
|
|
27
|
+
previousStyleCssText: string,
|
|
28
|
+
) => void
|
|
23
29
|
onClose?: () => void
|
|
24
30
|
}
|
|
25
31
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'preact/hooks'
|
|
2
2
|
import { Z_INDEX } from '../constants'
|
|
3
|
+
import { getOutlineColor } from '../dom'
|
|
3
4
|
import * as signals from '../signals'
|
|
4
5
|
|
|
5
6
|
export interface EditableHighlightsProps {
|
|
@@ -224,6 +225,7 @@ function collectEditableElements(): HighlightRect[] {
|
|
|
224
225
|
* Render highlight overlays efficiently by reusing DOM elements
|
|
225
226
|
*/
|
|
226
227
|
function renderHighlights(container: HTMLDivElement, highlights: HighlightRect[]): void {
|
|
228
|
+
const outlineColor = getOutlineColor()
|
|
227
229
|
// Get existing overlay elements
|
|
228
230
|
const existingOverlays = container.querySelectorAll('.highlight-overlay')
|
|
229
231
|
const existingCount = existingOverlays.length
|
|
@@ -246,11 +248,12 @@ function renderHighlights(container: HTMLDivElement, highlights: HighlightRect[]
|
|
|
246
248
|
// Update class based on type
|
|
247
249
|
overlay.className = `highlight-overlay ${highlight.type}`
|
|
248
250
|
|
|
249
|
-
// Update position
|
|
251
|
+
// Update position and color
|
|
250
252
|
overlay.style.left = `${highlight.rect.left - 6}px`
|
|
251
253
|
overlay.style.top = `${highlight.rect.top - 6}px`
|
|
252
254
|
overlay.style.width = `${highlight.rect.width + 12}px`
|
|
253
255
|
overlay.style.height = `${highlight.rect.height + 12}px`
|
|
256
|
+
overlay.style.borderColor = outlineColor
|
|
254
257
|
})
|
|
255
258
|
|
|
256
259
|
// Remove extra overlays
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'preact/hooks'
|
|
2
2
|
import { getColorPreview, parseColorClass } from '../color-utils'
|
|
3
3
|
import { Z_INDEX } from '../constants'
|
|
4
|
+
import { isPageDark } from '../dom'
|
|
4
5
|
import * as signals from '../signals'
|
|
5
6
|
|
|
6
7
|
export interface OutlineProps {
|
|
@@ -226,14 +227,18 @@ export function Outline(
|
|
|
226
227
|
overlayRef.current.style.width = `${width}px`
|
|
227
228
|
overlayRef.current.style.height = `${height}px`
|
|
228
229
|
|
|
230
|
+
// Detect page brightness for contrast-aware outline colors
|
|
231
|
+
const dark = isPageDark()
|
|
232
|
+
const outlineColor = dark ? '#FFFFFF' : '#1A1A1A'
|
|
233
|
+
|
|
229
234
|
// Different styling for components vs text elements
|
|
230
235
|
if (isComponent) {
|
|
231
|
-
overlayRef.current.style.border = `2px solid
|
|
232
|
-
overlayRef.current.style.backgroundColor = 'rgba(0, 0, 0, 0.03)'
|
|
233
|
-
overlayRef.current.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.08)'
|
|
236
|
+
overlayRef.current.style.border = `2px solid ${outlineColor}`
|
|
237
|
+
overlayRef.current.style.backgroundColor = dark ? 'rgba(255, 255, 255, 0.03)' : 'rgba(0, 0, 0, 0.03)'
|
|
238
|
+
overlayRef.current.style.boxShadow = dark ? '0 4px 16px rgba(255, 255, 255, 0.08)' : '0 4px 16px rgba(0, 0, 0, 0.08)'
|
|
234
239
|
labelRef.current.style.display = 'flex'
|
|
235
|
-
labelRef.current.style.backgroundColor = '#1A1A1A'
|
|
236
|
-
labelRef.current.style.color = 'white'
|
|
240
|
+
labelRef.current.style.backgroundColor = dark ? '#FFFFFF' : '#1A1A1A'
|
|
241
|
+
labelRef.current.style.color = dark ? '#1A1A1A' : 'white'
|
|
237
242
|
toolbarRef.current.className = 'element-toolbar hidden' // Hide toolbar for components
|
|
238
243
|
|
|
239
244
|
// Build label content
|
|
@@ -280,7 +285,7 @@ export function Outline(
|
|
|
280
285
|
labelRef.current.style.left = `${labelLeft}px`
|
|
281
286
|
}
|
|
282
287
|
} else {
|
|
283
|
-
overlayRef.current.style.border = `2px dashed
|
|
288
|
+
overlayRef.current.style.border = `2px dashed ${outlineColor}`
|
|
284
289
|
overlayRef.current.style.backgroundColor = 'transparent'
|
|
285
290
|
overlayRef.current.style.boxShadow = 'none'
|
|
286
291
|
labelRef.current.style.display = 'none'
|
package/src/editor/constants.ts
CHANGED
package/src/editor/dom.ts
CHANGED
|
@@ -8,6 +8,49 @@ import {
|
|
|
8
8
|
import { CSS } from './constants'
|
|
9
9
|
import type { ChildCmsElement } from './types'
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Parse an rgb/rgba color string into r, g, b components.
|
|
13
|
+
*/
|
|
14
|
+
function parseRgb(color: string): { r: number; g: number; b: number } | null {
|
|
15
|
+
const match = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
|
|
16
|
+
if (!match) return null
|
|
17
|
+
return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]) }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Calculate relative luminance of an sRGB color (0 = black, 1 = white).
|
|
22
|
+
*/
|
|
23
|
+
function relativeLuminance(r: number, g: number, b: number): number {
|
|
24
|
+
const toLinear = (c: number) => {
|
|
25
|
+
const s = c / 255
|
|
26
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
|
27
|
+
}
|
|
28
|
+
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect whether the page has a dark background by checking the computed
|
|
33
|
+
* background color of the body and html elements.
|
|
34
|
+
*/
|
|
35
|
+
export function isPageDark(): boolean {
|
|
36
|
+
if (typeof document === 'undefined') return false
|
|
37
|
+
for (const el of [document.body, document.documentElement]) {
|
|
38
|
+
if (!el) continue
|
|
39
|
+
const bg = getComputedStyle(el).backgroundColor
|
|
40
|
+
if (!bg || bg === 'transparent' || bg === 'rgba(0, 0, 0, 0)') continue
|
|
41
|
+
const parsed = parseRgb(bg)
|
|
42
|
+
if (parsed) return relativeLuminance(parsed.r, parsed.g, parsed.b) < 0.4
|
|
43
|
+
}
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get an outline color that contrasts with the page background.
|
|
49
|
+
*/
|
|
50
|
+
export function getOutlineColor(): string {
|
|
51
|
+
return isPageDark() ? '#FFFFFF' : '#1A1A1A'
|
|
52
|
+
}
|
|
53
|
+
|
|
11
54
|
/** Style element for contenteditable focus styles injected into the host page */
|
|
12
55
|
let focusStyleElement: HTMLStyleElement | null = null
|
|
13
56
|
|
|
@@ -253,11 +296,13 @@ export function cleanupHighlightSystem(): void {
|
|
|
253
296
|
*/
|
|
254
297
|
function injectFocusStyles(): void {
|
|
255
298
|
if (focusStyleElement) return
|
|
299
|
+
const dark = isPageDark()
|
|
300
|
+
const focusColor = dark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(26, 26, 26, 0.15)'
|
|
256
301
|
focusStyleElement = document.createElement('style')
|
|
257
302
|
focusStyleElement.id = 'cms-focus-styles'
|
|
258
303
|
focusStyleElement.textContent = `
|
|
259
304
|
[contenteditable="true"][data-cms-id]:focus {
|
|
260
|
-
outline: 2px solid
|
|
305
|
+
outline: 2px solid ${focusColor};
|
|
261
306
|
outline-offset: 6px;
|
|
262
307
|
border-radius: 4px;
|
|
263
308
|
}
|
package/src/editor/editor.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import type { Attribute } from './types'
|
|
2
1
|
import { fetchManifest, getDeploymentStatus, getMarkdownContent, saveBatchChanges } from './api'
|
|
3
2
|
import { CSS, TIMING } from './constants'
|
|
4
|
-
import { clearHistory, isApplyingUndoRedo, recordChange, recordTextChange } from './history'
|
|
5
3
|
import {
|
|
6
4
|
cleanupHighlightSystem,
|
|
7
5
|
disableAllInteractiveElements,
|
|
@@ -16,6 +14,7 @@ import {
|
|
|
16
14
|
makeElementEditable,
|
|
17
15
|
makeElementNonEditable,
|
|
18
16
|
} from './dom'
|
|
17
|
+
import { clearHistory, isApplyingUndoRedo, recordChange, recordTextChange } from './history'
|
|
19
18
|
import { getManifestEntryCount, hasManifestEntry } from './manifest'
|
|
20
19
|
import * as signals from './signals'
|
|
21
20
|
import {
|
|
@@ -27,9 +26,11 @@ import {
|
|
|
27
26
|
loadPendingEntryNavigation,
|
|
28
27
|
saveAttributeEditsToStorage,
|
|
29
28
|
saveColorEditsToStorage,
|
|
29
|
+
saveEditingState,
|
|
30
30
|
saveEditsToStorage,
|
|
31
31
|
saveImageEditsToStorage,
|
|
32
32
|
} from './storage'
|
|
33
|
+
import type { Attribute } from './types'
|
|
33
34
|
import type { AttributeChangePayload, ChangePayload, CmsConfig, DeploymentStatusResponse, ManifestEntry, SavedAttributeEdit } from './types'
|
|
34
35
|
|
|
35
36
|
// CSS attribute for markdown content elements
|
|
@@ -92,6 +93,7 @@ export async function startEditMode(
|
|
|
92
93
|
onStateChange?: () => void,
|
|
93
94
|
): Promise<void> {
|
|
94
95
|
signals.setEditing(true)
|
|
96
|
+
saveEditingState(true)
|
|
95
97
|
disableAllInteractiveElements()
|
|
96
98
|
initHighlightSystem()
|
|
97
99
|
onStateChange?.()
|
|
@@ -310,6 +312,7 @@ export async function startEditMode(
|
|
|
310
312
|
*/
|
|
311
313
|
export function stopEditMode(onStateChange?: () => void): void {
|
|
312
314
|
signals.setEditing(false)
|
|
315
|
+
saveEditingState(false)
|
|
313
316
|
signals.setShowingOriginal(false)
|
|
314
317
|
enableAllInteractiveElements()
|
|
315
318
|
cleanupHighlightSystem()
|
package/src/editor/history.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { computed, signal } from '@preact/signals'
|
|
2
2
|
import * as signals from './signals'
|
|
3
|
-
import {
|
|
4
|
-
saveAttributeEditsToStorage,
|
|
5
|
-
saveColorEditsToStorage,
|
|
6
|
-
saveEditsToStorage,
|
|
7
|
-
saveImageEditsToStorage,
|
|
8
|
-
} from './storage'
|
|
3
|
+
import { saveAttributeEditsToStorage, saveColorEditsToStorage, saveEditsToStorage, saveImageEditsToStorage } from './storage'
|
|
9
4
|
import type { Attribute, UndoAction, UndoTextAction } from './types'
|
|
10
5
|
|
|
11
6
|
// ============================================================================
|
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
import { useCallback, useState } from 'preact/hooks'
|
|
2
2
|
import { logDebug } from '../dom'
|
|
3
3
|
import { startDeploymentPolling } from '../editor'
|
|
4
|
+
import { getComponentInstances } from '../manifest'
|
|
4
5
|
import * as signals from '../signals'
|
|
5
|
-
import type { CmsConfig, InsertPosition } from '../types'
|
|
6
|
+
import type { CmsConfig, CmsManifest, ComponentInstance, InsertPosition } from '../types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Detect whether a component is rendered from a data array via `.map()`.
|
|
10
|
+
* Heuristic: if multiple component instances share the same name AND the same
|
|
11
|
+
* invocationSourcePath, they are likely array-rendered.
|
|
12
|
+
*/
|
|
13
|
+
function isArrayRendered(manifest: CmsManifest, component: ComponentInstance): boolean {
|
|
14
|
+
const instances = getComponentInstances(manifest)
|
|
15
|
+
let count = 0
|
|
16
|
+
for (const c of Object.values(instances)) {
|
|
17
|
+
if (
|
|
18
|
+
c.componentName === component.componentName
|
|
19
|
+
&& c.invocationSourcePath === component.invocationSourcePath
|
|
20
|
+
) {
|
|
21
|
+
count++
|
|
22
|
+
if (count > 1) return true
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
6
27
|
|
|
7
28
|
/** Collapse a DOM element with a smooth height transition */
|
|
8
29
|
function collapseElement(el: Element) {
|
|
@@ -65,7 +86,7 @@ export function useBlockEditorHandlers({
|
|
|
65
86
|
)
|
|
66
87
|
|
|
67
88
|
/**
|
|
68
|
-
* Insert a new component
|
|
89
|
+
* Insert a new component (or add array item if array-rendered)
|
|
69
90
|
*/
|
|
70
91
|
const handleInsertComponent = useCallback(
|
|
71
92
|
async (
|
|
@@ -84,6 +105,11 @@ export function useBlockEditorHandlers({
|
|
|
84
105
|
props,
|
|
85
106
|
)
|
|
86
107
|
|
|
108
|
+
// Check if this is an array-rendered component
|
|
109
|
+
const currentManifest = signals.manifest.value
|
|
110
|
+
const refComponent = currentManifest.components[referenceComponentId]
|
|
111
|
+
const arrayMode = refComponent && isArrayRendered(currentManifest, refComponent)
|
|
112
|
+
|
|
87
113
|
// Clone the existing mock preview before the block editor unmounts and removes it
|
|
88
114
|
const existingMock = document.querySelector('[data-cms-preview-mock]') as HTMLElement | null
|
|
89
115
|
let previewEl: HTMLElement | null = null
|
|
@@ -96,30 +122,55 @@ export function useBlockEditorHandlers({
|
|
|
96
122
|
existingMock.parentNode?.insertBefore(previewEl, existingMock.nextSibling)
|
|
97
123
|
}
|
|
98
124
|
|
|
99
|
-
// Call API to insert the component in source code
|
|
100
125
|
try {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
126
|
+
if (arrayMode) {
|
|
127
|
+
// Route to array-item endpoint
|
|
128
|
+
const response = await fetch(`${config.apiBase}/add-array-item`, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'Content-Type': 'application/json' },
|
|
131
|
+
credentials: 'include',
|
|
132
|
+
body: JSON.stringify({
|
|
133
|
+
referenceComponentId,
|
|
134
|
+
position,
|
|
135
|
+
props,
|
|
136
|
+
meta: {
|
|
137
|
+
source: 'inline-editor',
|
|
138
|
+
url: window.location.href,
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
})
|
|
116
142
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
const error = await response.text()
|
|
145
|
+
throw new Error(error || 'Failed to add array item')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
showToast(`Item added ${position} current item`, 'success')
|
|
149
|
+
} else {
|
|
150
|
+
// Standard component insertion
|
|
151
|
+
const response = await fetch(`${config.apiBase}/insert-component`, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
credentials: 'include',
|
|
155
|
+
body: JSON.stringify({
|
|
156
|
+
position,
|
|
157
|
+
referenceComponentId,
|
|
158
|
+
componentName,
|
|
159
|
+
props,
|
|
160
|
+
meta: {
|
|
161
|
+
source: 'inline-editor',
|
|
162
|
+
url: window.location.href,
|
|
163
|
+
},
|
|
164
|
+
}),
|
|
165
|
+
})
|
|
121
166
|
|
|
122
|
-
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
const error = await response.text()
|
|
169
|
+
throw new Error(error || 'Failed to insert component')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
showToast(`${componentName} inserted ${position} component`, 'success')
|
|
173
|
+
}
|
|
123
174
|
|
|
124
175
|
// Trigger deployment polling after successful insert
|
|
125
176
|
startDeploymentPolling(config)
|
|
@@ -129,19 +180,24 @@ export function useBlockEditorHandlers({
|
|
|
129
180
|
// Remove the preview on failure
|
|
130
181
|
previewEl?.remove()
|
|
131
182
|
|
|
132
|
-
showToast('Failed to insert component', 'error')
|
|
183
|
+
showToast(arrayMode ? 'Failed to add array item' : 'Failed to insert component', 'error')
|
|
133
184
|
}
|
|
134
185
|
},
|
|
135
186
|
[config.apiBase, config.debug, config, showToast],
|
|
136
187
|
)
|
|
137
188
|
|
|
138
189
|
/**
|
|
139
|
-
* Remove a block/component
|
|
190
|
+
* Remove a block/component (or remove array item if array-rendered)
|
|
140
191
|
*/
|
|
141
192
|
const handleRemoveBlock = useCallback(
|
|
142
193
|
async (componentId: string) => {
|
|
143
194
|
logDebug(config.debug, 'Remove block:', componentId)
|
|
144
195
|
|
|
196
|
+
// Check if this is an array-rendered component
|
|
197
|
+
const currentManifest = signals.manifest.value
|
|
198
|
+
const component = currentManifest.components[componentId]
|
|
199
|
+
const arrayMode = component && isArrayRendered(currentManifest, component)
|
|
200
|
+
|
|
145
201
|
// Find the element in the DOM
|
|
146
202
|
const componentEl = document.querySelector(
|
|
147
203
|
`[data-cms-component-id="${componentId}"]`,
|
|
@@ -154,7 +210,8 @@ export function useBlockEditorHandlers({
|
|
|
154
210
|
}
|
|
155
211
|
|
|
156
212
|
try {
|
|
157
|
-
const
|
|
213
|
+
const endpoint = arrayMode ? 'remove-array-item' : 'remove-component'
|
|
214
|
+
const response = await fetch(`${config.apiBase}/${endpoint}`, {
|
|
158
215
|
method: 'POST',
|
|
159
216
|
headers: { 'Content-Type': 'application/json' },
|
|
160
217
|
credentials: 'include',
|
|
@@ -169,10 +226,10 @@ export function useBlockEditorHandlers({
|
|
|
169
226
|
|
|
170
227
|
if (!response.ok) {
|
|
171
228
|
const error = await response.text()
|
|
172
|
-
throw new Error(error ||
|
|
229
|
+
throw new Error(error || `Failed to ${arrayMode ? 'remove item' : 'remove component'}`)
|
|
173
230
|
}
|
|
174
231
|
|
|
175
|
-
showToast('Component removed', 'success')
|
|
232
|
+
showToast(arrayMode ? 'Item removed' : 'Component removed', 'success')
|
|
176
233
|
|
|
177
234
|
// Trigger deployment polling after successful remove
|
|
178
235
|
startDeploymentPolling(config)
|
|
@@ -183,7 +240,7 @@ export function useBlockEditorHandlers({
|
|
|
183
240
|
}
|
|
184
241
|
} catch (error) {
|
|
185
242
|
console.error('[CMS] Failed to remove component:', error)
|
|
186
|
-
showToast('Failed to remove component', 'error')
|
|
243
|
+
showToast(arrayMode ? 'Failed to remove item' : 'Failed to remove component', 'error')
|
|
187
244
|
|
|
188
245
|
// Restore the component's appearance on failure
|
|
189
246
|
if (componentEl) {
|