@devbycrux/editor 0.2.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devbycrux/editor",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -151,7 +151,7 @@ function isTypingTarget(t: EventTarget | null): boolean {
151
151
 
152
152
  // ── CarouselEditor ────────────────────────────────────────────────────────────
153
153
 
154
- export default function CarouselEditor<P extends Project = Project>({ project: initialProject, adapter, onProjectChange, theme, slots }: Props<P>) {
154
+ export default function CarouselEditor<P extends Project = Project>({ project: initialProject, adapter, onProjectChange, theme, slots, hiddenElementIds, onToggleElementVisibility, onSelectionChange }: Props<P>) {
155
155
  const state = useProjectState(adapter, initialProject.id, initialProject)
156
156
  const project = state.project
157
157
  const slides = project.slides ?? []
@@ -339,6 +339,12 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
339
339
  const selectedSlide = slides.find(s => s.id === selectedSlideId)
340
340
  const selectedElement = selectedSlide?.elements.find(el => el.id === selectedElementId)
341
341
 
342
+ // Notify the host of selection changes so it can drive selection-aware chrome
343
+ // (e.g. a regen action in a toolbar slot). Fires with the element or null.
344
+ useEffect(() => {
345
+ onSelectionChange?.(selectedElement ?? null)
346
+ }, [selectedElement, onSelectionChange])
347
+
342
348
  const [w, h] = project.settings.resolution
343
349
  const canvasContainerRef = useRef<HTMLDivElement>(null)
344
350
  const [canvasContainerSize, setCanvasContainerSize] = useState<{ w: number; h: number }>({ w: 600, h: 700 })
@@ -387,21 +393,24 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
387
393
  <span className="text-xs font-medium">Refresh</span>
388
394
  </button>
389
395
 
390
- <button
391
- onClick={handleRender}
392
- disabled={rendering || project.status === 'pending' || slides.length === 0}
393
- className="absolute top-3 right-3 z-30 flex items-center gap-2 px-3 py-2 rounded-md border border-blue-500/50 bg-blue-600/80 text-white hover:bg-blue-600 hover:border-blue-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
394
- title={
395
- project.status === 'pending'
396
- ? 'Wait for the agent to finish before rendering'
397
- : slides.length === 0
398
- ? 'Add slides before rendering'
399
- : 'Render all slides as PNGs'
400
- }
401
- >
402
- <Download size={18} />
403
- <span className="text-xs font-medium">{rendering ? 'Starting…' : 'Render'}</span>
404
- </button>
396
+ <div className="absolute top-3 right-3 z-30 flex items-center gap-2">
397
+ {slots?.toolbarActions}
398
+ <button
399
+ onClick={handleRender}
400
+ disabled={rendering || project.status === 'pending' || slides.length === 0}
401
+ className="flex items-center gap-2 px-3 py-2 rounded-md border border-blue-500/50 bg-blue-600/80 text-white hover:bg-blue-600 hover:border-blue-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
402
+ title={
403
+ project.status === 'pending'
404
+ ? 'Wait for the agent to finish before rendering'
405
+ : slides.length === 0
406
+ ? 'Add slides before rendering'
407
+ : 'Render all slides as PNGs'
408
+ }
409
+ >
410
+ <Download size={18} />
411
+ <span className="text-xs font-medium">{rendering ? 'Starting…' : 'Render'}</span>
412
+ </button>
413
+ </div>
405
414
 
406
415
  {project.status === 'pending' ? (
407
416
  <div className="flex flex-col items-center gap-6 text-center max-w-lg w-full">
@@ -461,6 +470,7 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
461
470
  updateImageCrop={state.updateImageCrop}
462
471
  cropElementId={cropElementId}
463
472
  onExitCrop={() => setCropElementId(null)}
473
+ hiddenElementIds={hiddenElementIds}
464
474
  />
465
475
  </div>
466
476
  <p className="flex-shrink-0 text-xs text-gray-500 text-center max-w-md">
@@ -505,6 +515,8 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
505
515
  onReorderElement={handleReorderElement}
506
516
  onEnterCrop={(_slideId, elementId) => { setSelectedElementId(elementId); setCropElementId(elementId) }}
507
517
  updateOverlayProp={state.updateOverlayProp}
518
+ hiddenElementIds={hiddenElementIds}
519
+ onToggleElementVisibility={onToggleElementVisibility}
508
520
  />
509
521
  {slots?.assetsPanel && (
510
522
  <div className="border-t border-gray-800 flex flex-col overflow-hidden" style={{ minHeight: 180 }}>
@@ -106,6 +106,13 @@ interface Props {
106
106
  // Crop mode is owned here but the entry trigger lives in the property panel.
107
107
  cropElementId?: string | null
108
108
  onExitCrop?: () => void
109
+
110
+ /**
111
+ * Editor-only element ids to omit from this canvas (non-persisted). Used by
112
+ * the host's visibility toggle to hide a scrim/background while positioning
113
+ * overlays beneath it. Absent → all elements render.
114
+ */
115
+ hiddenElementIds?: string[]
109
116
  }
110
117
 
111
118
  export default function SlideCanvas({
@@ -128,9 +135,11 @@ export default function SlideCanvas({
128
135
  updateImageCrop,
129
136
  cropElementId,
130
137
  onExitCrop,
138
+ hiddenElementIds,
131
139
  }: Props) {
132
140
  const sid = slideId ?? slide.id
133
141
  const resolveSrc = resolveImageSrc ?? ((el: ImageElement) => resolveAssetDefault(el.src))
142
+ const hiddenSet = hiddenElementIds && hiddenElementIds.length ? new Set(hiddenElementIds) : null
134
143
 
135
144
  // Refs to each element wrapper so gesture previews can mutate DOM directly.
136
145
  const wrapperRefs = useRef<Map<string, HTMLDivElement>>(new Map())
@@ -295,6 +304,7 @@ export default function SlideCanvas({
295
304
 
296
305
  return (
297
306
  <div
307
+ data-interactive={interactive ? 'true' : undefined}
298
308
  style={{
299
309
  width: displayW,
300
310
  height: displayH,
@@ -353,6 +363,8 @@ export default function SlideCanvas({
353
363
  )}
354
364
 
355
365
  {slide.elements.map((element) => {
366
+ // Editor-only visibility: omit hidden elements from this canvas.
367
+ if (hiddenSet?.has(element.id)) return null
356
368
  const isSelected = selectedElementId === element.id
357
369
  const inCrop = cropState?.elementId === element.id
358
370
  const isRotated = (element.rotation ?? 0) !== 0
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from 'react'
2
- import { Crop } from 'lucide-react'
2
+ import { Crop, Eye, EyeOff } from 'lucide-react'
3
3
  import type {
4
4
  Project,
5
5
  Slide,
@@ -34,6 +34,37 @@ interface Props {
34
34
  updateOverlayProp?: (slideId: string, elementId: string, key: string, value: string) => Promise<void>
35
35
  // Adapter supplies overlay-schema listing (global + profile-scoped).
36
36
  adapter: EditorAdapter<Project>
37
+ // Editor-only element visibility (host-owned, non-persisted). When
38
+ // `onToggleElementVisibility` is supplied, an eye toggle is shown for the
39
+ // selected element; `hiddenElementIds` reflects the current hidden set.
40
+ hiddenElementIds?: string[]
41
+ onToggleElementVisibility?: (elementId: string) => void
42
+ }
43
+
44
+ // Small eye toggle to hide/show the selected element in the editor preview only
45
+ // (never persisted). Absent host callback → not rendered.
46
+ function HideToggle({
47
+ elementId,
48
+ isHidden,
49
+ onToggle,
50
+ }: {
51
+ elementId: string
52
+ isHidden: boolean
53
+ onToggle?: (elementId: string) => void
54
+ }) {
55
+ if (!onToggle) return null
56
+ return (
57
+ <button
58
+ type="button"
59
+ onClick={e => { e.stopPropagation(); onToggle(elementId) }}
60
+ title={isHidden ? 'Show in editor' : 'Hide from editor'}
61
+ aria-label={isHidden ? 'Show in editor' : 'Hide from editor'}
62
+ aria-pressed={isHidden}
63
+ className="text-gray-500 hover:text-white px-1"
64
+ >
65
+ {isHidden ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
66
+ </button>
67
+ )
37
68
  }
38
69
 
39
70
  function numInput(
@@ -146,6 +177,8 @@ export default function SlidePropertyPanel({
146
177
  onEnterCrop,
147
178
  updateOverlayProp,
148
179
  adapter,
180
+ hiddenElementIds,
181
+ onToggleElementVisibility,
149
182
  }: Props) {
150
183
  // Map of jsxPath → GlobalOverlay for overlay prop schemas
151
184
  const [overlaySchemas, setOverlaySchemas] = useState<Map<string, GlobalOverlay>>(new Map())
@@ -225,7 +258,12 @@ export default function SlidePropertyPanel({
225
258
  <span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
226
259
  {element.type === 'image' ? 'Image' : 'Overlay'}
227
260
  </span>
228
- <div className="flex gap-1">
261
+ <div className="flex items-center gap-1">
262
+ <HideToggle
263
+ elementId={element.id}
264
+ isHidden={hiddenElementIds?.includes(element.id) ?? false}
265
+ onToggle={onToggleElementVisibility}
266
+ />
229
267
  <button
230
268
  onClick={() => onReorderElement(slide.id, element.id, 'forward')}
231
269
  className="text-xs text-gray-500 hover:text-white px-1"
@@ -232,4 +232,56 @@ describe('CarouselEditor — editor-core integration', () => {
232
232
  expect(adapter.saveCalls.length).toBe(before)
233
233
  document.body.removeChild(input)
234
234
  })
235
+
236
+ // Visibility toggle: ids in `hiddenElementIds` are omitted from the interactive
237
+ // canvas (editor-only; the thumbnail and `saveProject` are untouched).
238
+ it('omits hidden elements from the interactive canvas', async () => {
239
+ const adapter = makeFakeAdapter()
240
+ const initial = makeProject({
241
+ slides: [
242
+ {
243
+ id: 'slide-0',
244
+ base_color: '#ffffff',
245
+ elements: [
246
+ { id: 'el-a', type: 'image', src: 'a.png', x: 0, y: 0, w: 100, h: 100, rotation: 0 },
247
+ { id: 'el-b', type: 'image', src: 'b.png', x: 10, y: 10, w: 100, h: 100, rotation: 0 },
248
+ ],
249
+ },
250
+ ],
251
+ })
252
+ const { container } = render(
253
+ <CarouselEditor project={initial} adapter={adapter} onProjectChange={vi.fn()} hiddenElementIds={['el-b']} />,
254
+ )
255
+ // Visible element renders in the interactive canvas; hidden one does not.
256
+ await waitFor(() => expect(container.querySelector('[data-interactive] [data-element-id="el-a"]')).not.toBeNull())
257
+ expect(container.querySelector('[data-interactive] [data-element-id="el-b"]')).toBeNull()
258
+ })
259
+
260
+ // onSelectionChange fires with the selected element, and with null on deselect.
261
+ it('fires onSelectionChange on select and deselect', async () => {
262
+ const adapter = makeFakeAdapter()
263
+ const initial = makeProject()
264
+ const onSelectionChange = vi.fn()
265
+ const { container } = render(
266
+ <CarouselEditor project={initial} adapter={adapter} onProjectChange={vi.fn()} onSelectionChange={onSelectionChange} />,
267
+ )
268
+
269
+ const lastSelection = () => {
270
+ const calls = onSelectionChange.mock.calls
271
+ return calls[calls.length - 1]?.[0]
272
+ }
273
+
274
+ const wrapper = await waitFor(() => findInteractiveWrapper('el-img'))
275
+ await act(async () => { fireEvent.click(wrapper) })
276
+ await waitFor(() => {
277
+ expect(lastSelection()?.id).toBe('el-img')
278
+ })
279
+
280
+ // Click the interactive canvas background to clear selection.
281
+ const root = container.querySelector('[data-interactive]') as HTMLElement
282
+ await act(async () => { fireEvent.click(root) })
283
+ await waitFor(() => {
284
+ expect(lastSelection()).toBeNull()
285
+ })
286
+ })
235
287
  })
package/src/types.ts CHANGED
@@ -406,6 +406,26 @@ export interface CarouselEditorProps<P extends Project = Project> {
406
406
  theme?: EditorTheme
407
407
  slots?: EditorSlots
408
408
  readOnly?: boolean
409
+ /**
410
+ * Editor-only set of element ids to hide from the interactive canvas. The host
411
+ * owns this state; the package never persists it (hidden elements are omitted
412
+ * from the canvas render only, never from `saveProject`). Lets a host
413
+ * temporarily hide a scrim/background to position overlays beneath it.
414
+ */
415
+ hiddenElementIds?: string[]
416
+ /**
417
+ * Invoked when the user toggles the selected element's editor-visibility via
418
+ * the property-panel eye button. The host updates its hidden-set and reflects
419
+ * it back through `hiddenElementIds`. Absent → no eye toggle is rendered.
420
+ */
421
+ onToggleElementVisibility?: (elementId: string) => void
422
+ /**
423
+ * Invoked whenever the selected element changes — with the element, or `null`
424
+ * when selection clears. Lets a host drive selection-aware chrome (e.g. a
425
+ * "regenerate image" action in a toolbar slot that targets the current
426
+ * selection). The package keeps owning selection state.
427
+ */
428
+ onSelectionChange?: (element: CarouselElement | null) => void
409
429
  }
410
430
 
411
431
  /**