@nuasite/cms 0.2.2 → 0.3.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.
Files changed (55) hide show
  1. package/README.md +81 -73
  2. package/dist/src/build-processor.d.ts.map +1 -1
  3. package/dist/src/component-registry.d.ts +6 -2
  4. package/dist/src/component-registry.d.ts.map +1 -1
  5. package/dist/src/dev-middleware.d.ts.map +1 -1
  6. package/dist/src/editor/api.d.ts +14 -0
  7. package/dist/src/editor/api.d.ts.map +1 -1
  8. package/dist/src/editor/components/ai-chat.d.ts.map +1 -1
  9. package/dist/src/editor/components/block-editor.d.ts.map +1 -1
  10. package/dist/src/editor/components/color-toolbar.d.ts.map +1 -1
  11. package/dist/src/editor/components/editable-highlights.d.ts.map +1 -1
  12. package/dist/src/editor/components/outline.d.ts.map +1 -1
  13. package/dist/src/editor/constants.d.ts +1 -0
  14. package/dist/src/editor/constants.d.ts.map +1 -1
  15. package/dist/src/editor/dom.d.ts +9 -0
  16. package/dist/src/editor/dom.d.ts.map +1 -1
  17. package/dist/src/editor/editor.d.ts.map +1 -1
  18. package/dist/src/editor/history.d.ts.map +1 -1
  19. package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts.map +1 -1
  20. package/dist/src/editor/index.d.ts.map +1 -1
  21. package/dist/src/editor/storage.d.ts +2 -0
  22. package/dist/src/editor/storage.d.ts.map +1 -1
  23. package/dist/src/handlers/array-ops.d.ts +59 -0
  24. package/dist/src/handlers/array-ops.d.ts.map +1 -0
  25. package/dist/src/handlers/component-ops.d.ts +26 -0
  26. package/dist/src/handlers/component-ops.d.ts.map +1 -1
  27. package/dist/src/index.d.ts.map +1 -1
  28. package/dist/src/source-finder/cross-file-tracker.d.ts.map +1 -1
  29. package/dist/src/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +1 -1
  31. package/src/build-processor.ts +27 -0
  32. package/src/component-registry.ts +125 -76
  33. package/src/dev-middleware.ts +85 -16
  34. package/src/editor/api.ts +72 -0
  35. package/src/editor/components/ai-chat.tsx +0 -1
  36. package/src/editor/components/block-editor.tsx +92 -17
  37. package/src/editor/components/color-toolbar.tsx +7 -1
  38. package/src/editor/components/editable-highlights.tsx +4 -1
  39. package/src/editor/components/outline.tsx +11 -6
  40. package/src/editor/constants.ts +1 -0
  41. package/src/editor/dom.ts +46 -1
  42. package/src/editor/editor.ts +5 -2
  43. package/src/editor/history.ts +1 -6
  44. package/src/editor/hooks/useBlockEditorHandlers.ts +86 -29
  45. package/src/editor/index.tsx +24 -8
  46. package/src/editor/storage.ts +24 -0
  47. package/src/handlers/array-ops.ts +452 -0
  48. package/src/handlers/component-ops.ts +269 -18
  49. package/src/handlers/markdown-ops.ts +7 -4
  50. package/src/handlers/request-utils.ts +1 -1
  51. package/src/handlers/source-writer.ts +4 -5
  52. package/src/index.ts +15 -10
  53. package/src/manifest-writer.ts +1 -1
  54. package/src/source-finder/cross-file-tracker.ts +1 -1
  55. 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, componentId])
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
- setEditorPosition({ top, left })
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)] max-h-[calc(100vh-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"
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 ? `Edit ${currentDefinition.name}` : 'Block Editor'
337
+ currentDefinition
338
+ ? (isArrayItem ? `Edit ${currentDefinition.name} Item` : `Edit ${currentDefinition.name}`)
339
+ : 'Block Editor'
287
340
  )
288
341
  : mode === 'confirm-remove'
289
342
  ? (
290
- `Remove ${currentDefinition?.name ?? 'Component'}`
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
- The <strong>{currentDefinition?.name}</strong> component highlighted in the page will be removed. This cannot be undone.
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
- Inserting <strong>{selectedComponent}</strong> {insertPosition} current component
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?: (type: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText', oldClass: string, newClass: string, previousClassName: string, previousStyleCssText: string) => void
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 #1A1A1A` // Dark border
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 #1A1A1A`
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'
@@ -89,6 +89,7 @@ export const STORAGE_KEYS = {
89
89
  PENDING_ATTRIBUTE_EDITS: 'cms-pending-attribute-edits',
90
90
  SETTINGS: 'cms-settings',
91
91
  PENDING_ENTRY_NAVIGATION: 'cms-pending-entry-navigation',
92
+ IS_EDITING: 'cms-is-editing',
92
93
  } as const
93
94
 
94
95
  /**
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 rgba(26, 26, 26, 0.15);
305
+ outline: 2px solid ${focusColor};
261
306
  outline-offset: 6px;
262
307
  border-radius: 4px;
263
308
  }
@@ -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()
@@ -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
- const response = await fetch(`${config.apiBase}/insert-component`, {
102
- method: 'POST',
103
- headers: { 'Content-Type': 'application/json' },
104
- credentials: 'include',
105
- body: JSON.stringify({
106
- position,
107
- referenceComponentId,
108
- componentName,
109
- props,
110
- meta: {
111
- source: 'inline-editor',
112
- url: window.location.href,
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
- if (!response.ok) {
118
- const error = await response.text()
119
- throw new Error(error || 'Failed to insert component')
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
- showToast(`${componentName} inserted ${position} component`, 'success')
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 response = await fetch(`${config.apiBase}/remove-component`, {
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 || 'Failed to remove component')
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) {