@dfosco/storyboard-react 3.11.1-beta.0 → 3.11.1
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 +791 -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,126 @@ 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)
|
|
176
290
|
const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
|
|
177
291
|
const titleInputRef = useRef(null)
|
|
178
292
|
const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
|
|
179
293
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
294
|
+
const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
|
|
295
|
+
const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
|
|
296
|
+
|
|
297
|
+
// Undo/redo history — tracks both widgets and sources as a combined snapshot
|
|
298
|
+
const undoRedo = useUndoRedo()
|
|
299
|
+
const stateRef = useRef({ widgets: localWidgets, sources: localSources })
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
stateRef.current = { widgets: localWidgets, sources: localSources }
|
|
302
|
+
}, [localWidgets, localSources])
|
|
303
|
+
|
|
304
|
+
// Serialized write queue — ensures JSONL events land in the right order
|
|
305
|
+
const writeQueueRef = useRef(Promise.resolve())
|
|
306
|
+
function queueWrite(fn) {
|
|
307
|
+
writeQueueRef.current = writeQueueRef.current.then(fn).catch((err) =>
|
|
308
|
+
console.error('[canvas] Write queue error:', err)
|
|
309
|
+
)
|
|
310
|
+
return writeQueueRef.current
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Ref for selectedWidgetIds to avoid stale closures in callbacks
|
|
314
|
+
const selectedIdsRef = useRef(selectedWidgetIds)
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
selectedIdsRef.current = selectedWidgetIds
|
|
317
|
+
}, [selectedWidgetIds])
|
|
318
|
+
|
|
319
|
+
const isMultiSelected = selectedWidgetIds.size > 1
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Selection handler — shift+click toggles in/out of multi-select set,
|
|
323
|
+
* plain click single-selects (clears others).
|
|
324
|
+
* Suppressed immediately after a multi-drag to prevent the post-drag
|
|
325
|
+
* click from collapsing the selection.
|
|
326
|
+
*/
|
|
327
|
+
const handleWidgetSelect = useCallback((widgetId, shiftKey) => {
|
|
328
|
+
if (justDraggedRef.current) return
|
|
329
|
+
if (shiftKey) {
|
|
330
|
+
setSelectedWidgetIds(prev => {
|
|
331
|
+
const next = new Set(prev)
|
|
332
|
+
if (next.has(widgetId)) {
|
|
333
|
+
next.delete(widgetId)
|
|
334
|
+
} else {
|
|
335
|
+
next.add(widgetId)
|
|
336
|
+
}
|
|
337
|
+
return next
|
|
338
|
+
})
|
|
339
|
+
} else {
|
|
340
|
+
setSelectedWidgetIds(new Set([widgetId]))
|
|
341
|
+
}
|
|
342
|
+
}, [])
|
|
343
|
+
|
|
344
|
+
// --- Multi-select drag: peers animate to new positions on drag end ---
|
|
345
|
+
// During drag, only the dragged widget moves (via neodrag). On drag end,
|
|
346
|
+
// peer widget positions are updated via React state, and we add the
|
|
347
|
+
// tc-on-translation class so they animate smoothly to their new spots.
|
|
348
|
+
const peerArticlesRef = useRef(new Map())
|
|
349
|
+
// Flag to suppress the click-based selection reset that fires after a drag
|
|
350
|
+
const justDraggedRef = useRef(false)
|
|
351
|
+
|
|
352
|
+
const handleItemDragStart = useCallback((dragId, position) => {
|
|
353
|
+
const ids = selectedIdsRef.current
|
|
354
|
+
peerArticlesRef.current.clear()
|
|
355
|
+
if (ids.size <= 1 || !ids.has(dragId)) return
|
|
356
|
+
|
|
357
|
+
// Suppress selection changes for the duration of the drag
|
|
358
|
+
justDraggedRef.current = true
|
|
359
|
+
|
|
360
|
+
// Collect peer article elements for transition on drag end
|
|
361
|
+
for (const id of ids) {
|
|
362
|
+
if (id === dragId) continue
|
|
363
|
+
const widgetEl = document.getElementById(id)
|
|
364
|
+
const article = widgetEl?.closest('article')
|
|
365
|
+
if (!article) continue
|
|
366
|
+
peerArticlesRef.current.set(id, article)
|
|
367
|
+
}
|
|
368
|
+
}, [])
|
|
369
|
+
|
|
370
|
+
const handleItemDrag = useCallback(() => {
|
|
371
|
+
// Peers stay put during drag — they animate on drag end
|
|
372
|
+
}, [])
|
|
373
|
+
|
|
374
|
+
/** Add transition class to peer articles so they animate to new positions. */
|
|
375
|
+
const transitionPeers = useCallback(() => {
|
|
376
|
+
for (const [, article] of peerArticlesRef.current) {
|
|
377
|
+
article.classList.add('tc-on-translation')
|
|
378
|
+
}
|
|
379
|
+
// Remove class after animation completes
|
|
380
|
+
const articles = [...peerArticlesRef.current.values()]
|
|
381
|
+
setTimeout(() => {
|
|
382
|
+
for (const article of articles) {
|
|
383
|
+
article.classList.remove('tc-on-translation')
|
|
384
|
+
}
|
|
385
|
+
}, 150 + 50 + 200)
|
|
386
|
+
peerArticlesRef.current.clear()
|
|
387
|
+
}, [])
|
|
388
|
+
|
|
389
|
+
const clearDragPreview = useCallback(() => {
|
|
390
|
+
peerArticlesRef.current.clear()
|
|
391
|
+
}, [])
|
|
180
392
|
|
|
181
393
|
if (canvas !== trackedCanvas) {
|
|
182
394
|
setTrackedCanvas(canvas)
|
|
183
395
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
184
396
|
setLocalSources(canvas?.sources ?? [])
|
|
185
397
|
setCanvasTitle(canvas?.title || name)
|
|
398
|
+
undoRedo.reset()
|
|
186
399
|
}
|
|
187
400
|
|
|
188
401
|
// Debounced save to server
|
|
@@ -216,22 +429,59 @@ export default function CanvasPage({ name }) {
|
|
|
216
429
|
}, [])
|
|
217
430
|
|
|
218
431
|
const handleWidgetUpdate = useCallback((widgetId, updates) => {
|
|
432
|
+
undoRedo.snapshot(stateRef.current, 'edit', widgetId)
|
|
433
|
+
// Snap width/height to grid when snap is enabled
|
|
434
|
+
const snapped = { ...updates }
|
|
435
|
+
if (snapEnabled && snapGridSize) {
|
|
436
|
+
if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 60)
|
|
437
|
+
if (snapped.height != null) snapped.height = snapDimension(snapped.height, snapGridSize, true, 60)
|
|
438
|
+
}
|
|
219
439
|
setLocalWidgets((prev) => {
|
|
220
440
|
if (!prev) return prev
|
|
221
441
|
const next = prev.map((w) =>
|
|
222
|
-
w.id === widgetId ? { ...w, props: { ...w.props, ...
|
|
442
|
+
w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
|
|
223
443
|
)
|
|
224
444
|
debouncedSave(name, next)
|
|
225
445
|
return next
|
|
226
446
|
})
|
|
227
|
-
}, [name, debouncedSave])
|
|
447
|
+
}, [name, debouncedSave, undoRedo, snapEnabled, snapGridSize])
|
|
228
448
|
|
|
229
449
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
450
|
+
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
230
451
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
231
|
-
|
|
232
|
-
|
|
452
|
+
queueWrite(() =>
|
|
453
|
+
removeWidgetApi(name, widgetId).catch((err) =>
|
|
454
|
+
console.error('[canvas] Failed to remove widget:', err)
|
|
455
|
+
)
|
|
233
456
|
)
|
|
234
|
-
}, [name])
|
|
457
|
+
}, [name, undoRedo])
|
|
458
|
+
|
|
459
|
+
const handleWidgetCopy = useCallback(async (widget) => {
|
|
460
|
+
// Find the next free offset — check how many copies already exist at +n*40
|
|
461
|
+
const baseX = widget.position?.x ?? 0
|
|
462
|
+
const baseY = widget.position?.y ?? 0
|
|
463
|
+
const occupied = new Set(
|
|
464
|
+
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
465
|
+
)
|
|
466
|
+
let n = 1
|
|
467
|
+
while (occupied.has(`${baseX + n * 40},${baseY + n * 40}`)) {
|
|
468
|
+
n++
|
|
469
|
+
}
|
|
470
|
+
const position = { x: baseX + n * 40, y: baseY + n * 40 }
|
|
471
|
+
try {
|
|
472
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
473
|
+
const result = await addWidgetApi(name, {
|
|
474
|
+
type: widget.type,
|
|
475
|
+
props: { ...widget.props },
|
|
476
|
+
position,
|
|
477
|
+
})
|
|
478
|
+
if (result.success && result.widget) {
|
|
479
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
480
|
+
}
|
|
481
|
+
} catch (err) {
|
|
482
|
+
console.error('[canvas] Failed to copy widget:', err)
|
|
483
|
+
}
|
|
484
|
+
}, [name, localWidgets, undoRedo])
|
|
235
485
|
|
|
236
486
|
const debouncedSourceSave = useRef(
|
|
237
487
|
debounce((canvasName, sources) => {
|
|
@@ -242,51 +492,250 @@ export default function CanvasPage({ name }) {
|
|
|
242
492
|
).current
|
|
243
493
|
|
|
244
494
|
const handleSourceUpdate = useCallback((exportName, updates) => {
|
|
495
|
+
undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
|
|
496
|
+
const snapped = { ...updates }
|
|
497
|
+
if (snapEnabled && snapGridSize) {
|
|
498
|
+
if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 100)
|
|
499
|
+
if (snapped.height != null) snapped.height = snapDimension(snapped.height, snapGridSize, true, 60)
|
|
500
|
+
}
|
|
245
501
|
setLocalSources((prev) => {
|
|
246
502
|
const current = Array.isArray(prev) ? prev : []
|
|
247
503
|
const next = current.some((s) => s?.export === exportName)
|
|
248
|
-
? current.map((s) => (s?.export === exportName ? { ...s, ...
|
|
249
|
-
: [...current, { export: exportName, ...
|
|
504
|
+
? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
|
|
505
|
+
: [...current, { export: exportName, ...snapped }]
|
|
250
506
|
debouncedSourceSave(name, next)
|
|
251
507
|
return next
|
|
252
508
|
})
|
|
253
|
-
}, [name, debouncedSourceSave])
|
|
509
|
+
}, [name, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
|
|
254
510
|
|
|
255
511
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
256
|
-
if (!dragId || !position)
|
|
512
|
+
if (!dragId || !position) {
|
|
513
|
+
clearDragPreview()
|
|
514
|
+
return
|
|
515
|
+
}
|
|
257
516
|
const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
|
|
258
517
|
|
|
518
|
+
const ids = selectedIdsRef.current
|
|
519
|
+
// Multi-select move: apply same delta to all selected widgets
|
|
520
|
+
// Checked BEFORE the jsx- early return so mixed selections work
|
|
521
|
+
if (ids.size > 1 && ids.has(dragId)) {
|
|
522
|
+
transitionPeers()
|
|
523
|
+
// Suppress the click-based selection reset that fires after pointerup
|
|
524
|
+
justDraggedRef.current = true
|
|
525
|
+
requestAnimationFrame(() => { justDraggedRef.current = false })
|
|
526
|
+
undoRedo.snapshot(stateRef.current, 'multi-move')
|
|
527
|
+
|
|
528
|
+
// Compute delta from the dragged widget's old position
|
|
529
|
+
const isJsx = dragId.startsWith('jsx-')
|
|
530
|
+
let oldPos = { x: 0, y: 0 }
|
|
531
|
+
if (isJsx) {
|
|
532
|
+
const sourceExport = dragId.replace(/^jsx-/, '')
|
|
533
|
+
const source = (stateRef.current.sources ?? []).find(s => s?.export === sourceExport)
|
|
534
|
+
oldPos = source?.position || { x: 0, y: 0 }
|
|
535
|
+
} else {
|
|
536
|
+
const draggedWidget = (stateRef.current.widgets ?? []).find(w => w.id === dragId)
|
|
537
|
+
oldPos = draggedWidget?.position || { x: 0, y: 0 }
|
|
538
|
+
}
|
|
539
|
+
const dx = rounded.x - oldPos.x
|
|
540
|
+
const dy = rounded.y - oldPos.y
|
|
541
|
+
|
|
542
|
+
debouncedSave.cancel()
|
|
543
|
+
|
|
544
|
+
// Update JSON widget positions
|
|
545
|
+
setLocalWidgets((prev) => {
|
|
546
|
+
if (!prev) return prev
|
|
547
|
+
const next = prev.map((w) => {
|
|
548
|
+
if (w.id === dragId) return { ...w, position: rounded }
|
|
549
|
+
if (ids.has(w.id)) {
|
|
550
|
+
return {
|
|
551
|
+
...w,
|
|
552
|
+
position: {
|
|
553
|
+
x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
|
|
554
|
+
y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
|
|
555
|
+
},
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return w
|
|
559
|
+
})
|
|
560
|
+
queueWrite(() =>
|
|
561
|
+
updateCanvas(name, { widgets: next }).catch((err) =>
|
|
562
|
+
console.error('[canvas] Failed to save multi-move:', err)
|
|
563
|
+
)
|
|
564
|
+
)
|
|
565
|
+
return next
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
// Update JSX source positions
|
|
569
|
+
setLocalSources((prev) => {
|
|
570
|
+
const current = Array.isArray(prev) ? prev : []
|
|
571
|
+
let changed = false
|
|
572
|
+
const next = current.map((s) => {
|
|
573
|
+
if (!s?.export) return s
|
|
574
|
+
const sid = `jsx-${s.export}`
|
|
575
|
+
if (sid === dragId) {
|
|
576
|
+
changed = true
|
|
577
|
+
return { ...s, position: rounded }
|
|
578
|
+
}
|
|
579
|
+
if (ids.has(sid)) {
|
|
580
|
+
changed = true
|
|
581
|
+
return {
|
|
582
|
+
...s,
|
|
583
|
+
position: {
|
|
584
|
+
x: Math.max(0, roundPosition((s.position?.x ?? 0) + dx)),
|
|
585
|
+
y: Math.max(0, roundPosition((s.position?.y ?? 0) + dy)),
|
|
586
|
+
},
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return s
|
|
590
|
+
})
|
|
591
|
+
if (changed) {
|
|
592
|
+
queueWrite(() =>
|
|
593
|
+
updateCanvas(name, { sources: next }).catch((err) =>
|
|
594
|
+
console.error('[canvas] Failed to save multi-move sources:', err)
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
}
|
|
598
|
+
return changed ? next : current
|
|
599
|
+
})
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
|
|
259
603
|
if (dragId.startsWith('jsx-')) {
|
|
604
|
+
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
260
605
|
const sourceExport = dragId.replace(/^jsx-/, '')
|
|
261
606
|
setLocalSources((prev) => {
|
|
262
607
|
const current = Array.isArray(prev) ? prev : []
|
|
263
608
|
const next = current.some((s) => s?.export === sourceExport)
|
|
264
609
|
? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
|
|
265
610
|
: [...current, { export: sourceExport, position: rounded }]
|
|
266
|
-
|
|
267
|
-
|
|
611
|
+
queueWrite(() =>
|
|
612
|
+
updateCanvas(name, { sources: next }).catch((err) =>
|
|
613
|
+
console.error('[canvas] Failed to save source position:', err)
|
|
614
|
+
)
|
|
268
615
|
)
|
|
269
616
|
return next
|
|
270
617
|
})
|
|
271
618
|
return
|
|
272
619
|
}
|
|
273
620
|
|
|
621
|
+
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
274
622
|
setLocalWidgets((prev) => {
|
|
275
623
|
if (!prev) return prev
|
|
276
624
|
const next = prev.map((w) =>
|
|
277
625
|
w.id === dragId ? { ...w, position: rounded } : w
|
|
278
626
|
)
|
|
279
|
-
|
|
280
|
-
|
|
627
|
+
queueWrite(() =>
|
|
628
|
+
updateCanvas(name, { widgets: next }).catch((err) =>
|
|
629
|
+
console.error('[canvas] Failed to save widget position:', err)
|
|
630
|
+
)
|
|
281
631
|
)
|
|
282
632
|
return next
|
|
283
633
|
})
|
|
284
|
-
}, [name])
|
|
634
|
+
}, [name, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
|
|
285
635
|
|
|
286
636
|
useEffect(() => {
|
|
287
637
|
zoomRef.current = zoom
|
|
288
638
|
}, [zoom])
|
|
289
639
|
|
|
640
|
+
// Restore scroll position from localStorage after first render
|
|
641
|
+
useEffect(() => {
|
|
642
|
+
const el = scrollRef.current
|
|
643
|
+
const saved = pendingScrollRestore.current
|
|
644
|
+
if (el && saved) {
|
|
645
|
+
if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
|
|
646
|
+
if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
|
|
647
|
+
pendingScrollRestore.current = null
|
|
648
|
+
}
|
|
649
|
+
}, [name, loading])
|
|
650
|
+
|
|
651
|
+
// Center on a specific widget if `?widget=<id>` is in the URL
|
|
652
|
+
useEffect(() => {
|
|
653
|
+
const params = new URLSearchParams(window.location.search)
|
|
654
|
+
const targetId = params.get('widget')
|
|
655
|
+
if (!targetId || loading) return
|
|
656
|
+
|
|
657
|
+
const el = scrollRef.current
|
|
658
|
+
if (!el) return
|
|
659
|
+
|
|
660
|
+
let x, y, w, h
|
|
661
|
+
|
|
662
|
+
// Check JSON widgets first
|
|
663
|
+
const widgets = localWidgets ?? []
|
|
664
|
+
const widget = widgets.find((wgt) => wgt.id === targetId)
|
|
665
|
+
if (widget) {
|
|
666
|
+
const fallback = WIDGET_FALLBACK_SIZES[widget.type] || { width: 200, height: 150 }
|
|
667
|
+
x = widget.position?.x ?? 0
|
|
668
|
+
y = widget.position?.y ?? 0
|
|
669
|
+
w = widget.props?.width ?? fallback.width
|
|
670
|
+
h = widget.props?.height ?? fallback.height
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Check JSX sources (jsx-ExportName)
|
|
674
|
+
if (!widget && targetId.startsWith('jsx-')) {
|
|
675
|
+
const exportName = targetId.slice(4)
|
|
676
|
+
const sourceMap = Object.fromEntries(
|
|
677
|
+
(localSources || []).filter((s) => s?.export).map((s) => [s.export, s])
|
|
678
|
+
)
|
|
679
|
+
const sourceData = sourceMap[exportName]
|
|
680
|
+
if (sourceData || (jsxExports && exportName in jsxExports)) {
|
|
681
|
+
const fallback = WIDGET_FALLBACK_SIZES['component']
|
|
682
|
+
x = sourceData?.position?.x ?? 0
|
|
683
|
+
y = sourceData?.position?.y ?? 0
|
|
684
|
+
w = sourceData?.width ?? fallback.width
|
|
685
|
+
h = sourceData?.height ?? fallback.height
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (x == null) return
|
|
690
|
+
|
|
691
|
+
const scale = zoomRef.current / 100
|
|
692
|
+
el.scrollLeft = (x + w / 2) * scale - el.clientWidth / 2
|
|
693
|
+
el.scrollTop = (y + h / 2) * scale - el.clientHeight / 2
|
|
694
|
+
|
|
695
|
+
// Clean the URL param without triggering navigation
|
|
696
|
+
const url = new URL(window.location.href)
|
|
697
|
+
url.searchParams.delete('widget')
|
|
698
|
+
window.history.replaceState({}, '', url.toString())
|
|
699
|
+
}, [loading, localWidgets, localSources, jsxExports])
|
|
700
|
+
|
|
701
|
+
// Persist viewport state (zoom + scroll) to localStorage on changes
|
|
702
|
+
useEffect(() => {
|
|
703
|
+
const el = scrollRef.current
|
|
704
|
+
saveViewportState(name, {
|
|
705
|
+
zoom,
|
|
706
|
+
scrollLeft: el?.scrollLeft ?? 0,
|
|
707
|
+
scrollTop: el?.scrollTop ?? 0,
|
|
708
|
+
})
|
|
709
|
+
}, [name, zoom])
|
|
710
|
+
|
|
711
|
+
useEffect(() => {
|
|
712
|
+
const el = scrollRef.current
|
|
713
|
+
if (!el) return
|
|
714
|
+
function handleScroll() {
|
|
715
|
+
saveViewportState(name, {
|
|
716
|
+
zoom: zoomRef.current,
|
|
717
|
+
scrollLeft: el.scrollLeft,
|
|
718
|
+
scrollTop: el.scrollTop,
|
|
719
|
+
})
|
|
720
|
+
}
|
|
721
|
+
el.addEventListener('scroll', handleScroll, { passive: true })
|
|
722
|
+
|
|
723
|
+
// Flush viewport state on page unload so a refresh never misses it
|
|
724
|
+
function handleBeforeUnload() {
|
|
725
|
+
saveViewportState(name, {
|
|
726
|
+
zoom: zoomRef.current,
|
|
727
|
+
scrollLeft: el.scrollLeft,
|
|
728
|
+
scrollTop: el.scrollTop,
|
|
729
|
+
})
|
|
730
|
+
}
|
|
731
|
+
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
732
|
+
|
|
733
|
+
return () => {
|
|
734
|
+
el.removeEventListener('scroll', handleScroll)
|
|
735
|
+
window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
736
|
+
}
|
|
737
|
+
}, [name, loading])
|
|
738
|
+
|
|
290
739
|
/**
|
|
291
740
|
* Zoom to a new level, anchoring on an optional client-space point.
|
|
292
741
|
* When a cursor position is provided (e.g. from a wheel event), the
|
|
@@ -345,6 +794,26 @@ export default function CanvasPage({ name }) {
|
|
|
345
794
|
}
|
|
346
795
|
}, [name])
|
|
347
796
|
|
|
797
|
+
// Tell the Vite dev server to suppress full-reloads while this canvas is active.
|
|
798
|
+
// The ?canvas-hmr URL param opts out of the guard for canvas UI development.
|
|
799
|
+
// Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
|
|
800
|
+
useEffect(() => {
|
|
801
|
+
if (!import.meta.hot) return
|
|
802
|
+
const hmrEnabled = new URLSearchParams(window.location.search).has('canvas-hmr')
|
|
803
|
+
if (hmrEnabled) return
|
|
804
|
+
|
|
805
|
+
const msg = { active: true, hmrEnabled: false }
|
|
806
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
807
|
+
const interval = setInterval(() => {
|
|
808
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
809
|
+
}, 3000)
|
|
810
|
+
|
|
811
|
+
return () => {
|
|
812
|
+
clearInterval(interval)
|
|
813
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
|
|
814
|
+
}
|
|
815
|
+
}, [name])
|
|
816
|
+
|
|
348
817
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
349
818
|
const addWidget = useCallback(async (type) => {
|
|
350
819
|
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
@@ -357,12 +826,13 @@ export default function CanvasPage({ name }) {
|
|
|
357
826
|
position: pos,
|
|
358
827
|
})
|
|
359
828
|
if (result.success && result.widget) {
|
|
829
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
360
830
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
361
831
|
}
|
|
362
832
|
} catch (err) {
|
|
363
833
|
console.error('[canvas] Failed to add widget:', err)
|
|
364
834
|
}
|
|
365
|
-
}, [name])
|
|
835
|
+
}, [name, undoRedo])
|
|
366
836
|
|
|
367
837
|
// Listen for CoreUIBar add-widget events
|
|
368
838
|
useEffect(() => {
|
|
@@ -385,6 +855,70 @@ export default function CanvasPage({ name }) {
|
|
|
385
855
|
return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
|
|
386
856
|
}, [])
|
|
387
857
|
|
|
858
|
+
// Listen for snap-to-grid toggle from CoreUIBar
|
|
859
|
+
useEffect(() => {
|
|
860
|
+
function handleSnapToggle() {
|
|
861
|
+
setSnapEnabled((prev) => {
|
|
862
|
+
const next = !prev
|
|
863
|
+
updateCanvas(name, { snapToGrid: next }).catch((err) =>
|
|
864
|
+
console.error('[canvas] Failed to persist snap setting:', err)
|
|
865
|
+
)
|
|
866
|
+
return next
|
|
867
|
+
})
|
|
868
|
+
}
|
|
869
|
+
document.addEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
870
|
+
return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
871
|
+
}, [name])
|
|
872
|
+
|
|
873
|
+
// Broadcast snap state to Svelte toolbar
|
|
874
|
+
useEffect(() => {
|
|
875
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
|
|
876
|
+
detail: { snapEnabled }
|
|
877
|
+
}))
|
|
878
|
+
}, [snapEnabled])
|
|
879
|
+
|
|
880
|
+
// Listen for gridSize from Svelte toolbar config
|
|
881
|
+
useEffect(() => {
|
|
882
|
+
function handleGridSize(e) {
|
|
883
|
+
const size = e.detail?.gridSize
|
|
884
|
+
if (typeof size === 'number' && size > 0) setSnapGridSize(size)
|
|
885
|
+
}
|
|
886
|
+
document.addEventListener('storyboard:canvas:grid-size', handleGridSize)
|
|
887
|
+
return () => document.removeEventListener('storyboard:canvas:grid-size', handleGridSize)
|
|
888
|
+
}, [])
|
|
889
|
+
|
|
890
|
+
// Listen for zoom-to-fit from CoreUIBar
|
|
891
|
+
useEffect(() => {
|
|
892
|
+
function handleZoomToFit() {
|
|
893
|
+
const el = scrollRef.current
|
|
894
|
+
if (!el) return
|
|
895
|
+
|
|
896
|
+
const bounds = computeCanvasBounds(localWidgets, localSources, jsxExports)
|
|
897
|
+
if (!bounds) return
|
|
898
|
+
|
|
899
|
+
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
900
|
+
const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
|
|
901
|
+
|
|
902
|
+
const viewW = el.clientWidth
|
|
903
|
+
const viewH = el.clientHeight
|
|
904
|
+
|
|
905
|
+
// Find the zoom level that fits the bounding box in the viewport
|
|
906
|
+
const fitScale = Math.min(viewW / boxW, viewH / boxH)
|
|
907
|
+
const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
|
|
908
|
+
const newScale = fitZoom / 100
|
|
909
|
+
|
|
910
|
+
// Apply zoom synchronously so DOM updates before we scroll
|
|
911
|
+
zoomRef.current = fitZoom
|
|
912
|
+
flushSync(() => setZoom(fitZoom))
|
|
913
|
+
|
|
914
|
+
// Scroll so the bounding box top-left (with padding) is at viewport top-left
|
|
915
|
+
el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
|
|
916
|
+
el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
|
|
917
|
+
}
|
|
918
|
+
document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
919
|
+
return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
920
|
+
}, [localWidgets, localSources, jsxExports])
|
|
921
|
+
|
|
388
922
|
// Canvas background should follow toolbar theme target.
|
|
389
923
|
useEffect(() => {
|
|
390
924
|
function readMode() {
|
|
@@ -417,20 +951,42 @@ export default function CanvasPage({ name }) {
|
|
|
417
951
|
|
|
418
952
|
useEffect(() => {
|
|
419
953
|
function handleKeyDown(e) {
|
|
420
|
-
if (
|
|
954
|
+
if (selectedWidgetIds.size === 0) return
|
|
421
955
|
const tag = e.target.tagName
|
|
422
956
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
957
|
+
if (e.key === 'Escape') {
|
|
958
|
+
e.preventDefault()
|
|
959
|
+
setSelectedWidgetIds(new Set())
|
|
960
|
+
}
|
|
423
961
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
424
962
|
e.preventDefault()
|
|
425
|
-
|
|
426
|
-
|
|
963
|
+
if (selectedWidgetIds.size > 1) {
|
|
964
|
+
// Multi-delete — snapshot once, remove all, persist via updateCanvas
|
|
965
|
+
undoRedo.snapshot(stateRef.current, 'multi-remove')
|
|
966
|
+
debouncedSave.cancel()
|
|
967
|
+
setLocalWidgets((prev) => {
|
|
968
|
+
if (!prev) return prev
|
|
969
|
+
const next = prev.filter(w => !selectedWidgetIds.has(w.id))
|
|
970
|
+
queueWrite(() =>
|
|
971
|
+
updateCanvas(name, { widgets: next }).catch(err =>
|
|
972
|
+
console.error('[canvas] Failed to save multi-delete:', err)
|
|
973
|
+
)
|
|
974
|
+
)
|
|
975
|
+
return next
|
|
976
|
+
})
|
|
977
|
+
} else {
|
|
978
|
+
const widgetId = [...selectedWidgetIds][0]
|
|
979
|
+
if (widgetId) handleWidgetRemove(widgetId)
|
|
980
|
+
}
|
|
981
|
+
setSelectedWidgetIds(new Set())
|
|
427
982
|
}
|
|
428
983
|
}
|
|
429
984
|
document.addEventListener('keydown', handleKeyDown)
|
|
430
985
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
431
|
-
}, [
|
|
986
|
+
}, [selectedWidgetIds, handleWidgetRemove, undoRedo, name, debouncedSave])
|
|
432
987
|
|
|
433
|
-
// Paste handler —
|
|
988
|
+
// Paste handler — images become image widgets, same-origin URLs become prototypes,
|
|
989
|
+
// other URLs become link previews, text becomes markdown
|
|
434
990
|
useEffect(() => {
|
|
435
991
|
const origin = window.location.origin
|
|
436
992
|
const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
@@ -461,10 +1017,82 @@ export default function CanvasPage({ name }) {
|
|
|
461
1017
|
return pathname
|
|
462
1018
|
}
|
|
463
1019
|
|
|
1020
|
+
function blobToDataUrl(blob) {
|
|
1021
|
+
return new Promise((resolve, reject) => {
|
|
1022
|
+
const reader = new FileReader()
|
|
1023
|
+
reader.onload = () => resolve(reader.result)
|
|
1024
|
+
reader.onerror = reject
|
|
1025
|
+
reader.readAsDataURL(blob)
|
|
1026
|
+
})
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function getImageDimensions(dataUrl) {
|
|
1030
|
+
return new Promise((resolve) => {
|
|
1031
|
+
const img = new Image()
|
|
1032
|
+
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
|
|
1033
|
+
img.onerror = () => resolve({ width: 400, height: 300 })
|
|
1034
|
+
img.src = dataUrl
|
|
1035
|
+
})
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
async function handleImagePaste(e) {
|
|
1039
|
+
const items = e.clipboardData?.items
|
|
1040
|
+
if (!items) return false
|
|
1041
|
+
|
|
1042
|
+
for (const item of items) {
|
|
1043
|
+
if (!item.type.startsWith('image/')) continue
|
|
1044
|
+
|
|
1045
|
+
const blob = item.getAsFile()
|
|
1046
|
+
if (!blob) continue
|
|
1047
|
+
|
|
1048
|
+
e.preventDefault()
|
|
1049
|
+
|
|
1050
|
+
try {
|
|
1051
|
+
const dataUrl = await blobToDataUrl(blob)
|
|
1052
|
+
const { width: natW, height: natH } = await getImageDimensions(dataUrl)
|
|
1053
|
+
|
|
1054
|
+
// Display at 2x retina: halve natural dimensions, then cap at 600px
|
|
1055
|
+
const maxWidth = 600
|
|
1056
|
+
let displayW = Math.round(natW / 2)
|
|
1057
|
+
let displayH = Math.round(natH / 2)
|
|
1058
|
+
if (displayW > maxWidth) {
|
|
1059
|
+
displayH = Math.round(displayH * (maxWidth / displayW))
|
|
1060
|
+
displayW = maxWidth
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const uploadResult = await uploadImage(dataUrl, name)
|
|
1064
|
+
if (!uploadResult.success) {
|
|
1065
|
+
console.error('[canvas] Image upload failed:', uploadResult.error)
|
|
1066
|
+
return true
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1070
|
+
const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
|
|
1071
|
+
const result = await addWidgetApi(name, {
|
|
1072
|
+
type: 'image',
|
|
1073
|
+
props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
|
|
1074
|
+
position: pos,
|
|
1075
|
+
})
|
|
1076
|
+
if (result.success && result.widget) {
|
|
1077
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1078
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1079
|
+
}
|
|
1080
|
+
} catch (err) {
|
|
1081
|
+
console.error('[canvas] Failed to paste image:', err)
|
|
1082
|
+
}
|
|
1083
|
+
return true
|
|
1084
|
+
}
|
|
1085
|
+
return false
|
|
1086
|
+
}
|
|
1087
|
+
|
|
464
1088
|
async function handlePaste(e) {
|
|
465
1089
|
const tag = e.target.tagName
|
|
466
1090
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
467
1091
|
|
|
1092
|
+
// Image paste takes priority
|
|
1093
|
+
const handledImage = await handleImagePaste(e)
|
|
1094
|
+
if (handledImage) return
|
|
1095
|
+
|
|
468
1096
|
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
469
1097
|
if (!text) return
|
|
470
1098
|
|
|
@@ -473,11 +1101,14 @@ export default function CanvasPage({ name }) {
|
|
|
473
1101
|
let type, props
|
|
474
1102
|
try {
|
|
475
1103
|
const parsed = new URL(text)
|
|
476
|
-
if (
|
|
1104
|
+
if (isFigmaUrl(text)) {
|
|
1105
|
+
type = 'figma-embed'
|
|
1106
|
+
props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
|
|
1107
|
+
} else if (isSameOriginPrototype(text)) {
|
|
477
1108
|
const pathPortion = parsed.pathname + parsed.search + parsed.hash
|
|
478
1109
|
const src = extractPrototypeSrc(pathPortion)
|
|
479
1110
|
type = 'prototype'
|
|
480
|
-
props = { src: src || '/', label: '', width: 800, height: 600 }
|
|
1111
|
+
props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
|
|
481
1112
|
} else {
|
|
482
1113
|
type = 'link-preview'
|
|
483
1114
|
props = { url: text, title: '' }
|
|
@@ -496,6 +1127,7 @@ export default function CanvasPage({ name }) {
|
|
|
496
1127
|
position: pos,
|
|
497
1128
|
})
|
|
498
1129
|
if (result.success && result.widget) {
|
|
1130
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
499
1131
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
500
1132
|
}
|
|
501
1133
|
} catch (err) {
|
|
@@ -504,7 +1136,75 @@ export default function CanvasPage({ name }) {
|
|
|
504
1136
|
}
|
|
505
1137
|
document.addEventListener('paste', handlePaste)
|
|
506
1138
|
return () => document.removeEventListener('paste', handlePaste)
|
|
507
|
-
}, [name])
|
|
1139
|
+
}, [name, undoRedo])
|
|
1140
|
+
|
|
1141
|
+
// --- Undo / Redo ---
|
|
1142
|
+
const handleUndo = useCallback(() => {
|
|
1143
|
+
const previous = undoRedo.undo(stateRef.current)
|
|
1144
|
+
if (!previous) return
|
|
1145
|
+
debouncedSave.cancel()
|
|
1146
|
+
debouncedSourceSave.cancel()
|
|
1147
|
+
setLocalWidgets(previous.widgets)
|
|
1148
|
+
setLocalSources(previous.sources)
|
|
1149
|
+
queueWrite(() =>
|
|
1150
|
+
updateCanvas(name, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
|
|
1151
|
+
console.error('[canvas] Failed to persist undo:', err)
|
|
1152
|
+
)
|
|
1153
|
+
)
|
|
1154
|
+
}, [name, debouncedSave, debouncedSourceSave, undoRedo])
|
|
1155
|
+
|
|
1156
|
+
const handleRedo = useCallback(() => {
|
|
1157
|
+
const next = undoRedo.redo(stateRef.current)
|
|
1158
|
+
if (!next) return
|
|
1159
|
+
debouncedSave.cancel()
|
|
1160
|
+
debouncedSourceSave.cancel()
|
|
1161
|
+
setLocalWidgets(next.widgets)
|
|
1162
|
+
setLocalSources(next.sources)
|
|
1163
|
+
queueWrite(() =>
|
|
1164
|
+
updateCanvas(name, { widgets: next.widgets, sources: next.sources }).catch((err) =>
|
|
1165
|
+
console.error('[canvas] Failed to persist redo:', err)
|
|
1166
|
+
)
|
|
1167
|
+
)
|
|
1168
|
+
}, [name, debouncedSave, debouncedSourceSave, undoRedo])
|
|
1169
|
+
|
|
1170
|
+
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
|
|
1171
|
+
useEffect(() => {
|
|
1172
|
+
if (!import.meta.hot) return
|
|
1173
|
+
function handleKeyDown(e) {
|
|
1174
|
+
const tag = e.target.tagName
|
|
1175
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
1176
|
+
const mod = e.metaKey || e.ctrlKey
|
|
1177
|
+
if (mod && e.key === 'z' && !e.shiftKey) {
|
|
1178
|
+
e.preventDefault()
|
|
1179
|
+
handleUndo()
|
|
1180
|
+
}
|
|
1181
|
+
if (mod && e.key === 'z' && e.shiftKey) {
|
|
1182
|
+
e.preventDefault()
|
|
1183
|
+
handleRedo()
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
1187
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
1188
|
+
}, [handleUndo, handleRedo])
|
|
1189
|
+
|
|
1190
|
+
// Listen for undo/redo from CoreUIBar (Svelte toolbar)
|
|
1191
|
+
useEffect(() => {
|
|
1192
|
+
function handleUndoEvent() { handleUndo() }
|
|
1193
|
+
function handleRedoEvent() { handleRedo() }
|
|
1194
|
+
document.addEventListener('storyboard:canvas:undo', handleUndoEvent)
|
|
1195
|
+
document.addEventListener('storyboard:canvas:redo', handleRedoEvent)
|
|
1196
|
+
return () => {
|
|
1197
|
+
document.removeEventListener('storyboard:canvas:undo', handleUndoEvent)
|
|
1198
|
+
document.removeEventListener('storyboard:canvas:redo', handleRedoEvent)
|
|
1199
|
+
}
|
|
1200
|
+
}, [handleUndo, handleRedo])
|
|
1201
|
+
|
|
1202
|
+
// Broadcast undo/redo availability to Svelte toolbar
|
|
1203
|
+
useEffect(() => {
|
|
1204
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:undo-redo-state', {
|
|
1205
|
+
detail: { canUndo: undoRedo.canUndo, canRedo: undoRedo.canRedo }
|
|
1206
|
+
}))
|
|
1207
|
+
}, [undoRedo.canUndo, undoRedo.canRedo])
|
|
508
1208
|
|
|
509
1209
|
// Cmd+scroll / trackpad pinch to smooth-zoom the canvas
|
|
510
1210
|
// On macOS, pinch-to-zoom fires wheel events with ctrlKey: true and small
|
|
@@ -604,9 +1304,11 @@ export default function CanvasPage({ name }) {
|
|
|
604
1304
|
dotted: canvas.dotted ?? false,
|
|
605
1305
|
grid: canvas.grid ?? false,
|
|
606
1306
|
gridSize: canvas.gridSize ?? 18,
|
|
1307
|
+
snapGrid: snapEnabled ? [snapGridSize, snapGridSize] : undefined,
|
|
607
1308
|
colorMode: canvas.colorMode === 'auto'
|
|
608
1309
|
? getToolbarColorMode(canvasTheme)
|
|
609
1310
|
: (canvas.colorMode ?? 'auto'),
|
|
1311
|
+
locked: !isLocalDev,
|
|
610
1312
|
}
|
|
611
1313
|
|
|
612
1314
|
const canvasThemeVars = getCanvasThemeVars(canvasTheme)
|
|
@@ -633,25 +1335,31 @@ export default function CanvasPage({ name }) {
|
|
|
633
1335
|
id={`jsx-${exportName}`}
|
|
634
1336
|
data-tc-x={sourcePosition.x}
|
|
635
1337
|
data-tc-y={sourcePosition.y}
|
|
636
|
-
data-tc-handle
|
|
1338
|
+
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
637
1339
|
{...canvasPrimerAttrs}
|
|
638
1340
|
style={canvasThemeVars}
|
|
639
|
-
onClick={(e) => {
|
|
1341
|
+
onClick={isLocalDev ? (e) => {
|
|
640
1342
|
e.stopPropagation()
|
|
641
|
-
|
|
642
|
-
|
|
1343
|
+
if (!e.target.closest('.tc-drag-handle')) {
|
|
1344
|
+
handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
|
|
1345
|
+
}
|
|
1346
|
+
} : undefined}
|
|
643
1347
|
>
|
|
644
1348
|
<WidgetChrome
|
|
1349
|
+
widgetId={`jsx-${exportName}`}
|
|
645
1350
|
features={componentFeatures}
|
|
646
|
-
selected={
|
|
647
|
-
|
|
648
|
-
|
|
1351
|
+
selected={selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1352
|
+
multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1353
|
+
onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
|
|
1354
|
+
onDeselect={() => setSelectedWidgetIds(new Set())}
|
|
1355
|
+
readOnly={!isLocalDev}
|
|
649
1356
|
>
|
|
650
1357
|
<ComponentWidget
|
|
651
1358
|
component={Component}
|
|
652
1359
|
width={sourceData.width}
|
|
653
1360
|
height={sourceData.height}
|
|
654
|
-
onUpdate={(updates) => handleSourceUpdate(exportName, updates)}
|
|
1361
|
+
onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
|
|
1362
|
+
resizable={isResizable('component') && isLocalDev}
|
|
655
1363
|
/>
|
|
656
1364
|
</WidgetChrome>
|
|
657
1365
|
</div>
|
|
@@ -667,24 +1375,29 @@ export default function CanvasPage({ name }) {
|
|
|
667
1375
|
id={widget.id}
|
|
668
1376
|
data-tc-x={widget?.position?.x ?? 0}
|
|
669
1377
|
data-tc-y={widget?.position?.y ?? 0}
|
|
670
|
-
data-tc-handle
|
|
1378
|
+
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
671
1379
|
{...canvasPrimerAttrs}
|
|
672
1380
|
style={canvasThemeVars}
|
|
673
|
-
onClick={(e) => {
|
|
1381
|
+
onClick={isLocalDev ? (e) => {
|
|
674
1382
|
e.stopPropagation()
|
|
675
|
-
|
|
676
|
-
|
|
1383
|
+
if (!e.target.closest('.tc-drag-handle')) {
|
|
1384
|
+
handleWidgetSelect(widget.id, e.shiftKey)
|
|
1385
|
+
}
|
|
1386
|
+
} : undefined}
|
|
677
1387
|
>
|
|
678
1388
|
<ChromeWrappedWidget
|
|
679
1389
|
widget={widget}
|
|
680
|
-
selected={
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1390
|
+
selected={selectedWidgetIds.has(widget.id)}
|
|
1391
|
+
multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
|
|
1392
|
+
onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
|
|
1393
|
+
onDeselect={() => setSelectedWidgetIds(new Set())}
|
|
1394
|
+
onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
|
|
1395
|
+
onCopy={isLocalDev ? handleWidgetCopy : undefined}
|
|
1396
|
+
onRemove={isLocalDev ? (id) => {
|
|
685
1397
|
handleWidgetRemove(id)
|
|
686
|
-
|
|
687
|
-
}}
|
|
1398
|
+
setSelectedWidgetIds(new Set())
|
|
1399
|
+
} : undefined}
|
|
1400
|
+
readOnly={!isLocalDev}
|
|
688
1401
|
/>
|
|
689
1402
|
</div>
|
|
690
1403
|
)
|
|
@@ -695,17 +1408,27 @@ export default function CanvasPage({ name }) {
|
|
|
695
1408
|
return (
|
|
696
1409
|
<>
|
|
697
1410
|
<div className={styles.canvasTitle}>
|
|
698
|
-
<
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
1411
|
+
<div className={styles.canvasTitleWrap}>
|
|
1412
|
+
<span className={styles.canvasTitleMeasure} aria-hidden="true">{canvasTitle || ' '}</span>
|
|
1413
|
+
{isLocalDev ? (
|
|
1414
|
+
<input
|
|
1415
|
+
ref={titleInputRef}
|
|
1416
|
+
className={styles.canvasTitleInput}
|
|
1417
|
+
value={canvasTitle}
|
|
1418
|
+
size={1}
|
|
1419
|
+
onChange={handleTitleChange}
|
|
1420
|
+
onKeyDown={handleTitleKeyDown}
|
|
1421
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1422
|
+
spellCheck={false}
|
|
1423
|
+
aria-label="Canvas title"
|
|
1424
|
+
/>
|
|
1425
|
+
) : (
|
|
1426
|
+
<h1 className={styles.canvasTitleStatic}>{canvasTitle}</h1>
|
|
1427
|
+
)}
|
|
1428
|
+
</div>
|
|
1429
|
+
{isLocalDev && (
|
|
1430
|
+
<span className={styles.localEditingLabel}>Local editing</span>
|
|
1431
|
+
)}
|
|
709
1432
|
</div>
|
|
710
1433
|
<div
|
|
711
1434
|
ref={scrollRef}
|
|
@@ -717,7 +1440,7 @@ export default function CanvasPage({ name }) {
|
|
|
717
1440
|
...canvasThemeVars,
|
|
718
1441
|
...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
|
|
719
1442
|
}}
|
|
720
|
-
onClick={() =>
|
|
1443
|
+
onClick={() => setSelectedWidgetIds(new Set())}
|
|
721
1444
|
onMouseDown={handlePanStart}
|
|
722
1445
|
>
|
|
723
1446
|
<div
|
|
@@ -732,7 +1455,7 @@ export default function CanvasPage({ name }) {
|
|
|
732
1455
|
...(spaceHeld ? { pointerEvents: 'none' } : {}),
|
|
733
1456
|
}}
|
|
734
1457
|
>
|
|
735
|
-
<Canvas {...canvasProps} onDragEnd={handleItemDragEnd}>
|
|
1458
|
+
<Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
|
|
736
1459
|
{allChildren}
|
|
737
1460
|
</Canvas>
|
|
738
1461
|
</div>
|