@dfosco/storyboard-react 3.11.0-beta.0 → 3.11.0-beta.2
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 +3 -3
- package/src/canvas/CanvasControls.jsx +2 -59
- package/src/canvas/CanvasControls.module.css +0 -29
- package/src/canvas/CanvasPage.jsx +264 -17
- package/src/canvas/computeCanvasBounds.test.js +121 -0
- package/src/canvas/useUndoRedo.js +86 -0
- package/src/canvas/useUndoRedo.test.js +231 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +17 -0
- package/src/canvas/widgets/WidgetChrome.jsx +113 -15
- package/src/canvas/widgets/WidgetChrome.module.css +64 -0
- package/src/canvas/widgets/widgetConfig.js +44 -3
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.2",
|
|
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.2",
|
|
7
|
+
"@dfosco/tiny-canvas": "3.11.0-beta.2",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1"
|
|
@@ -2,16 +2,12 @@ import { useState, useRef, useEffect, useCallback } from 'react'
|
|
|
2
2
|
import { getMenuWidgetTypes } from './widgets/widgetConfig.js'
|
|
3
3
|
import styles from './CanvasControls.module.css'
|
|
4
4
|
|
|
5
|
-
const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200]
|
|
6
|
-
export const ZOOM_MIN = ZOOM_STEPS[0]
|
|
7
|
-
export const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1]
|
|
8
|
-
|
|
9
5
|
const WIDGET_TYPES = getMenuWidgetTypes()
|
|
10
6
|
|
|
11
7
|
/**
|
|
12
|
-
* Focused canvas toolbar — bottom-left
|
|
8
|
+
* Focused canvas toolbar — bottom-left add-widget control.
|
|
13
9
|
*/
|
|
14
|
-
export default function CanvasControls({
|
|
10
|
+
export default function CanvasControls({ onAddWidget }) {
|
|
15
11
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
16
12
|
const menuRef = useRef(null)
|
|
17
13
|
|
|
@@ -27,24 +23,6 @@ export default function CanvasControls({ zoom, onZoomChange, onAddWidget }) {
|
|
|
27
23
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
28
24
|
}, [menuOpen])
|
|
29
25
|
|
|
30
|
-
const zoomIn = useCallback(() => {
|
|
31
|
-
onZoomChange((z) => {
|
|
32
|
-
const next = ZOOM_STEPS.find((s) => s > z)
|
|
33
|
-
return next ?? ZOOM_MAX
|
|
34
|
-
})
|
|
35
|
-
}, [onZoomChange])
|
|
36
|
-
|
|
37
|
-
const zoomOut = useCallback(() => {
|
|
38
|
-
onZoomChange((z) => {
|
|
39
|
-
const next = [...ZOOM_STEPS].reverse().find((s) => s < z)
|
|
40
|
-
return next ?? ZOOM_MIN
|
|
41
|
-
})
|
|
42
|
-
}, [onZoomChange])
|
|
43
|
-
|
|
44
|
-
const resetZoom = useCallback(() => {
|
|
45
|
-
onZoomChange(100)
|
|
46
|
-
}, [onZoomChange])
|
|
47
|
-
|
|
48
26
|
const handleAddWidget = useCallback((type) => {
|
|
49
27
|
onAddWidget(type)
|
|
50
28
|
setMenuOpen(false)
|
|
@@ -52,7 +30,6 @@ export default function CanvasControls({ zoom, onZoomChange, onAddWidget }) {
|
|
|
52
30
|
|
|
53
31
|
return (
|
|
54
32
|
<div className={styles.toolbar} role="toolbar" aria-label="Canvas controls">
|
|
55
|
-
{/* Create widget */}
|
|
56
33
|
<div ref={menuRef} className={styles.createGroup}>
|
|
57
34
|
<button
|
|
58
35
|
className={styles.btn}
|
|
@@ -81,40 +58,6 @@ export default function CanvasControls({ zoom, onZoomChange, onAddWidget }) {
|
|
|
81
58
|
</div>
|
|
82
59
|
)}
|
|
83
60
|
</div>
|
|
84
|
-
|
|
85
|
-
<div className={styles.divider} />
|
|
86
|
-
|
|
87
|
-
{/* Zoom controls */}
|
|
88
|
-
<button
|
|
89
|
-
className={styles.btn}
|
|
90
|
-
onClick={zoomOut}
|
|
91
|
-
disabled={zoom <= ZOOM_MIN}
|
|
92
|
-
aria-label="Zoom out"
|
|
93
|
-
title="Zoom out"
|
|
94
|
-
>
|
|
95
|
-
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
96
|
-
<path d="M2.75 7.25h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5Z" />
|
|
97
|
-
</svg>
|
|
98
|
-
</button>
|
|
99
|
-
<button
|
|
100
|
-
className={styles.zoomLevel}
|
|
101
|
-
onClick={resetZoom}
|
|
102
|
-
title="Reset to 100%"
|
|
103
|
-
aria-label={`Zoom ${zoom}%, click to reset`}
|
|
104
|
-
>
|
|
105
|
-
{zoom}%
|
|
106
|
-
</button>
|
|
107
|
-
<button
|
|
108
|
-
className={styles.btn}
|
|
109
|
-
onClick={zoomIn}
|
|
110
|
-
disabled={zoom >= ZOOM_MAX}
|
|
111
|
-
aria-label="Zoom in"
|
|
112
|
-
title="Zoom in"
|
|
113
|
-
>
|
|
114
|
-
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
115
|
-
<path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z" />
|
|
116
|
-
</svg>
|
|
117
|
-
</button>
|
|
118
61
|
</div>
|
|
119
62
|
)
|
|
120
63
|
}
|
|
@@ -50,35 +50,6 @@
|
|
|
50
50
|
cursor: default;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
.zoomLevel {
|
|
54
|
-
all: unset;
|
|
55
|
-
cursor: pointer;
|
|
56
|
-
display: flex;
|
|
57
|
-
align-items: center;
|
|
58
|
-
justify-content: center;
|
|
59
|
-
min-width: 44px;
|
|
60
|
-
height: 32px;
|
|
61
|
-
padding: 0 4px;
|
|
62
|
-
border-radius: 8px;
|
|
63
|
-
font-size: 12px;
|
|
64
|
-
font-weight: 500;
|
|
65
|
-
font-variant-numeric: tabular-nums;
|
|
66
|
-
color: var(--fgColor-muted, #656d76);
|
|
67
|
-
transition: background 120ms;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
.zoomLevel:hover {
|
|
71
|
-
background: var(--bgColor-muted, #f6f8fa);
|
|
72
|
-
color: var(--fgColor-default, #1f2328);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
.divider {
|
|
76
|
-
width: 1px;
|
|
77
|
-
height: 20px;
|
|
78
|
-
margin: 0 2px;
|
|
79
|
-
background: var(--borderColor-muted, #d8dee4);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
53
|
/* Create widget menu */
|
|
83
54
|
.createGroup {
|
|
84
55
|
position: relative;
|
|
@@ -11,6 +11,7 @@ import { getFeatures } from './widgets/widgetConfig.js'
|
|
|
11
11
|
import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
|
|
12
12
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
13
13
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
14
|
+
import useUndoRedo from './useUndoRedo.js'
|
|
14
15
|
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
|
|
15
16
|
import styles from './CanvasPage.module.css'
|
|
16
17
|
|
|
@@ -48,13 +49,16 @@ function resolveCanvasThemeFromStorage() {
|
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* Debounce helper — returns a function that delays invocation.
|
|
52
|
+
* Exposes `.cancel()` to abort pending calls (used by undo/redo).
|
|
51
53
|
*/
|
|
52
54
|
function debounce(fn, ms) {
|
|
53
55
|
let timer
|
|
54
|
-
|
|
56
|
+
const debounced = (...args) => {
|
|
55
57
|
clearTimeout(timer)
|
|
56
58
|
timer = setTimeout(() => fn(...args), ms)
|
|
57
59
|
}
|
|
60
|
+
debounced.cancel = () => clearTimeout(timer)
|
|
61
|
+
return debounced
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
/** Per-canvas viewport state persistence (zoom + scroll position). */
|
|
@@ -99,8 +103,8 @@ function getViewportCenter(scrollEl, scale) {
|
|
|
99
103
|
|
|
100
104
|
/** Fallback sizes for widget types without explicit width/height defaults. */
|
|
101
105
|
const WIDGET_FALLBACK_SIZES = {
|
|
102
|
-
'sticky-note': { width:
|
|
103
|
-
'markdown': { width:
|
|
106
|
+
'sticky-note': { width: 270, height: 170 },
|
|
107
|
+
'markdown': { width: 530, height: 240 },
|
|
104
108
|
'prototype': { width: 800, height: 600 },
|
|
105
109
|
'link-preview': { width: 320, height: 120 },
|
|
106
110
|
'figma-embed': { width: 800, height: 450 },
|
|
@@ -126,6 +130,57 @@ function roundPosition(value) {
|
|
|
126
130
|
return Math.round(value)
|
|
127
131
|
}
|
|
128
132
|
|
|
133
|
+
/** Padding (canvas-space pixels) around bounding box for zoom-to-fit. */
|
|
134
|
+
const FIT_PADDING = 48
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Compute the axis-aligned bounding box that contains every widget and source.
|
|
138
|
+
* Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
|
|
139
|
+
*/
|
|
140
|
+
function computeCanvasBounds(widgets, sources, jsxExports) {
|
|
141
|
+
let minX = Infinity
|
|
142
|
+
let minY = Infinity
|
|
143
|
+
let maxX = -Infinity
|
|
144
|
+
let maxY = -Infinity
|
|
145
|
+
let hasItems = false
|
|
146
|
+
|
|
147
|
+
// JSON widgets
|
|
148
|
+
for (const w of (widgets ?? [])) {
|
|
149
|
+
const x = w?.position?.x ?? 0
|
|
150
|
+
const y = w?.position?.y ?? 0
|
|
151
|
+
const fallback = WIDGET_FALLBACK_SIZES[w.type] || { width: 200, height: 150 }
|
|
152
|
+
const width = w.props?.width ?? fallback.width
|
|
153
|
+
const height = w.props?.height ?? fallback.height
|
|
154
|
+
minX = Math.min(minX, x)
|
|
155
|
+
minY = Math.min(minY, y)
|
|
156
|
+
maxX = Math.max(maxX, x + width)
|
|
157
|
+
maxY = Math.max(maxY, y + height)
|
|
158
|
+
hasItems = true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// JSX sources
|
|
162
|
+
const sourceMap = Object.fromEntries(
|
|
163
|
+
(sources || []).filter((s) => s?.export).map((s) => [s.export, s])
|
|
164
|
+
)
|
|
165
|
+
if (jsxExports) {
|
|
166
|
+
for (const exportName of Object.keys(jsxExports)) {
|
|
167
|
+
const sourceData = sourceMap[exportName] || {}
|
|
168
|
+
const x = sourceData.position?.x ?? 0
|
|
169
|
+
const y = sourceData.position?.y ?? 0
|
|
170
|
+
const fallback = WIDGET_FALLBACK_SIZES['component']
|
|
171
|
+
const width = sourceData.width ?? fallback.width
|
|
172
|
+
const height = sourceData.height ?? fallback.height
|
|
173
|
+
minX = Math.min(minX, x)
|
|
174
|
+
minY = Math.min(minY, y)
|
|
175
|
+
maxX = Math.max(maxX, x + width)
|
|
176
|
+
maxY = Math.max(maxY, y + height)
|
|
177
|
+
hasItems = true
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return hasItems ? { minX, minY, maxX, maxY } : null
|
|
182
|
+
}
|
|
183
|
+
|
|
129
184
|
/** Renders a single JSON-defined widget by type lookup. */
|
|
130
185
|
function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
131
186
|
const Component = getWidgetComponent(widget.type)
|
|
@@ -152,6 +207,7 @@ function ChromeWrappedWidget({
|
|
|
152
207
|
onDeselect,
|
|
153
208
|
onUpdate,
|
|
154
209
|
onRemove,
|
|
210
|
+
onCopy,
|
|
155
211
|
}) {
|
|
156
212
|
const widgetRef = useRef(null)
|
|
157
213
|
const features = getFeatures(widget.type)
|
|
@@ -159,8 +215,10 @@ function ChromeWrappedWidget({
|
|
|
159
215
|
const handleAction = useCallback((actionId) => {
|
|
160
216
|
if (actionId === 'delete') {
|
|
161
217
|
onRemove(widget.id)
|
|
218
|
+
} else if (actionId === 'copy') {
|
|
219
|
+
onCopy(widget)
|
|
162
220
|
}
|
|
163
|
-
}, [widget
|
|
221
|
+
}, [widget, onRemove, onCopy])
|
|
164
222
|
|
|
165
223
|
return (
|
|
166
224
|
<WidgetChrome
|
|
@@ -207,11 +265,28 @@ export default function CanvasPage({ name }) {
|
|
|
207
265
|
const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
|
|
208
266
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
209
267
|
|
|
268
|
+
// Undo/redo history — tracks both widgets and sources as a combined snapshot
|
|
269
|
+
const undoRedo = useUndoRedo()
|
|
270
|
+
const stateRef = useRef({ widgets: localWidgets, sources: localSources })
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
stateRef.current = { widgets: localWidgets, sources: localSources }
|
|
273
|
+
}, [localWidgets, localSources])
|
|
274
|
+
|
|
275
|
+
// Serialized write queue — ensures JSONL events land in the right order
|
|
276
|
+
const writeQueueRef = useRef(Promise.resolve())
|
|
277
|
+
function queueWrite(fn) {
|
|
278
|
+
writeQueueRef.current = writeQueueRef.current.then(fn).catch((err) =>
|
|
279
|
+
console.error('[canvas] Write queue error:', err)
|
|
280
|
+
)
|
|
281
|
+
return writeQueueRef.current
|
|
282
|
+
}
|
|
283
|
+
|
|
210
284
|
if (canvas !== trackedCanvas) {
|
|
211
285
|
setTrackedCanvas(canvas)
|
|
212
286
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
213
287
|
setLocalSources(canvas?.sources ?? [])
|
|
214
288
|
setCanvasTitle(canvas?.title || name)
|
|
289
|
+
undoRedo.reset()
|
|
215
290
|
}
|
|
216
291
|
|
|
217
292
|
// Debounced save to server
|
|
@@ -245,6 +320,7 @@ export default function CanvasPage({ name }) {
|
|
|
245
320
|
}, [])
|
|
246
321
|
|
|
247
322
|
const handleWidgetUpdate = useCallback((widgetId, updates) => {
|
|
323
|
+
undoRedo.snapshot(stateRef.current, 'edit', widgetId)
|
|
248
324
|
setLocalWidgets((prev) => {
|
|
249
325
|
if (!prev) return prev
|
|
250
326
|
const next = prev.map((w) =>
|
|
@@ -253,14 +329,44 @@ export default function CanvasPage({ name }) {
|
|
|
253
329
|
debouncedSave(name, next)
|
|
254
330
|
return next
|
|
255
331
|
})
|
|
256
|
-
}, [name, debouncedSave])
|
|
332
|
+
}, [name, debouncedSave, undoRedo])
|
|
257
333
|
|
|
258
334
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
335
|
+
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
259
336
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
260
|
-
|
|
261
|
-
|
|
337
|
+
queueWrite(() =>
|
|
338
|
+
removeWidgetApi(name, widgetId).catch((err) =>
|
|
339
|
+
console.error('[canvas] Failed to remove widget:', err)
|
|
340
|
+
)
|
|
262
341
|
)
|
|
263
|
-
}, [name])
|
|
342
|
+
}, [name, undoRedo])
|
|
343
|
+
|
|
344
|
+
const handleWidgetCopy = useCallback(async (widget) => {
|
|
345
|
+
// Find the next free offset — check how many copies already exist at +n*40
|
|
346
|
+
const baseX = widget.position?.x ?? 0
|
|
347
|
+
const baseY = widget.position?.y ?? 0
|
|
348
|
+
const occupied = new Set(
|
|
349
|
+
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
350
|
+
)
|
|
351
|
+
let n = 1
|
|
352
|
+
while (occupied.has(`${baseX + n * 40},${baseY + n * 40}`)) {
|
|
353
|
+
n++
|
|
354
|
+
}
|
|
355
|
+
const position = { x: baseX + n * 40, y: baseY + n * 40 }
|
|
356
|
+
try {
|
|
357
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
358
|
+
const result = await addWidgetApi(name, {
|
|
359
|
+
type: widget.type,
|
|
360
|
+
props: { ...widget.props },
|
|
361
|
+
position,
|
|
362
|
+
})
|
|
363
|
+
if (result.success && result.widget) {
|
|
364
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
365
|
+
}
|
|
366
|
+
} catch (err) {
|
|
367
|
+
console.error('[canvas] Failed to copy widget:', err)
|
|
368
|
+
}
|
|
369
|
+
}, [name, localWidgets, undoRedo])
|
|
264
370
|
|
|
265
371
|
const debouncedSourceSave = useRef(
|
|
266
372
|
debounce((canvasName, sources) => {
|
|
@@ -271,6 +377,7 @@ export default function CanvasPage({ name }) {
|
|
|
271
377
|
).current
|
|
272
378
|
|
|
273
379
|
const handleSourceUpdate = useCallback((exportName, updates) => {
|
|
380
|
+
undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
|
|
274
381
|
setLocalSources((prev) => {
|
|
275
382
|
const current = Array.isArray(prev) ? prev : []
|
|
276
383
|
const next = current.some((s) => s?.export === exportName)
|
|
@@ -279,38 +386,44 @@ export default function CanvasPage({ name }) {
|
|
|
279
386
|
debouncedSourceSave(name, next)
|
|
280
387
|
return next
|
|
281
388
|
})
|
|
282
|
-
}, [name, debouncedSourceSave])
|
|
389
|
+
}, [name, debouncedSourceSave, undoRedo])
|
|
283
390
|
|
|
284
391
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
285
392
|
if (!dragId || !position) return
|
|
286
393
|
const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
|
|
287
394
|
|
|
288
395
|
if (dragId.startsWith('jsx-')) {
|
|
396
|
+
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
289
397
|
const sourceExport = dragId.replace(/^jsx-/, '')
|
|
290
398
|
setLocalSources((prev) => {
|
|
291
399
|
const current = Array.isArray(prev) ? prev : []
|
|
292
400
|
const next = current.some((s) => s?.export === sourceExport)
|
|
293
401
|
? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
|
|
294
402
|
: [...current, { export: sourceExport, position: rounded }]
|
|
295
|
-
|
|
296
|
-
|
|
403
|
+
queueWrite(() =>
|
|
404
|
+
updateCanvas(name, { sources: next }).catch((err) =>
|
|
405
|
+
console.error('[canvas] Failed to save source position:', err)
|
|
406
|
+
)
|
|
297
407
|
)
|
|
298
408
|
return next
|
|
299
409
|
})
|
|
300
410
|
return
|
|
301
411
|
}
|
|
302
412
|
|
|
413
|
+
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
303
414
|
setLocalWidgets((prev) => {
|
|
304
415
|
if (!prev) return prev
|
|
305
416
|
const next = prev.map((w) =>
|
|
306
417
|
w.id === dragId ? { ...w, position: rounded } : w
|
|
307
418
|
)
|
|
308
|
-
|
|
309
|
-
|
|
419
|
+
queueWrite(() =>
|
|
420
|
+
updateCanvas(name, { widgets: next }).catch((err) =>
|
|
421
|
+
console.error('[canvas] Failed to save widget position:', err)
|
|
422
|
+
)
|
|
310
423
|
)
|
|
311
424
|
return next
|
|
312
425
|
})
|
|
313
|
-
}, [name])
|
|
426
|
+
}, [name, undoRedo])
|
|
314
427
|
|
|
315
428
|
useEffect(() => {
|
|
316
429
|
zoomRef.current = zoom
|
|
@@ -327,6 +440,36 @@ export default function CanvasPage({ name }) {
|
|
|
327
440
|
}
|
|
328
441
|
}, [name, loading])
|
|
329
442
|
|
|
443
|
+
// Center on a specific widget if `?widget=<id>` is in the URL
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
const params = new URLSearchParams(window.location.search)
|
|
446
|
+
const targetId = params.get('widget')
|
|
447
|
+
if (!targetId || loading) return
|
|
448
|
+
|
|
449
|
+
const widgets = localWidgets ?? []
|
|
450
|
+
const widget = widgets.find((w) => w.id === targetId)
|
|
451
|
+
if (!widget) return
|
|
452
|
+
|
|
453
|
+
const el = scrollRef.current
|
|
454
|
+
if (!el) return
|
|
455
|
+
|
|
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
|
|
462
|
+
|
|
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
|
|
466
|
+
|
|
467
|
+
// Clean the URL param without triggering navigation
|
|
468
|
+
const url = new URL(window.location.href)
|
|
469
|
+
url.searchParams.delete('widget')
|
|
470
|
+
window.history.replaceState({}, '', url.toString())
|
|
471
|
+
}, [loading, localWidgets])
|
|
472
|
+
|
|
330
473
|
// Persist viewport state (zoom + scroll) to localStorage on changes
|
|
331
474
|
useEffect(() => {
|
|
332
475
|
const el = scrollRef.current
|
|
@@ -455,12 +598,13 @@ export default function CanvasPage({ name }) {
|
|
|
455
598
|
position: pos,
|
|
456
599
|
})
|
|
457
600
|
if (result.success && result.widget) {
|
|
601
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
458
602
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
459
603
|
}
|
|
460
604
|
} catch (err) {
|
|
461
605
|
console.error('[canvas] Failed to add widget:', err)
|
|
462
606
|
}
|
|
463
|
-
}, [name])
|
|
607
|
+
}, [name, undoRedo])
|
|
464
608
|
|
|
465
609
|
// Listen for CoreUIBar add-widget events
|
|
466
610
|
useEffect(() => {
|
|
@@ -483,6 +627,38 @@ export default function CanvasPage({ name }) {
|
|
|
483
627
|
return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
|
|
484
628
|
}, [])
|
|
485
629
|
|
|
630
|
+
// Listen for zoom-to-fit from CoreUIBar
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
function handleZoomToFit() {
|
|
633
|
+
const el = scrollRef.current
|
|
634
|
+
if (!el) return
|
|
635
|
+
|
|
636
|
+
const bounds = computeCanvasBounds(localWidgets, localSources, jsxExports)
|
|
637
|
+
if (!bounds) return
|
|
638
|
+
|
|
639
|
+
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
640
|
+
const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
|
|
641
|
+
|
|
642
|
+
const viewW = el.clientWidth
|
|
643
|
+
const viewH = el.clientHeight
|
|
644
|
+
|
|
645
|
+
// Find the zoom level that fits the bounding box in the viewport
|
|
646
|
+
const fitScale = Math.min(viewW / boxW, viewH / boxH)
|
|
647
|
+
const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
|
|
648
|
+
const newScale = fitZoom / 100
|
|
649
|
+
|
|
650
|
+
// Apply zoom synchronously so DOM updates before we scroll
|
|
651
|
+
zoomRef.current = fitZoom
|
|
652
|
+
flushSync(() => setZoom(fitZoom))
|
|
653
|
+
|
|
654
|
+
// Scroll so the bounding box top-left (with padding) is at viewport top-left
|
|
655
|
+
el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
|
|
656
|
+
el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
|
|
657
|
+
}
|
|
658
|
+
document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
659
|
+
return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
660
|
+
}, [localWidgets, localSources, jsxExports])
|
|
661
|
+
|
|
486
662
|
// Canvas background should follow toolbar theme target.
|
|
487
663
|
useEffect(() => {
|
|
488
664
|
function readMode() {
|
|
@@ -621,6 +797,7 @@ export default function CanvasPage({ name }) {
|
|
|
621
797
|
position: pos,
|
|
622
798
|
})
|
|
623
799
|
if (result.success && result.widget) {
|
|
800
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
624
801
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
625
802
|
}
|
|
626
803
|
} catch (err) {
|
|
@@ -654,7 +831,7 @@ export default function CanvasPage({ name }) {
|
|
|
654
831
|
const pathPortion = parsed.pathname + parsed.search + parsed.hash
|
|
655
832
|
const src = extractPrototypeSrc(pathPortion)
|
|
656
833
|
type = 'prototype'
|
|
657
|
-
props = { src: src || '/', label: '', width: 800, height: 600 }
|
|
834
|
+
props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
|
|
658
835
|
} else {
|
|
659
836
|
type = 'link-preview'
|
|
660
837
|
props = { url: text, title: '' }
|
|
@@ -673,6 +850,7 @@ export default function CanvasPage({ name }) {
|
|
|
673
850
|
position: pos,
|
|
674
851
|
})
|
|
675
852
|
if (result.success && result.widget) {
|
|
853
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
676
854
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
677
855
|
}
|
|
678
856
|
} catch (err) {
|
|
@@ -681,7 +859,75 @@ export default function CanvasPage({ name }) {
|
|
|
681
859
|
}
|
|
682
860
|
document.addEventListener('paste', handlePaste)
|
|
683
861
|
return () => document.removeEventListener('paste', handlePaste)
|
|
684
|
-
}, [name])
|
|
862
|
+
}, [name, undoRedo])
|
|
863
|
+
|
|
864
|
+
// --- Undo / Redo ---
|
|
865
|
+
const handleUndo = useCallback(() => {
|
|
866
|
+
const previous = undoRedo.undo(stateRef.current)
|
|
867
|
+
if (!previous) return
|
|
868
|
+
debouncedSave.cancel()
|
|
869
|
+
debouncedSourceSave.cancel()
|
|
870
|
+
setLocalWidgets(previous.widgets)
|
|
871
|
+
setLocalSources(previous.sources)
|
|
872
|
+
queueWrite(() =>
|
|
873
|
+
updateCanvas(name, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
|
|
874
|
+
console.error('[canvas] Failed to persist undo:', err)
|
|
875
|
+
)
|
|
876
|
+
)
|
|
877
|
+
}, [name, debouncedSave, debouncedSourceSave, undoRedo])
|
|
878
|
+
|
|
879
|
+
const handleRedo = useCallback(() => {
|
|
880
|
+
const next = undoRedo.redo(stateRef.current)
|
|
881
|
+
if (!next) return
|
|
882
|
+
debouncedSave.cancel()
|
|
883
|
+
debouncedSourceSave.cancel()
|
|
884
|
+
setLocalWidgets(next.widgets)
|
|
885
|
+
setLocalSources(next.sources)
|
|
886
|
+
queueWrite(() =>
|
|
887
|
+
updateCanvas(name, { widgets: next.widgets, sources: next.sources }).catch((err) =>
|
|
888
|
+
console.error('[canvas] Failed to persist redo:', err)
|
|
889
|
+
)
|
|
890
|
+
)
|
|
891
|
+
}, [name, debouncedSave, debouncedSourceSave, undoRedo])
|
|
892
|
+
|
|
893
|
+
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
|
|
894
|
+
useEffect(() => {
|
|
895
|
+
if (!import.meta.hot) return
|
|
896
|
+
function handleKeyDown(e) {
|
|
897
|
+
const tag = e.target.tagName
|
|
898
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
899
|
+
const mod = e.metaKey || e.ctrlKey
|
|
900
|
+
if (mod && e.key === 'z' && !e.shiftKey) {
|
|
901
|
+
e.preventDefault()
|
|
902
|
+
handleUndo()
|
|
903
|
+
}
|
|
904
|
+
if (mod && e.key === 'z' && e.shiftKey) {
|
|
905
|
+
e.preventDefault()
|
|
906
|
+
handleRedo()
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
910
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
911
|
+
}, [handleUndo, handleRedo])
|
|
912
|
+
|
|
913
|
+
// Listen for undo/redo from CoreUIBar (Svelte toolbar)
|
|
914
|
+
useEffect(() => {
|
|
915
|
+
function handleUndoEvent() { handleUndo() }
|
|
916
|
+
function handleRedoEvent() { handleRedo() }
|
|
917
|
+
document.addEventListener('storyboard:canvas:undo', handleUndoEvent)
|
|
918
|
+
document.addEventListener('storyboard:canvas:redo', handleRedoEvent)
|
|
919
|
+
return () => {
|
|
920
|
+
document.removeEventListener('storyboard:canvas:undo', handleUndoEvent)
|
|
921
|
+
document.removeEventListener('storyboard:canvas:redo', handleRedoEvent)
|
|
922
|
+
}
|
|
923
|
+
}, [handleUndo, handleRedo])
|
|
924
|
+
|
|
925
|
+
// Broadcast undo/redo availability to Svelte toolbar
|
|
926
|
+
useEffect(() => {
|
|
927
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:undo-redo-state', {
|
|
928
|
+
detail: { canUndo: undoRedo.canUndo, canRedo: undoRedo.canRedo }
|
|
929
|
+
}))
|
|
930
|
+
}, [undoRedo.canUndo, undoRedo.canRedo])
|
|
685
931
|
|
|
686
932
|
// Cmd+scroll / trackpad pinch to smooth-zoom the canvas
|
|
687
933
|
// On macOS, pinch-to-zoom fires wheel events with ctrlKey: true and small
|
|
@@ -858,6 +1104,7 @@ export default function CanvasPage({ name }) {
|
|
|
858
1104
|
onSelect={() => setSelectedWidgetId(widget.id)}
|
|
859
1105
|
onDeselect={() => setSelectedWidgetId(null)}
|
|
860
1106
|
onUpdate={handleWidgetUpdate}
|
|
1107
|
+
onCopy={handleWidgetCopy}
|
|
861
1108
|
onRemove={(id) => {
|
|
862
1109
|
handleWidgetRemove(id)
|
|
863
1110
|
setSelectedWidgetId(null)
|