@dfosco/storyboard-react 3.11.1-beta.0 → 3.11.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/Viewfinder.jsx +5 -3
- package/src/__mocks__/virtual-storyboard-data-index.js +1 -0
- package/src/canvas/CanvasControls.jsx +2 -59
- package/src/canvas/CanvasControls.module.css +0 -29
- package/src/canvas/CanvasPage.bridge.test.jsx +68 -42
- package/src/canvas/CanvasPage.jsx +801 -68
- package/src/canvas/CanvasPage.module.css +47 -2
- package/src/canvas/CanvasPage.multiselect.test.jsx +345 -0
- package/src/canvas/canvasApi.js +8 -0
- package/src/canvas/computeCanvasBounds.test.js +121 -0
- package/src/canvas/useCanvas.js +2 -1
- package/src/canvas/useUndoRedo.js +86 -0
- package/src/canvas/useUndoRedo.test.js +231 -0
- package/src/canvas/widgets/ComponentWidget.jsx +9 -7
- package/src/canvas/widgets/FigmaEmbed.jsx +195 -0
- package/src/canvas/widgets/FigmaEmbed.module.css +147 -0
- package/src/canvas/widgets/ImageWidget.jsx +115 -0
- package/src/canvas/widgets/ImageWidget.module.css +39 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +25 -8
- package/src/canvas/widgets/MarkdownBlock.test.jsx +53 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +132 -26
- package/src/canvas/widgets/PrototypeEmbed.module.css +66 -2
- package/src/canvas/widgets/StickyNote.jsx +21 -16
- package/src/canvas/widgets/StickyNote.test.jsx +24 -4
- package/src/canvas/widgets/WidgetChrome.jsx +276 -50
- package/src/canvas/widgets/WidgetChrome.module.css +91 -10
- package/src/canvas/widgets/figmaUrl.js +118 -0
- package/src/canvas/widgets/figmaUrl.test.js +139 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/widgetConfig.js +74 -6
- package/src/canvas/widgets/widgetConfig.test.js +46 -0
- package/src/canvas/widgets/widgetProps.js +2 -0
- package/src/context.jsx +34 -4
- package/src/context.test.jsx +13 -0
|
@@ -7,10 +7,12 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
|
|
|
7
7
|
import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
|
|
8
8
|
import { getWidgetComponent } from './widgets/index.js'
|
|
9
9
|
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
10
|
-
import { getFeatures } from './widgets/widgetConfig.js'
|
|
10
|
+
import { getFeatures, isResizable } from './widgets/widgetConfig.js'
|
|
11
|
+
import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
|
|
11
12
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
12
13
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
13
|
-
import
|
|
14
|
+
import useUndoRedo from './useUndoRedo.js'
|
|
15
|
+
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
|
|
14
16
|
import styles from './CanvasPage.module.css'
|
|
15
17
|
|
|
16
18
|
const ZOOM_MIN = 25
|
|
@@ -47,13 +49,40 @@ function resolveCanvasThemeFromStorage() {
|
|
|
47
49
|
|
|
48
50
|
/**
|
|
49
51
|
* Debounce helper — returns a function that delays invocation.
|
|
52
|
+
* Exposes `.cancel()` to abort pending calls (used by undo/redo).
|
|
50
53
|
*/
|
|
51
54
|
function debounce(fn, ms) {
|
|
52
55
|
let timer
|
|
53
|
-
|
|
56
|
+
const debounced = (...args) => {
|
|
54
57
|
clearTimeout(timer)
|
|
55
58
|
timer = setTimeout(() => fn(...args), ms)
|
|
56
59
|
}
|
|
60
|
+
debounced.cancel = () => clearTimeout(timer)
|
|
61
|
+
return debounced
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Per-canvas viewport state persistence (zoom + scroll position). */
|
|
65
|
+
function getViewportStorageKey(canvasName) {
|
|
66
|
+
return `sb-canvas-viewport:${canvasName}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadViewportState(canvasName) {
|
|
70
|
+
try {
|
|
71
|
+
const raw = localStorage.getItem(getViewportStorageKey(canvasName))
|
|
72
|
+
if (!raw) return null
|
|
73
|
+
const state = JSON.parse(raw)
|
|
74
|
+
return {
|
|
75
|
+
zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
|
|
76
|
+
scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
|
|
77
|
+
scrollTop: typeof state.scrollTop === 'number' ? state.scrollTop : null,
|
|
78
|
+
}
|
|
79
|
+
} catch { return null }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function saveViewportState(canvasName, state) {
|
|
83
|
+
try {
|
|
84
|
+
localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify(state))
|
|
85
|
+
} catch { /* quota exceeded — non-critical */ }
|
|
57
86
|
}
|
|
58
87
|
|
|
59
88
|
/**
|
|
@@ -74,11 +103,13 @@ function getViewportCenter(scrollEl, scale) {
|
|
|
74
103
|
|
|
75
104
|
/** Fallback sizes for widget types without explicit width/height defaults. */
|
|
76
105
|
const WIDGET_FALLBACK_SIZES = {
|
|
77
|
-
'sticky-note': { width:
|
|
78
|
-
'markdown': { width:
|
|
106
|
+
'sticky-note': { width: 270, height: 170 },
|
|
107
|
+
'markdown': { width: 530, height: 240 },
|
|
79
108
|
'prototype': { width: 800, height: 600 },
|
|
80
109
|
'link-preview': { width: 320, height: 120 },
|
|
110
|
+
'figma-embed': { width: 800, height: 450 },
|
|
81
111
|
'component': { width: 200, height: 150 },
|
|
112
|
+
'image': { width: 400, height: 300 },
|
|
82
113
|
}
|
|
83
114
|
|
|
84
115
|
/**
|
|
@@ -99,6 +130,78 @@ function roundPosition(value) {
|
|
|
99
130
|
return Math.round(value)
|
|
100
131
|
}
|
|
101
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
|
+
// eslint-disable-next-line no-unused-vars
|
|
140
|
+
function snapPosition(pos, gridSize, enabled) {
|
|
141
|
+
if (!enabled || !gridSize) return pos
|
|
142
|
+
return {
|
|
143
|
+
x: Math.max(0, snapValue(pos.x, gridSize)),
|
|
144
|
+
y: Math.max(0, snapValue(pos.y, gridSize)),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Snap a dimension to the grid if snapping is enabled. */
|
|
149
|
+
function snapDimension(value, gridSize, enabled, min = 0) {
|
|
150
|
+
if (!enabled || !gridSize) return value
|
|
151
|
+
return Math.max(min, snapValue(value, gridSize))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Padding (canvas-space pixels) around bounding box for zoom-to-fit. */
|
|
155
|
+
const FIT_PADDING = 48
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Compute the axis-aligned bounding box that contains every widget and source.
|
|
159
|
+
* Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
|
|
160
|
+
*/
|
|
161
|
+
function computeCanvasBounds(widgets, sources, jsxExports) {
|
|
162
|
+
let minX = Infinity
|
|
163
|
+
let minY = Infinity
|
|
164
|
+
let maxX = -Infinity
|
|
165
|
+
let maxY = -Infinity
|
|
166
|
+
let hasItems = false
|
|
167
|
+
|
|
168
|
+
// JSON widgets
|
|
169
|
+
for (const w of (widgets ?? [])) {
|
|
170
|
+
const x = w?.position?.x ?? 0
|
|
171
|
+
const y = w?.position?.y ?? 0
|
|
172
|
+
const fallback = WIDGET_FALLBACK_SIZES[w.type] || { width: 200, height: 150 }
|
|
173
|
+
const width = w.props?.width ?? fallback.width
|
|
174
|
+
const height = w.props?.height ?? fallback.height
|
|
175
|
+
minX = Math.min(minX, x)
|
|
176
|
+
minY = Math.min(minY, y)
|
|
177
|
+
maxX = Math.max(maxX, x + width)
|
|
178
|
+
maxY = Math.max(maxY, y + height)
|
|
179
|
+
hasItems = true
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// JSX sources
|
|
183
|
+
const sourceMap = Object.fromEntries(
|
|
184
|
+
(sources || []).filter((s) => s?.export).map((s) => [s.export, s])
|
|
185
|
+
)
|
|
186
|
+
if (jsxExports) {
|
|
187
|
+
for (const exportName of Object.keys(jsxExports)) {
|
|
188
|
+
const sourceData = sourceMap[exportName] || {}
|
|
189
|
+
const x = sourceData.position?.x ?? 0
|
|
190
|
+
const y = sourceData.position?.y ?? 0
|
|
191
|
+
const fallback = WIDGET_FALLBACK_SIZES['component']
|
|
192
|
+
const width = sourceData.width ?? fallback.width
|
|
193
|
+
const height = sourceData.height ?? fallback.height
|
|
194
|
+
minX = Math.min(minX, x)
|
|
195
|
+
minY = Math.min(minY, y)
|
|
196
|
+
maxX = Math.max(maxX, x + width)
|
|
197
|
+
maxY = Math.max(maxY, y + height)
|
|
198
|
+
hasItems = true
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return hasItems ? { minX, minY, maxX, maxY } : null
|
|
203
|
+
}
|
|
204
|
+
|
|
102
205
|
/** Renders a single JSON-defined widget by type lookup. */
|
|
103
206
|
function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
104
207
|
const Component = getWidgetComponent(widget.type)
|
|
@@ -106,8 +209,9 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
|
106
209
|
console.warn(`[canvas] Unknown widget type: ${widget.type}`)
|
|
107
210
|
return null
|
|
108
211
|
}
|
|
212
|
+
const resizable = isResizable(widget.type) && !!onUpdate
|
|
109
213
|
// Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
|
|
110
|
-
const elementProps = { id: widget.id, props: widget.props, onUpdate }
|
|
214
|
+
const elementProps = { id: widget.id, props: widget.props, onUpdate, resizable }
|
|
111
215
|
if (Component.$$typeof === Symbol.for('react.forward_ref')) {
|
|
112
216
|
elementProps.ref = widgetRef
|
|
113
217
|
}
|
|
@@ -121,19 +225,24 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
|
121
225
|
function ChromeWrappedWidget({
|
|
122
226
|
widget,
|
|
123
227
|
selected,
|
|
228
|
+
multiSelected,
|
|
124
229
|
onSelect,
|
|
125
230
|
onDeselect,
|
|
126
231
|
onUpdate,
|
|
127
232
|
onRemove,
|
|
233
|
+
onCopy,
|
|
234
|
+
readOnly,
|
|
128
235
|
}) {
|
|
129
236
|
const widgetRef = useRef(null)
|
|
130
237
|
const features = getFeatures(widget.type)
|
|
131
238
|
|
|
132
239
|
const handleAction = useCallback((actionId) => {
|
|
133
240
|
if (actionId === 'delete') {
|
|
134
|
-
onRemove(widget.id)
|
|
241
|
+
onRemove?.(widget.id)
|
|
242
|
+
} else if (actionId === 'copy') {
|
|
243
|
+
onCopy?.(widget)
|
|
135
244
|
}
|
|
136
|
-
}, [widget
|
|
245
|
+
}, [widget, onRemove, onCopy])
|
|
137
246
|
|
|
138
247
|
return (
|
|
139
248
|
<WidgetChrome
|
|
@@ -141,16 +250,18 @@ function ChromeWrappedWidget({
|
|
|
141
250
|
widgetType={widget.type}
|
|
142
251
|
features={features}
|
|
143
252
|
selected={selected}
|
|
253
|
+
multiSelected={multiSelected}
|
|
144
254
|
widgetProps={widget.props}
|
|
145
255
|
widgetRef={widgetRef}
|
|
146
256
|
onSelect={onSelect}
|
|
147
257
|
onDeselect={onDeselect}
|
|
148
258
|
onAction={handleAction}
|
|
149
|
-
onUpdate={(updates) => onUpdate(widget.id, updates)}
|
|
259
|
+
onUpdate={onUpdate ? (updates) => onUpdate(widget.id, updates) : undefined}
|
|
260
|
+
readOnly={readOnly}
|
|
150
261
|
>
|
|
151
262
|
<WidgetRenderer
|
|
152
263
|
widget={widget}
|
|
153
|
-
onUpdate={(updates) => onUpdate(widget.id, updates)}
|
|
264
|
+
onUpdate={onUpdate ? (updates) => onUpdate(widget.id, updates) : undefined}
|
|
154
265
|
widgetRef={widgetRef}
|
|
155
266
|
/>
|
|
156
267
|
</WidgetChrome>
|
|
@@ -165,24 +276,129 @@ function ChromeWrappedWidget({
|
|
|
165
276
|
*/
|
|
166
277
|
export default function CanvasPage({ name }) {
|
|
167
278
|
const { canvas, jsxExports, loading } = useCanvas(name)
|
|
279
|
+
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
|
|
168
280
|
|
|
169
281
|
// Local mutable copy of widgets for instant UI updates
|
|
170
282
|
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
171
283
|
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
172
|
-
const [
|
|
173
|
-
const
|
|
174
|
-
const
|
|
284
|
+
const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
|
|
285
|
+
const initialViewport = loadViewportState(name)
|
|
286
|
+
const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
|
|
287
|
+
const zoomRef = useRef(initialViewport?.zoom ?? 100)
|
|
175
288
|
const scrollRef = useRef(null)
|
|
289
|
+
const pendingScrollRestore = useRef(initialViewport)
|
|
290
|
+
const initialWidgetParam = useRef(new URLSearchParams(window.location.search).has('widget'))
|
|
176
291
|
const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
|
|
177
292
|
const titleInputRef = useRef(null)
|
|
178
293
|
const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
|
|
179
294
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
295
|
+
const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
|
|
296
|
+
const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
|
|
297
|
+
|
|
298
|
+
// Undo/redo history — tracks both widgets and sources as a combined snapshot
|
|
299
|
+
const undoRedo = useUndoRedo()
|
|
300
|
+
const stateRef = useRef({ widgets: localWidgets, sources: localSources })
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
stateRef.current = { widgets: localWidgets, sources: localSources }
|
|
303
|
+
}, [localWidgets, localSources])
|
|
304
|
+
|
|
305
|
+
// Serialized write queue — ensures JSONL events land in the right order
|
|
306
|
+
const writeQueueRef = useRef(Promise.resolve())
|
|
307
|
+
function queueWrite(fn) {
|
|
308
|
+
writeQueueRef.current = writeQueueRef.current.then(fn).catch((err) =>
|
|
309
|
+
console.error('[canvas] Write queue error:', err)
|
|
310
|
+
)
|
|
311
|
+
return writeQueueRef.current
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Ref for selectedWidgetIds to avoid stale closures in callbacks
|
|
315
|
+
const selectedIdsRef = useRef(selectedWidgetIds)
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
selectedIdsRef.current = selectedWidgetIds
|
|
318
|
+
}, [selectedWidgetIds])
|
|
319
|
+
|
|
320
|
+
const isMultiSelected = selectedWidgetIds.size > 1
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Selection handler — shift+click toggles in/out of multi-select set,
|
|
324
|
+
* plain click single-selects (clears others).
|
|
325
|
+
* Suppressed immediately after a multi-drag to prevent the post-drag
|
|
326
|
+
* click from collapsing the selection.
|
|
327
|
+
*/
|
|
328
|
+
const handleWidgetSelect = useCallback((widgetId, shiftKey) => {
|
|
329
|
+
if (justDraggedRef.current) return
|
|
330
|
+
if (shiftKey) {
|
|
331
|
+
setSelectedWidgetIds(prev => {
|
|
332
|
+
const next = new Set(prev)
|
|
333
|
+
if (next.has(widgetId)) {
|
|
334
|
+
next.delete(widgetId)
|
|
335
|
+
} else {
|
|
336
|
+
next.add(widgetId)
|
|
337
|
+
}
|
|
338
|
+
return next
|
|
339
|
+
})
|
|
340
|
+
} else {
|
|
341
|
+
setSelectedWidgetIds(new Set([widgetId]))
|
|
342
|
+
}
|
|
343
|
+
}, [])
|
|
344
|
+
|
|
345
|
+
// --- Multi-select drag: peers animate to new positions on drag end ---
|
|
346
|
+
// During drag, only the dragged widget moves (via neodrag). On drag end,
|
|
347
|
+
// peer widget positions are updated via React state, and we add the
|
|
348
|
+
// tc-on-translation class so they animate smoothly to their new spots.
|
|
349
|
+
const peerArticlesRef = useRef(new Map())
|
|
350
|
+
// Flag to suppress the click-based selection reset that fires after a drag
|
|
351
|
+
const justDraggedRef = useRef(false)
|
|
352
|
+
|
|
353
|
+
const handleItemDragStart = useCallback((dragId, position) => {
|
|
354
|
+
const ids = selectedIdsRef.current
|
|
355
|
+
peerArticlesRef.current.clear()
|
|
356
|
+
if (ids.size <= 1 || !ids.has(dragId)) return
|
|
357
|
+
|
|
358
|
+
// Suppress selection changes for the duration of the drag
|
|
359
|
+
justDraggedRef.current = true
|
|
360
|
+
|
|
361
|
+
// Collect peer article elements for transition on drag end
|
|
362
|
+
for (const id of ids) {
|
|
363
|
+
if (id === dragId) continue
|
|
364
|
+
const widgetEl = document.getElementById(id)
|
|
365
|
+
const article = widgetEl?.closest('article')
|
|
366
|
+
if (!article) continue
|
|
367
|
+
peerArticlesRef.current.set(id, article)
|
|
368
|
+
}
|
|
369
|
+
}, [])
|
|
370
|
+
|
|
371
|
+
const handleItemDrag = useCallback(() => {
|
|
372
|
+
// Peers stay put during drag — they animate on drag end
|
|
373
|
+
}, [])
|
|
374
|
+
|
|
375
|
+
/** Add transition class to peer articles so they animate to new positions. */
|
|
376
|
+
const transitionPeers = useCallback(() => {
|
|
377
|
+
for (const [, article] of peerArticlesRef.current) {
|
|
378
|
+
article.classList.add('tc-on-translation')
|
|
379
|
+
}
|
|
380
|
+
// Remove class after animation completes
|
|
381
|
+
const articles = [...peerArticlesRef.current.values()]
|
|
382
|
+
setTimeout(() => {
|
|
383
|
+
for (const article of articles) {
|
|
384
|
+
article.classList.remove('tc-on-translation')
|
|
385
|
+
}
|
|
386
|
+
}, 150 + 50 + 200)
|
|
387
|
+
peerArticlesRef.current.clear()
|
|
388
|
+
}, [])
|
|
389
|
+
|
|
390
|
+
const clearDragPreview = useCallback(() => {
|
|
391
|
+
peerArticlesRef.current.clear()
|
|
392
|
+
}, [])
|
|
180
393
|
|
|
181
394
|
if (canvas !== trackedCanvas) {
|
|
182
395
|
setTrackedCanvas(canvas)
|
|
183
396
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
184
397
|
setLocalSources(canvas?.sources ?? [])
|
|
185
398
|
setCanvasTitle(canvas?.title || name)
|
|
399
|
+
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
400
|
+
setSnapGridSize(canvas?.gridSize || 40)
|
|
401
|
+
undoRedo.reset()
|
|
186
402
|
}
|
|
187
403
|
|
|
188
404
|
// Debounced save to server
|
|
@@ -216,22 +432,59 @@ export default function CanvasPage({ name }) {
|
|
|
216
432
|
}, [])
|
|
217
433
|
|
|
218
434
|
const handleWidgetUpdate = useCallback((widgetId, updates) => {
|
|
435
|
+
undoRedo.snapshot(stateRef.current, 'edit', widgetId)
|
|
436
|
+
// Snap width/height to grid when snap is enabled
|
|
437
|
+
const snapped = { ...updates }
|
|
438
|
+
if (snapEnabled && snapGridSize) {
|
|
439
|
+
if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 60)
|
|
440
|
+
if (snapped.height != null) snapped.height = snapDimension(snapped.height, snapGridSize, true, 60)
|
|
441
|
+
}
|
|
219
442
|
setLocalWidgets((prev) => {
|
|
220
443
|
if (!prev) return prev
|
|
221
444
|
const next = prev.map((w) =>
|
|
222
|
-
w.id === widgetId ? { ...w, props: { ...w.props, ...
|
|
445
|
+
w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
|
|
223
446
|
)
|
|
224
447
|
debouncedSave(name, next)
|
|
225
448
|
return next
|
|
226
449
|
})
|
|
227
|
-
}, [name, debouncedSave])
|
|
450
|
+
}, [name, debouncedSave, undoRedo, snapEnabled, snapGridSize])
|
|
228
451
|
|
|
229
452
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
453
|
+
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
230
454
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
231
|
-
|
|
232
|
-
|
|
455
|
+
queueWrite(() =>
|
|
456
|
+
removeWidgetApi(name, widgetId).catch((err) =>
|
|
457
|
+
console.error('[canvas] Failed to remove widget:', err)
|
|
458
|
+
)
|
|
233
459
|
)
|
|
234
|
-
}, [name])
|
|
460
|
+
}, [name, undoRedo])
|
|
461
|
+
|
|
462
|
+
const handleWidgetCopy = useCallback(async (widget) => {
|
|
463
|
+
// Find the next free offset — check how many copies already exist at +n*40
|
|
464
|
+
const baseX = widget.position?.x ?? 0
|
|
465
|
+
const baseY = widget.position?.y ?? 0
|
|
466
|
+
const occupied = new Set(
|
|
467
|
+
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
468
|
+
)
|
|
469
|
+
let n = 1
|
|
470
|
+
while (occupied.has(`${baseX + n * 40},${baseY + n * 40}`)) {
|
|
471
|
+
n++
|
|
472
|
+
}
|
|
473
|
+
const position = { x: baseX + n * 40, y: baseY + n * 40 }
|
|
474
|
+
try {
|
|
475
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
476
|
+
const result = await addWidgetApi(name, {
|
|
477
|
+
type: widget.type,
|
|
478
|
+
props: { ...widget.props },
|
|
479
|
+
position,
|
|
480
|
+
})
|
|
481
|
+
if (result.success && result.widget) {
|
|
482
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
483
|
+
}
|
|
484
|
+
} catch (err) {
|
|
485
|
+
console.error('[canvas] Failed to copy widget:', err)
|
|
486
|
+
}
|
|
487
|
+
}, [name, localWidgets, undoRedo])
|
|
235
488
|
|
|
236
489
|
const debouncedSourceSave = useRef(
|
|
237
490
|
debounce((canvasName, sources) => {
|
|
@@ -242,51 +495,250 @@ export default function CanvasPage({ name }) {
|
|
|
242
495
|
).current
|
|
243
496
|
|
|
244
497
|
const handleSourceUpdate = useCallback((exportName, updates) => {
|
|
498
|
+
undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
|
|
499
|
+
const snapped = { ...updates }
|
|
500
|
+
if (snapEnabled && snapGridSize) {
|
|
501
|
+
if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 100)
|
|
502
|
+
if (snapped.height != null) snapped.height = snapDimension(snapped.height, snapGridSize, true, 60)
|
|
503
|
+
}
|
|
245
504
|
setLocalSources((prev) => {
|
|
246
505
|
const current = Array.isArray(prev) ? prev : []
|
|
247
506
|
const next = current.some((s) => s?.export === exportName)
|
|
248
|
-
? current.map((s) => (s?.export === exportName ? { ...s, ...
|
|
249
|
-
: [...current, { export: exportName, ...
|
|
507
|
+
? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
|
|
508
|
+
: [...current, { export: exportName, ...snapped }]
|
|
250
509
|
debouncedSourceSave(name, next)
|
|
251
510
|
return next
|
|
252
511
|
})
|
|
253
|
-
}, [name, debouncedSourceSave])
|
|
512
|
+
}, [name, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
|
|
254
513
|
|
|
255
514
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
256
|
-
if (!dragId || !position)
|
|
515
|
+
if (!dragId || !position) {
|
|
516
|
+
clearDragPreview()
|
|
517
|
+
return
|
|
518
|
+
}
|
|
257
519
|
const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
|
|
258
520
|
|
|
521
|
+
const ids = selectedIdsRef.current
|
|
522
|
+
// Multi-select move: apply same delta to all selected widgets
|
|
523
|
+
// Checked BEFORE the jsx- early return so mixed selections work
|
|
524
|
+
if (ids.size > 1 && ids.has(dragId)) {
|
|
525
|
+
transitionPeers()
|
|
526
|
+
// Suppress the click-based selection reset that fires after pointerup
|
|
527
|
+
justDraggedRef.current = true
|
|
528
|
+
requestAnimationFrame(() => { justDraggedRef.current = false })
|
|
529
|
+
undoRedo.snapshot(stateRef.current, 'multi-move')
|
|
530
|
+
|
|
531
|
+
// Compute delta from the dragged widget's old position
|
|
532
|
+
const isJsx = dragId.startsWith('jsx-')
|
|
533
|
+
let oldPos = { x: 0, y: 0 }
|
|
534
|
+
if (isJsx) {
|
|
535
|
+
const sourceExport = dragId.replace(/^jsx-/, '')
|
|
536
|
+
const source = (stateRef.current.sources ?? []).find(s => s?.export === sourceExport)
|
|
537
|
+
oldPos = source?.position || { x: 0, y: 0 }
|
|
538
|
+
} else {
|
|
539
|
+
const draggedWidget = (stateRef.current.widgets ?? []).find(w => w.id === dragId)
|
|
540
|
+
oldPos = draggedWidget?.position || { x: 0, y: 0 }
|
|
541
|
+
}
|
|
542
|
+
const dx = rounded.x - oldPos.x
|
|
543
|
+
const dy = rounded.y - oldPos.y
|
|
544
|
+
|
|
545
|
+
debouncedSave.cancel()
|
|
546
|
+
|
|
547
|
+
// Update JSON widget positions
|
|
548
|
+
setLocalWidgets((prev) => {
|
|
549
|
+
if (!prev) return prev
|
|
550
|
+
const next = prev.map((w) => {
|
|
551
|
+
if (w.id === dragId) return { ...w, position: rounded }
|
|
552
|
+
if (ids.has(w.id)) {
|
|
553
|
+
return {
|
|
554
|
+
...w,
|
|
555
|
+
position: {
|
|
556
|
+
x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
|
|
557
|
+
y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
|
|
558
|
+
},
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return w
|
|
562
|
+
})
|
|
563
|
+
queueWrite(() =>
|
|
564
|
+
updateCanvas(name, { widgets: next }).catch((err) =>
|
|
565
|
+
console.error('[canvas] Failed to save multi-move:', err)
|
|
566
|
+
)
|
|
567
|
+
)
|
|
568
|
+
return next
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
// Update JSX source positions
|
|
572
|
+
setLocalSources((prev) => {
|
|
573
|
+
const current = Array.isArray(prev) ? prev : []
|
|
574
|
+
let changed = false
|
|
575
|
+
const next = current.map((s) => {
|
|
576
|
+
if (!s?.export) return s
|
|
577
|
+
const sid = `jsx-${s.export}`
|
|
578
|
+
if (sid === dragId) {
|
|
579
|
+
changed = true
|
|
580
|
+
return { ...s, position: rounded }
|
|
581
|
+
}
|
|
582
|
+
if (ids.has(sid)) {
|
|
583
|
+
changed = true
|
|
584
|
+
return {
|
|
585
|
+
...s,
|
|
586
|
+
position: {
|
|
587
|
+
x: Math.max(0, roundPosition((s.position?.x ?? 0) + dx)),
|
|
588
|
+
y: Math.max(0, roundPosition((s.position?.y ?? 0) + dy)),
|
|
589
|
+
},
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return s
|
|
593
|
+
})
|
|
594
|
+
if (changed) {
|
|
595
|
+
queueWrite(() =>
|
|
596
|
+
updateCanvas(name, { sources: next }).catch((err) =>
|
|
597
|
+
console.error('[canvas] Failed to save multi-move sources:', err)
|
|
598
|
+
)
|
|
599
|
+
)
|
|
600
|
+
}
|
|
601
|
+
return changed ? next : current
|
|
602
|
+
})
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
|
|
259
606
|
if (dragId.startsWith('jsx-')) {
|
|
607
|
+
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
260
608
|
const sourceExport = dragId.replace(/^jsx-/, '')
|
|
261
609
|
setLocalSources((prev) => {
|
|
262
610
|
const current = Array.isArray(prev) ? prev : []
|
|
263
611
|
const next = current.some((s) => s?.export === sourceExport)
|
|
264
612
|
? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
|
|
265
613
|
: [...current, { export: sourceExport, position: rounded }]
|
|
266
|
-
|
|
267
|
-
|
|
614
|
+
queueWrite(() =>
|
|
615
|
+
updateCanvas(name, { sources: next }).catch((err) =>
|
|
616
|
+
console.error('[canvas] Failed to save source position:', err)
|
|
617
|
+
)
|
|
268
618
|
)
|
|
269
619
|
return next
|
|
270
620
|
})
|
|
271
621
|
return
|
|
272
622
|
}
|
|
273
623
|
|
|
624
|
+
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
274
625
|
setLocalWidgets((prev) => {
|
|
275
626
|
if (!prev) return prev
|
|
276
627
|
const next = prev.map((w) =>
|
|
277
628
|
w.id === dragId ? { ...w, position: rounded } : w
|
|
278
629
|
)
|
|
279
|
-
|
|
280
|
-
|
|
630
|
+
queueWrite(() =>
|
|
631
|
+
updateCanvas(name, { widgets: next }).catch((err) =>
|
|
632
|
+
console.error('[canvas] Failed to save widget position:', err)
|
|
633
|
+
)
|
|
281
634
|
)
|
|
282
635
|
return next
|
|
283
636
|
})
|
|
284
|
-
}, [name])
|
|
637
|
+
}, [name, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
|
|
285
638
|
|
|
286
639
|
useEffect(() => {
|
|
287
640
|
zoomRef.current = zoom
|
|
288
641
|
}, [zoom])
|
|
289
642
|
|
|
643
|
+
// Restore scroll position from localStorage after first render
|
|
644
|
+
useEffect(() => {
|
|
645
|
+
const el = scrollRef.current
|
|
646
|
+
const saved = pendingScrollRestore.current
|
|
647
|
+
if (el && saved) {
|
|
648
|
+
if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
|
|
649
|
+
if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
|
|
650
|
+
pendingScrollRestore.current = null
|
|
651
|
+
}
|
|
652
|
+
}, [name, loading])
|
|
653
|
+
|
|
654
|
+
// Center on a specific widget if `?widget=<id>` is in the URL
|
|
655
|
+
useEffect(() => {
|
|
656
|
+
const params = new URLSearchParams(window.location.search)
|
|
657
|
+
const targetId = params.get('widget')
|
|
658
|
+
if (!targetId || loading) return
|
|
659
|
+
|
|
660
|
+
const el = scrollRef.current
|
|
661
|
+
if (!el) return
|
|
662
|
+
|
|
663
|
+
let x, y, w, h
|
|
664
|
+
|
|
665
|
+
// Check JSON widgets first
|
|
666
|
+
const widgets = localWidgets ?? []
|
|
667
|
+
const widget = widgets.find((wgt) => wgt.id === targetId)
|
|
668
|
+
if (widget) {
|
|
669
|
+
const fallback = WIDGET_FALLBACK_SIZES[widget.type] || { width: 200, height: 150 }
|
|
670
|
+
x = widget.position?.x ?? 0
|
|
671
|
+
y = widget.position?.y ?? 0
|
|
672
|
+
w = widget.props?.width ?? fallback.width
|
|
673
|
+
h = widget.props?.height ?? fallback.height
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Check JSX sources (jsx-ExportName)
|
|
677
|
+
if (!widget && targetId.startsWith('jsx-')) {
|
|
678
|
+
const exportName = targetId.slice(4)
|
|
679
|
+
const sourceMap = Object.fromEntries(
|
|
680
|
+
(localSources || []).filter((s) => s?.export).map((s) => [s.export, s])
|
|
681
|
+
)
|
|
682
|
+
const sourceData = sourceMap[exportName]
|
|
683
|
+
if (sourceData || (jsxExports && exportName in jsxExports)) {
|
|
684
|
+
const fallback = WIDGET_FALLBACK_SIZES['component']
|
|
685
|
+
x = sourceData?.position?.x ?? 0
|
|
686
|
+
y = sourceData?.position?.y ?? 0
|
|
687
|
+
w = sourceData?.width ?? fallback.width
|
|
688
|
+
h = sourceData?.height ?? fallback.height
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (x == null) return
|
|
693
|
+
|
|
694
|
+
const scale = zoomRef.current / 100
|
|
695
|
+
el.scrollLeft = (x + w / 2) * scale - el.clientWidth / 2
|
|
696
|
+
el.scrollTop = (y + h / 2) * scale - el.clientHeight / 2
|
|
697
|
+
|
|
698
|
+
// Clean the URL param without triggering navigation
|
|
699
|
+
const url = new URL(window.location.href)
|
|
700
|
+
url.searchParams.delete('widget')
|
|
701
|
+
window.history.replaceState({}, '', url.toString())
|
|
702
|
+
}, [loading, localWidgets, localSources, jsxExports])
|
|
703
|
+
|
|
704
|
+
// Persist viewport state (zoom + scroll) to localStorage on changes
|
|
705
|
+
useEffect(() => {
|
|
706
|
+
const el = scrollRef.current
|
|
707
|
+
saveViewportState(name, {
|
|
708
|
+
zoom,
|
|
709
|
+
scrollLeft: el?.scrollLeft ?? 0,
|
|
710
|
+
scrollTop: el?.scrollTop ?? 0,
|
|
711
|
+
})
|
|
712
|
+
}, [name, zoom])
|
|
713
|
+
|
|
714
|
+
useEffect(() => {
|
|
715
|
+
const el = scrollRef.current
|
|
716
|
+
if (!el) return
|
|
717
|
+
function handleScroll() {
|
|
718
|
+
saveViewportState(name, {
|
|
719
|
+
zoom: zoomRef.current,
|
|
720
|
+
scrollLeft: el.scrollLeft,
|
|
721
|
+
scrollTop: el.scrollTop,
|
|
722
|
+
})
|
|
723
|
+
}
|
|
724
|
+
el.addEventListener('scroll', handleScroll, { passive: true })
|
|
725
|
+
|
|
726
|
+
// Flush viewport state on page unload so a refresh never misses it
|
|
727
|
+
function handleBeforeUnload() {
|
|
728
|
+
saveViewportState(name, {
|
|
729
|
+
zoom: zoomRef.current,
|
|
730
|
+
scrollLeft: el.scrollLeft,
|
|
731
|
+
scrollTop: el.scrollTop,
|
|
732
|
+
})
|
|
733
|
+
}
|
|
734
|
+
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
735
|
+
|
|
736
|
+
return () => {
|
|
737
|
+
el.removeEventListener('scroll', handleScroll)
|
|
738
|
+
window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
739
|
+
}
|
|
740
|
+
}, [name, loading])
|
|
741
|
+
|
|
290
742
|
/**
|
|
291
743
|
* Zoom to a new level, anchoring on an optional client-space point.
|
|
292
744
|
* When a cursor position is provided (e.g. from a wheel event), the
|
|
@@ -345,6 +797,26 @@ export default function CanvasPage({ name }) {
|
|
|
345
797
|
}
|
|
346
798
|
}, [name])
|
|
347
799
|
|
|
800
|
+
// Tell the Vite dev server to suppress full-reloads while this canvas is active.
|
|
801
|
+
// The ?canvas-hmr URL param opts out of the guard for canvas UI development.
|
|
802
|
+
// Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
|
|
803
|
+
useEffect(() => {
|
|
804
|
+
if (!import.meta.hot) return
|
|
805
|
+
const hmrEnabled = new URLSearchParams(window.location.search).has('canvas-hmr')
|
|
806
|
+
if (hmrEnabled) return
|
|
807
|
+
|
|
808
|
+
const msg = { active: true, hmrEnabled: false }
|
|
809
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
810
|
+
const interval = setInterval(() => {
|
|
811
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
812
|
+
}, 3000)
|
|
813
|
+
|
|
814
|
+
return () => {
|
|
815
|
+
clearInterval(interval)
|
|
816
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
|
|
817
|
+
}
|
|
818
|
+
}, [name])
|
|
819
|
+
|
|
348
820
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
349
821
|
const addWidget = useCallback(async (type) => {
|
|
350
822
|
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
@@ -357,12 +829,13 @@ export default function CanvasPage({ name }) {
|
|
|
357
829
|
position: pos,
|
|
358
830
|
})
|
|
359
831
|
if (result.success && result.widget) {
|
|
832
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
360
833
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
361
834
|
}
|
|
362
835
|
} catch (err) {
|
|
363
836
|
console.error('[canvas] Failed to add widget:', err)
|
|
364
837
|
}
|
|
365
|
-
}, [name])
|
|
838
|
+
}, [name, undoRedo])
|
|
366
839
|
|
|
367
840
|
// Listen for CoreUIBar add-widget events
|
|
368
841
|
useEffect(() => {
|
|
@@ -385,6 +858,77 @@ export default function CanvasPage({ name }) {
|
|
|
385
858
|
return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
|
|
386
859
|
}, [])
|
|
387
860
|
|
|
861
|
+
// Listen for snap-to-grid toggle from CoreUIBar
|
|
862
|
+
useEffect(() => {
|
|
863
|
+
function handleSnapToggle() {
|
|
864
|
+
setSnapEnabled((prev) => {
|
|
865
|
+
const next = !prev
|
|
866
|
+
updateCanvas(name, { snapToGrid: next }).catch((err) =>
|
|
867
|
+
console.error('[canvas] Failed to persist snap setting:', err)
|
|
868
|
+
)
|
|
869
|
+
return next
|
|
870
|
+
})
|
|
871
|
+
}
|
|
872
|
+
document.addEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
873
|
+
return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
874
|
+
}, [name])
|
|
875
|
+
|
|
876
|
+
// Broadcast snap state to Svelte toolbar
|
|
877
|
+
useEffect(() => {
|
|
878
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
|
|
879
|
+
detail: { snapEnabled }
|
|
880
|
+
}))
|
|
881
|
+
}, [snapEnabled])
|
|
882
|
+
|
|
883
|
+
// Listen for gridSize from Svelte toolbar config
|
|
884
|
+
useEffect(() => {
|
|
885
|
+
function handleGridSize(e) {
|
|
886
|
+
const size = e.detail?.gridSize
|
|
887
|
+
if (typeof size === 'number' && size > 0) setSnapGridSize(size)
|
|
888
|
+
}
|
|
889
|
+
document.addEventListener('storyboard:canvas:grid-size', handleGridSize)
|
|
890
|
+
return () => document.removeEventListener('storyboard:canvas:grid-size', handleGridSize)
|
|
891
|
+
}, [])
|
|
892
|
+
|
|
893
|
+
// Listen for zoom-to-fit from CoreUIBar
|
|
894
|
+
useEffect(() => {
|
|
895
|
+
function handleZoomToFit() {
|
|
896
|
+
const el = scrollRef.current
|
|
897
|
+
if (!el) return
|
|
898
|
+
|
|
899
|
+
const bounds = computeCanvasBounds(localWidgets, localSources, jsxExports)
|
|
900
|
+
if (!bounds) return
|
|
901
|
+
|
|
902
|
+
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
903
|
+
const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
|
|
904
|
+
|
|
905
|
+
const viewW = el.clientWidth
|
|
906
|
+
const viewH = el.clientHeight
|
|
907
|
+
|
|
908
|
+
// Find the zoom level that fits the bounding box in the viewport
|
|
909
|
+
const fitScale = Math.min(viewW / boxW, viewH / boxH)
|
|
910
|
+
const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
|
|
911
|
+
const newScale = fitZoom / 100
|
|
912
|
+
|
|
913
|
+
// Apply zoom synchronously so DOM updates before we scroll
|
|
914
|
+
zoomRef.current = fitZoom
|
|
915
|
+
flushSync(() => setZoom(fitZoom))
|
|
916
|
+
|
|
917
|
+
// Scroll so the bounding box top-left (with padding) is at viewport top-left
|
|
918
|
+
el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
|
|
919
|
+
el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
|
|
920
|
+
}
|
|
921
|
+
document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
922
|
+
return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
923
|
+
}, [localWidgets, localSources, jsxExports])
|
|
924
|
+
|
|
925
|
+
// On initial load without a ?widget= deep link, zoom to fit all objects
|
|
926
|
+
useEffect(() => {
|
|
927
|
+
if (loading || initialWidgetParam.current) return
|
|
928
|
+
initialWidgetParam.current = true // only once
|
|
929
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-to-fit'))
|
|
930
|
+
}, [loading])
|
|
931
|
+
|
|
388
932
|
// Canvas background should follow toolbar theme target.
|
|
389
933
|
useEffect(() => {
|
|
390
934
|
function readMode() {
|
|
@@ -417,20 +961,42 @@ export default function CanvasPage({ name }) {
|
|
|
417
961
|
|
|
418
962
|
useEffect(() => {
|
|
419
963
|
function handleKeyDown(e) {
|
|
420
|
-
if (
|
|
964
|
+
if (selectedWidgetIds.size === 0) return
|
|
421
965
|
const tag = e.target.tagName
|
|
422
966
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
967
|
+
if (e.key === 'Escape') {
|
|
968
|
+
e.preventDefault()
|
|
969
|
+
setSelectedWidgetIds(new Set())
|
|
970
|
+
}
|
|
423
971
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
424
972
|
e.preventDefault()
|
|
425
|
-
|
|
426
|
-
|
|
973
|
+
if (selectedWidgetIds.size > 1) {
|
|
974
|
+
// Multi-delete — snapshot once, remove all, persist via updateCanvas
|
|
975
|
+
undoRedo.snapshot(stateRef.current, 'multi-remove')
|
|
976
|
+
debouncedSave.cancel()
|
|
977
|
+
setLocalWidgets((prev) => {
|
|
978
|
+
if (!prev) return prev
|
|
979
|
+
const next = prev.filter(w => !selectedWidgetIds.has(w.id))
|
|
980
|
+
queueWrite(() =>
|
|
981
|
+
updateCanvas(name, { widgets: next }).catch(err =>
|
|
982
|
+
console.error('[canvas] Failed to save multi-delete:', err)
|
|
983
|
+
)
|
|
984
|
+
)
|
|
985
|
+
return next
|
|
986
|
+
})
|
|
987
|
+
} else {
|
|
988
|
+
const widgetId = [...selectedWidgetIds][0]
|
|
989
|
+
if (widgetId) handleWidgetRemove(widgetId)
|
|
990
|
+
}
|
|
991
|
+
setSelectedWidgetIds(new Set())
|
|
427
992
|
}
|
|
428
993
|
}
|
|
429
994
|
document.addEventListener('keydown', handleKeyDown)
|
|
430
995
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
431
|
-
}, [
|
|
996
|
+
}, [selectedWidgetIds, handleWidgetRemove, undoRedo, name, debouncedSave])
|
|
432
997
|
|
|
433
|
-
// Paste handler —
|
|
998
|
+
// Paste handler — images become image widgets, same-origin URLs become prototypes,
|
|
999
|
+
// other URLs become link previews, text becomes markdown
|
|
434
1000
|
useEffect(() => {
|
|
435
1001
|
const origin = window.location.origin
|
|
436
1002
|
const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
@@ -461,10 +1027,82 @@ export default function CanvasPage({ name }) {
|
|
|
461
1027
|
return pathname
|
|
462
1028
|
}
|
|
463
1029
|
|
|
1030
|
+
function blobToDataUrl(blob) {
|
|
1031
|
+
return new Promise((resolve, reject) => {
|
|
1032
|
+
const reader = new FileReader()
|
|
1033
|
+
reader.onload = () => resolve(reader.result)
|
|
1034
|
+
reader.onerror = reject
|
|
1035
|
+
reader.readAsDataURL(blob)
|
|
1036
|
+
})
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function getImageDimensions(dataUrl) {
|
|
1040
|
+
return new Promise((resolve) => {
|
|
1041
|
+
const img = new Image()
|
|
1042
|
+
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
|
|
1043
|
+
img.onerror = () => resolve({ width: 400, height: 300 })
|
|
1044
|
+
img.src = dataUrl
|
|
1045
|
+
})
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function handleImagePaste(e) {
|
|
1049
|
+
const items = e.clipboardData?.items
|
|
1050
|
+
if (!items) return false
|
|
1051
|
+
|
|
1052
|
+
for (const item of items) {
|
|
1053
|
+
if (!item.type.startsWith('image/')) continue
|
|
1054
|
+
|
|
1055
|
+
const blob = item.getAsFile()
|
|
1056
|
+
if (!blob) continue
|
|
1057
|
+
|
|
1058
|
+
e.preventDefault()
|
|
1059
|
+
|
|
1060
|
+
try {
|
|
1061
|
+
const dataUrl = await blobToDataUrl(blob)
|
|
1062
|
+
const { width: natW, height: natH } = await getImageDimensions(dataUrl)
|
|
1063
|
+
|
|
1064
|
+
// Display at 2x retina: halve natural dimensions, then cap at 600px
|
|
1065
|
+
const maxWidth = 600
|
|
1066
|
+
let displayW = Math.round(natW / 2)
|
|
1067
|
+
let displayH = Math.round(natH / 2)
|
|
1068
|
+
if (displayW > maxWidth) {
|
|
1069
|
+
displayH = Math.round(displayH * (maxWidth / displayW))
|
|
1070
|
+
displayW = maxWidth
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const uploadResult = await uploadImage(dataUrl, name)
|
|
1074
|
+
if (!uploadResult.success) {
|
|
1075
|
+
console.error('[canvas] Image upload failed:', uploadResult.error)
|
|
1076
|
+
return true
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1080
|
+
const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
|
|
1081
|
+
const result = await addWidgetApi(name, {
|
|
1082
|
+
type: 'image',
|
|
1083
|
+
props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
|
|
1084
|
+
position: pos,
|
|
1085
|
+
})
|
|
1086
|
+
if (result.success && result.widget) {
|
|
1087
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1088
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1089
|
+
}
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
console.error('[canvas] Failed to paste image:', err)
|
|
1092
|
+
}
|
|
1093
|
+
return true
|
|
1094
|
+
}
|
|
1095
|
+
return false
|
|
1096
|
+
}
|
|
1097
|
+
|
|
464
1098
|
async function handlePaste(e) {
|
|
465
1099
|
const tag = e.target.tagName
|
|
466
1100
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
467
1101
|
|
|
1102
|
+
// Image paste takes priority
|
|
1103
|
+
const handledImage = await handleImagePaste(e)
|
|
1104
|
+
if (handledImage) return
|
|
1105
|
+
|
|
468
1106
|
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
469
1107
|
if (!text) return
|
|
470
1108
|
|
|
@@ -473,11 +1111,14 @@ export default function CanvasPage({ name }) {
|
|
|
473
1111
|
let type, props
|
|
474
1112
|
try {
|
|
475
1113
|
const parsed = new URL(text)
|
|
476
|
-
if (
|
|
1114
|
+
if (isFigmaUrl(text)) {
|
|
1115
|
+
type = 'figma-embed'
|
|
1116
|
+
props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
|
|
1117
|
+
} else if (isSameOriginPrototype(text)) {
|
|
477
1118
|
const pathPortion = parsed.pathname + parsed.search + parsed.hash
|
|
478
1119
|
const src = extractPrototypeSrc(pathPortion)
|
|
479
1120
|
type = 'prototype'
|
|
480
|
-
props = { src: src || '/', label: '', width: 800, height: 600 }
|
|
1121
|
+
props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
|
|
481
1122
|
} else {
|
|
482
1123
|
type = 'link-preview'
|
|
483
1124
|
props = { url: text, title: '' }
|
|
@@ -496,6 +1137,7 @@ export default function CanvasPage({ name }) {
|
|
|
496
1137
|
position: pos,
|
|
497
1138
|
})
|
|
498
1139
|
if (result.success && result.widget) {
|
|
1140
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
499
1141
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
500
1142
|
}
|
|
501
1143
|
} catch (err) {
|
|
@@ -504,7 +1146,75 @@ export default function CanvasPage({ name }) {
|
|
|
504
1146
|
}
|
|
505
1147
|
document.addEventListener('paste', handlePaste)
|
|
506
1148
|
return () => document.removeEventListener('paste', handlePaste)
|
|
507
|
-
}, [name])
|
|
1149
|
+
}, [name, undoRedo])
|
|
1150
|
+
|
|
1151
|
+
// --- Undo / Redo ---
|
|
1152
|
+
const handleUndo = useCallback(() => {
|
|
1153
|
+
const previous = undoRedo.undo(stateRef.current)
|
|
1154
|
+
if (!previous) return
|
|
1155
|
+
debouncedSave.cancel()
|
|
1156
|
+
debouncedSourceSave.cancel()
|
|
1157
|
+
setLocalWidgets(previous.widgets)
|
|
1158
|
+
setLocalSources(previous.sources)
|
|
1159
|
+
queueWrite(() =>
|
|
1160
|
+
updateCanvas(name, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
|
|
1161
|
+
console.error('[canvas] Failed to persist undo:', err)
|
|
1162
|
+
)
|
|
1163
|
+
)
|
|
1164
|
+
}, [name, debouncedSave, debouncedSourceSave, undoRedo])
|
|
1165
|
+
|
|
1166
|
+
const handleRedo = useCallback(() => {
|
|
1167
|
+
const next = undoRedo.redo(stateRef.current)
|
|
1168
|
+
if (!next) return
|
|
1169
|
+
debouncedSave.cancel()
|
|
1170
|
+
debouncedSourceSave.cancel()
|
|
1171
|
+
setLocalWidgets(next.widgets)
|
|
1172
|
+
setLocalSources(next.sources)
|
|
1173
|
+
queueWrite(() =>
|
|
1174
|
+
updateCanvas(name, { widgets: next.widgets, sources: next.sources }).catch((err) =>
|
|
1175
|
+
console.error('[canvas] Failed to persist redo:', err)
|
|
1176
|
+
)
|
|
1177
|
+
)
|
|
1178
|
+
}, [name, debouncedSave, debouncedSourceSave, undoRedo])
|
|
1179
|
+
|
|
1180
|
+
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
|
|
1181
|
+
useEffect(() => {
|
|
1182
|
+
if (!import.meta.hot) return
|
|
1183
|
+
function handleKeyDown(e) {
|
|
1184
|
+
const tag = e.target.tagName
|
|
1185
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
1186
|
+
const mod = e.metaKey || e.ctrlKey
|
|
1187
|
+
if (mod && e.key === 'z' && !e.shiftKey) {
|
|
1188
|
+
e.preventDefault()
|
|
1189
|
+
handleUndo()
|
|
1190
|
+
}
|
|
1191
|
+
if (mod && e.key === 'z' && e.shiftKey) {
|
|
1192
|
+
e.preventDefault()
|
|
1193
|
+
handleRedo()
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
1197
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
1198
|
+
}, [handleUndo, handleRedo])
|
|
1199
|
+
|
|
1200
|
+
// Listen for undo/redo from CoreUIBar (Svelte toolbar)
|
|
1201
|
+
useEffect(() => {
|
|
1202
|
+
function handleUndoEvent() { handleUndo() }
|
|
1203
|
+
function handleRedoEvent() { handleRedo() }
|
|
1204
|
+
document.addEventListener('storyboard:canvas:undo', handleUndoEvent)
|
|
1205
|
+
document.addEventListener('storyboard:canvas:redo', handleRedoEvent)
|
|
1206
|
+
return () => {
|
|
1207
|
+
document.removeEventListener('storyboard:canvas:undo', handleUndoEvent)
|
|
1208
|
+
document.removeEventListener('storyboard:canvas:redo', handleRedoEvent)
|
|
1209
|
+
}
|
|
1210
|
+
}, [handleUndo, handleRedo])
|
|
1211
|
+
|
|
1212
|
+
// Broadcast undo/redo availability to Svelte toolbar
|
|
1213
|
+
useEffect(() => {
|
|
1214
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:undo-redo-state', {
|
|
1215
|
+
detail: { canUndo: undoRedo.canUndo, canRedo: undoRedo.canRedo }
|
|
1216
|
+
}))
|
|
1217
|
+
}, [undoRedo.canUndo, undoRedo.canRedo])
|
|
508
1218
|
|
|
509
1219
|
// Cmd+scroll / trackpad pinch to smooth-zoom the canvas
|
|
510
1220
|
// On macOS, pinch-to-zoom fires wheel events with ctrlKey: true and small
|
|
@@ -604,9 +1314,11 @@ export default function CanvasPage({ name }) {
|
|
|
604
1314
|
dotted: canvas.dotted ?? false,
|
|
605
1315
|
grid: canvas.grid ?? false,
|
|
606
1316
|
gridSize: canvas.gridSize ?? 18,
|
|
1317
|
+
snapGrid: snapEnabled ? [snapGridSize, snapGridSize] : undefined,
|
|
607
1318
|
colorMode: canvas.colorMode === 'auto'
|
|
608
1319
|
? getToolbarColorMode(canvasTheme)
|
|
609
1320
|
: (canvas.colorMode ?? 'auto'),
|
|
1321
|
+
locked: !isLocalDev,
|
|
610
1322
|
}
|
|
611
1323
|
|
|
612
1324
|
const canvasThemeVars = getCanvasThemeVars(canvasTheme)
|
|
@@ -633,25 +1345,31 @@ export default function CanvasPage({ name }) {
|
|
|
633
1345
|
id={`jsx-${exportName}`}
|
|
634
1346
|
data-tc-x={sourcePosition.x}
|
|
635
1347
|
data-tc-y={sourcePosition.y}
|
|
636
|
-
data-tc-handle
|
|
1348
|
+
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
637
1349
|
{...canvasPrimerAttrs}
|
|
638
1350
|
style={canvasThemeVars}
|
|
639
|
-
onClick={(e) => {
|
|
1351
|
+
onClick={isLocalDev ? (e) => {
|
|
640
1352
|
e.stopPropagation()
|
|
641
|
-
|
|
642
|
-
|
|
1353
|
+
if (!e.target.closest('.tc-drag-handle')) {
|
|
1354
|
+
handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
|
|
1355
|
+
}
|
|
1356
|
+
} : undefined}
|
|
643
1357
|
>
|
|
644
1358
|
<WidgetChrome
|
|
1359
|
+
widgetId={`jsx-${exportName}`}
|
|
645
1360
|
features={componentFeatures}
|
|
646
|
-
selected={
|
|
647
|
-
|
|
648
|
-
|
|
1361
|
+
selected={selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1362
|
+
multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1363
|
+
onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
|
|
1364
|
+
onDeselect={() => setSelectedWidgetIds(new Set())}
|
|
1365
|
+
readOnly={!isLocalDev}
|
|
649
1366
|
>
|
|
650
1367
|
<ComponentWidget
|
|
651
1368
|
component={Component}
|
|
652
1369
|
width={sourceData.width}
|
|
653
1370
|
height={sourceData.height}
|
|
654
|
-
onUpdate={(updates) => handleSourceUpdate(exportName, updates)}
|
|
1371
|
+
onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
|
|
1372
|
+
resizable={isResizable('component') && isLocalDev}
|
|
655
1373
|
/>
|
|
656
1374
|
</WidgetChrome>
|
|
657
1375
|
</div>
|
|
@@ -667,24 +1385,29 @@ export default function CanvasPage({ name }) {
|
|
|
667
1385
|
id={widget.id}
|
|
668
1386
|
data-tc-x={widget?.position?.x ?? 0}
|
|
669
1387
|
data-tc-y={widget?.position?.y ?? 0}
|
|
670
|
-
data-tc-handle
|
|
1388
|
+
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
671
1389
|
{...canvasPrimerAttrs}
|
|
672
1390
|
style={canvasThemeVars}
|
|
673
|
-
onClick={(e) => {
|
|
1391
|
+
onClick={isLocalDev ? (e) => {
|
|
674
1392
|
e.stopPropagation()
|
|
675
|
-
|
|
676
|
-
|
|
1393
|
+
if (!e.target.closest('.tc-drag-handle')) {
|
|
1394
|
+
handleWidgetSelect(widget.id, e.shiftKey)
|
|
1395
|
+
}
|
|
1396
|
+
} : undefined}
|
|
677
1397
|
>
|
|
678
1398
|
<ChromeWrappedWidget
|
|
679
1399
|
widget={widget}
|
|
680
|
-
selected={
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1400
|
+
selected={selectedWidgetIds.has(widget.id)}
|
|
1401
|
+
multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
|
|
1402
|
+
onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
|
|
1403
|
+
onDeselect={() => setSelectedWidgetIds(new Set())}
|
|
1404
|
+
onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
|
|
1405
|
+
onCopy={isLocalDev ? handleWidgetCopy : undefined}
|
|
1406
|
+
onRemove={isLocalDev ? (id) => {
|
|
685
1407
|
handleWidgetRemove(id)
|
|
686
|
-
|
|
687
|
-
}}
|
|
1408
|
+
setSelectedWidgetIds(new Set())
|
|
1409
|
+
} : undefined}
|
|
1410
|
+
readOnly={!isLocalDev}
|
|
688
1411
|
/>
|
|
689
1412
|
</div>
|
|
690
1413
|
)
|
|
@@ -695,17 +1418,27 @@ export default function CanvasPage({ name }) {
|
|
|
695
1418
|
return (
|
|
696
1419
|
<>
|
|
697
1420
|
<div className={styles.canvasTitle}>
|
|
698
|
-
<
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
1421
|
+
<div className={styles.canvasTitleWrap}>
|
|
1422
|
+
<span className={styles.canvasTitleMeasure} aria-hidden="true">{canvasTitle || ' '}</span>
|
|
1423
|
+
{isLocalDev ? (
|
|
1424
|
+
<input
|
|
1425
|
+
ref={titleInputRef}
|
|
1426
|
+
className={styles.canvasTitleInput}
|
|
1427
|
+
value={canvasTitle}
|
|
1428
|
+
size={1}
|
|
1429
|
+
onChange={handleTitleChange}
|
|
1430
|
+
onKeyDown={handleTitleKeyDown}
|
|
1431
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1432
|
+
spellCheck={false}
|
|
1433
|
+
aria-label="Canvas title"
|
|
1434
|
+
/>
|
|
1435
|
+
) : (
|
|
1436
|
+
<h1 className={styles.canvasTitleStatic}>{canvasTitle}</h1>
|
|
1437
|
+
)}
|
|
1438
|
+
</div>
|
|
1439
|
+
{isLocalDev && (
|
|
1440
|
+
<span className={styles.localEditingLabel}>Local editing</span>
|
|
1441
|
+
)}
|
|
709
1442
|
</div>
|
|
710
1443
|
<div
|
|
711
1444
|
ref={scrollRef}
|
|
@@ -717,7 +1450,7 @@ export default function CanvasPage({ name }) {
|
|
|
717
1450
|
...canvasThemeVars,
|
|
718
1451
|
...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
|
|
719
1452
|
}}
|
|
720
|
-
onClick={() =>
|
|
1453
|
+
onClick={() => setSelectedWidgetIds(new Set())}
|
|
721
1454
|
onMouseDown={handlePanStart}
|
|
722
1455
|
>
|
|
723
1456
|
<div
|
|
@@ -732,7 +1465,7 @@ export default function CanvasPage({ name }) {
|
|
|
732
1465
|
...(spaceHeld ? { pointerEvents: 'none' } : {}),
|
|
733
1466
|
}}
|
|
734
1467
|
>
|
|
735
|
-
<Canvas {...canvasProps} onDragEnd={handleItemDragEnd}>
|
|
1468
|
+
<Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
|
|
736
1469
|
{allChildren}
|
|
737
1470
|
</Canvas>
|
|
738
1471
|
</div>
|