@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.
|
|
3
|
+
"version": "3.11.0-beta.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "3.11.0-beta.
|
|
7
|
-
"@dfosco/tiny-canvas": "3.11.0-beta.
|
|
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, ...
|
|
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
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
const
|
|
461
|
-
|
|
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
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
}
|