@dfosco/storyboard-react 3.11.0-beta.2 → 3.11.0-beta.4

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,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.11.0-beta.2",
3
+ "version": "3.11.0-beta.4",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.11.0-beta.2",
7
- "@dfosco/tiny-canvas": "3.11.0-beta.2",
6
+ "@dfosco/storyboard-core": "3.11.0-beta.4",
7
+ "@dfosco/tiny-canvas": "3.11.0-beta.4",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1"
@@ -130,6 +130,26 @@ function roundPosition(value) {
130
130
  return Math.round(value)
131
131
  }
132
132
 
133
+ /** Snap a value to the nearest grid line. */
134
+ function snapValue(value, gridSize) {
135
+ return Math.round(value / gridSize) * gridSize
136
+ }
137
+
138
+ /** Snap a position to the grid if snapping is enabled. */
139
+ function snapPosition(pos, gridSize, enabled) {
140
+ if (!enabled || !gridSize) return pos
141
+ return {
142
+ x: Math.max(0, snapValue(pos.x, gridSize)),
143
+ y: Math.max(0, snapValue(pos.y, gridSize)),
144
+ }
145
+ }
146
+
147
+ /** Snap a dimension to the grid if snapping is enabled. */
148
+ function snapDimension(value, gridSize, enabled, min = 0) {
149
+ if (!enabled || !gridSize) return value
150
+ return Math.max(min, snapValue(value, gridSize))
151
+ }
152
+
133
153
  /** Padding (canvas-space pixels) around bounding box for zoom-to-fit. */
134
154
  const FIT_PADDING = 48
135
155
 
@@ -264,6 +284,8 @@ export default function CanvasPage({ name }) {
264
284
  const titleInputRef = useRef(null)
265
285
  const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
266
286
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
287
+ const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
288
+ const snapGridSize = canvas?.gridSize || 40
267
289
 
268
290
  // Undo/redo history — tracks both widgets and sources as a combined snapshot
269
291
  const undoRedo = useUndoRedo()
@@ -321,15 +343,21 @@ export default function CanvasPage({ name }) {
321
343
 
322
344
  const handleWidgetUpdate = useCallback((widgetId, updates) => {
323
345
  undoRedo.snapshot(stateRef.current, 'edit', widgetId)
346
+ // Snap width/height to grid when snap is enabled
347
+ const snapped = { ...updates }
348
+ if (snapEnabled && snapGridSize) {
349
+ if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 60)
350
+ if (snapped.height != null) snapped.height = snapDimension(snapped.height, snapGridSize, true, 60)
351
+ }
324
352
  setLocalWidgets((prev) => {
325
353
  if (!prev) return prev
326
354
  const next = prev.map((w) =>
327
- w.id === widgetId ? { ...w, props: { ...w.props, ...updates } } : w
355
+ w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
328
356
  )
329
357
  debouncedSave(name, next)
330
358
  return next
331
359
  })
332
- }, [name, debouncedSave, undoRedo])
360
+ }, [name, debouncedSave, undoRedo, snapEnabled, snapGridSize])
333
361
 
334
362
  const handleWidgetRemove = useCallback((widgetId) => {
335
363
  undoRedo.snapshot(stateRef.current, 'remove', widgetId)
@@ -390,7 +418,8 @@ export default function CanvasPage({ name }) {
390
418
 
391
419
  const handleItemDragEnd = useCallback((dragId, position) => {
392
420
  if (!dragId || !position) return
393
- const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
421
+ const raw = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
422
+ const rounded = snapPosition(raw, snapGridSize, snapEnabled)
394
423
 
395
424
  if (dragId.startsWith('jsx-')) {
396
425
  undoRedo.snapshot(stateRef.current, 'move', dragId)
@@ -423,7 +452,7 @@ export default function CanvasPage({ name }) {
423
452
  )
424
453
  return next
425
454
  })
426
- }, [name, undoRedo])
455
+ }, [name, undoRedo, snapEnabled, snapGridSize])
427
456
 
428
457
  useEffect(() => {
429
458
  zoomRef.current = zoom
@@ -446,29 +475,49 @@ export default function CanvasPage({ name }) {
446
475
  const targetId = params.get('widget')
447
476
  if (!targetId || loading) return
448
477
 
449
- const widgets = localWidgets ?? []
450
- const widget = widgets.find((w) => w.id === targetId)
451
- if (!widget) return
452
-
453
478
  const el = scrollRef.current
454
479
  if (!el) return
455
480
 
456
- const scale = zoomRef.current / 100
457
- const fallback = WIDGET_FALLBACK_SIZES[widget.type] || { width: 200, height: 150 }
458
- const wW = (widget.props?.width ?? fallback.width) * scale
459
- const wH = (widget.props?.height ?? fallback.height) * scale
460
- const wX = (widget.position?.x ?? 0) * scale
461
- const wY = (widget.position?.y ?? 0) * scale
481
+ let x, y, w, h
482
+
483
+ // Check JSON widgets first
484
+ const widgets = localWidgets ?? []
485
+ const widget = widgets.find((wgt) => wgt.id === targetId)
486
+ if (widget) {
487
+ const fallback = WIDGET_FALLBACK_SIZES[widget.type] || { width: 200, height: 150 }
488
+ x = widget.position?.x ?? 0
489
+ y = widget.position?.y ?? 0
490
+ w = widget.props?.width ?? fallback.width
491
+ h = widget.props?.height ?? fallback.height
492
+ }
462
493
 
463
- // Center the widget in the viewport
464
- el.scrollLeft = wX + wW / 2 - el.clientWidth / 2
465
- el.scrollTop = wY + wH / 2 - el.clientHeight / 2
494
+ // Check JSX sources (jsx-ExportName)
495
+ if (!widget && targetId.startsWith('jsx-')) {
496
+ const exportName = targetId.slice(4)
497
+ const sourceMap = Object.fromEntries(
498
+ (localSources || []).filter((s) => s?.export).map((s) => [s.export, s])
499
+ )
500
+ const sourceData = sourceMap[exportName]
501
+ if (sourceData || (jsxExports && exportName in jsxExports)) {
502
+ const fallback = WIDGET_FALLBACK_SIZES['component']
503
+ x = sourceData?.position?.x ?? 0
504
+ y = sourceData?.position?.y ?? 0
505
+ w = sourceData?.width ?? fallback.width
506
+ h = sourceData?.height ?? fallback.height
507
+ }
508
+ }
509
+
510
+ if (x == null) return
511
+
512
+ const scale = zoomRef.current / 100
513
+ el.scrollLeft = (x + w / 2) * scale - el.clientWidth / 2
514
+ el.scrollTop = (y + h / 2) * scale - el.clientHeight / 2
466
515
 
467
516
  // Clean the URL param without triggering navigation
468
517
  const url = new URL(window.location.href)
469
518
  url.searchParams.delete('widget')
470
519
  window.history.replaceState({}, '', url.toString())
471
- }, [loading, localWidgets])
520
+ }, [loading, localWidgets, localSources, jsxExports])
472
521
 
473
522
  // Persist viewport state (zoom + scroll) to localStorage on changes
474
523
  useEffect(() => {
@@ -627,6 +676,28 @@ export default function CanvasPage({ name }) {
627
676
  return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
628
677
  }, [])
629
678
 
679
+ // Listen for snap-to-grid toggle from CoreUIBar
680
+ useEffect(() => {
681
+ function handleSnapToggle() {
682
+ setSnapEnabled((prev) => {
683
+ const next = !prev
684
+ updateCanvas(name, { snapToGrid: next }).catch((err) =>
685
+ console.error('[canvas] Failed to persist snap setting:', err)
686
+ )
687
+ return next
688
+ })
689
+ }
690
+ document.addEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
691
+ return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
692
+ }, [name])
693
+
694
+ // Broadcast snap state to Svelte toolbar
695
+ useEffect(() => {
696
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
697
+ detail: { snapEnabled }
698
+ }))
699
+ }, [snapEnabled])
700
+
630
701
  // Listen for zoom-to-fit from CoreUIBar
631
702
  useEffect(() => {
632
703
  function handleZoomToFit() {
@@ -1065,6 +1136,7 @@ export default function CanvasPage({ name }) {
1065
1136
  }}
1066
1137
  >
1067
1138
  <WidgetChrome
1139
+ widgetId={`jsx-${exportName}`}
1068
1140
  features={componentFeatures}
1069
1141
  selected={selectedWidgetId === `jsx-${exportName}`}
1070
1142
  onSelect={() => setSelectedWidgetId(`jsx-${exportName}`)}
@@ -51,6 +51,28 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate }, ref) {
51
51
  }).catch((err) => {
52
52
  console.error('[canvas] Failed to toggle image privacy:', err)
53
53
  })
54
+ } else if (actionId === 'download-image') {
55
+ if (!src) return
56
+ const url = getImageUrl(src)
57
+ const a = document.createElement('a')
58
+ a.href = url
59
+ a.download = src.replace(/^_/, '')
60
+ document.body.appendChild(a)
61
+ a.click()
62
+ document.body.removeChild(a)
63
+ } else if (actionId === 'copy-as-png') {
64
+ if (!src) return
65
+ const url = getImageUrl(src)
66
+ fetch(url)
67
+ .then((r) => r.blob())
68
+ .then((blob) => {
69
+ const pngBlob = blob.type === 'image/png' ? blob : blob
70
+ navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]).catch(() => {})
71
+ })
72
+ .catch((err) => console.error('[canvas] Failed to copy image:', err))
73
+ } else if (actionId === 'copy-file-path') {
74
+ if (!src) return
75
+ navigator.clipboard.writeText(`src/canvas/images/${src}`).catch(() => {})
54
76
  }
55
77
  }
56
78
  }), [src, onUpdate])
@@ -85,6 +85,23 @@ function LinkIcon() {
85
85
  )
86
86
  }
87
87
 
88
+ function ChevronDownIcon() {
89
+ return (
90
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
91
+ <path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z" />
92
+ </svg>
93
+ )
94
+ }
95
+
96
+ function DownloadIcon() {
97
+ return (
98
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
99
+ <path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z" />
100
+ <path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06Z" />
101
+ </svg>
102
+ )
103
+ }
104
+
88
105
  /** Icon registry — maps icon name strings from config to React components. */
89
106
  const ICON_REGISTRY = {
90
107
  'trash': DeleteIcon,
@@ -97,6 +114,8 @@ const ICON_REGISTRY = {
97
114
  'copy': CopyIcon,
98
115
  'link': LinkIcon,
99
116
  'more': MoreIcon,
117
+ 'chevron-down': ChevronDownIcon,
118
+ 'download': DownloadIcon,
100
119
  }
101
120
 
102
121
  /** Danger-styled actions in the overflow menu. */
@@ -167,6 +186,64 @@ function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
167
186
  )
168
187
  }
169
188
 
189
+ /**
190
+ * Dropdown feature — a chevron button that opens a menu of actions.
191
+ * Items and their icons/labels come from config.
192
+ */
193
+ function DropdownFeature({ feature, onAction }) {
194
+ const [open, setOpen] = useState(false)
195
+ const menuRef = useRef(null)
196
+
197
+ useEffect(() => {
198
+ if (!open) return
199
+ function handlePointerDown(e) {
200
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
201
+ setOpen(false)
202
+ }
203
+ }
204
+ document.addEventListener('pointerdown', handlePointerDown)
205
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
206
+ }, [open])
207
+
208
+ const TriggerIcon = ICON_REGISTRY[feature.icon] || ChevronDownIcon
209
+
210
+ return (
211
+ <div ref={menuRef} className={styles.overflowWrapper}>
212
+ <Tooltip text={feature.label || 'Actions'} direction="n">
213
+ <button
214
+ className={styles.featureBtn}
215
+ onClick={(e) => { e.stopPropagation(); setOpen((v) => !v) }}
216
+ aria-label={feature.label || 'Actions'}
217
+ aria-expanded={open}
218
+ >
219
+ <TriggerIcon />
220
+ </button>
221
+ </Tooltip>
222
+ {open && (
223
+ <div className={styles.overflowMenu}>
224
+ {(feature.items || []).map((item) => {
225
+ const Icon = ICON_REGISTRY[item.icon]
226
+ return (
227
+ <button
228
+ key={item.action}
229
+ className={styles.overflowItem}
230
+ onClick={(e) => {
231
+ e.stopPropagation()
232
+ onAction?.(item.action)
233
+ setOpen(false)
234
+ }}
235
+ >
236
+ {Icon && <Icon />}
237
+ <span>{item.label || item.action}</span>
238
+ </button>
239
+ )
240
+ })}
241
+ </div>
242
+ )}
243
+ </div>
244
+ )
245
+ }
246
+
170
247
  /**
171
248
  * ColorPicker feature button — shows a dot that reveals color options on hover.
172
249
  */
@@ -347,6 +424,22 @@ export default function WidgetChrome({
347
424
  )
348
425
  }
349
426
 
427
+ if (feature.type === 'dropdown') {
428
+ return (
429
+ <DropdownFeature
430
+ key={feature.id}
431
+ feature={feature}
432
+ onAction={(actionId) => {
433
+ if (widgetRef?.current?.handleAction) {
434
+ widgetRef.current.handleAction(actionId)
435
+ } else {
436
+ onAction?.(actionId)
437
+ }
438
+ }}
439
+ />
440
+ )
441
+ }
442
+
350
443
  return null
351
444
  })}
352
445
  <WidgetOverflowMenu
@@ -26,12 +26,20 @@ function resolveVar(value) {
26
26
  }
27
27
 
28
28
  /**
29
- * Resolve all string values in a feature object.
29
+ * Resolve all string values in a feature object, including nested items.
30
30
  */
31
31
  function resolveFeature(feature) {
32
32
  const resolved = {}
33
33
  for (const [key, val] of Object.entries(feature)) {
34
- resolved[key] = resolveVar(val)
34
+ if (key === 'items' && Array.isArray(val)) {
35
+ resolved[key] = val.map((item) => {
36
+ const r = {}
37
+ for (const [k, v] of Object.entries(item)) r[k] = resolveVar(v)
38
+ return r
39
+ })
40
+ } else {
41
+ resolved[key] = resolveVar(val)
42
+ }
35
43
  }
36
44
  return resolved
37
45
  }