@dfosco/storyboard-react 4.0.0-beta.2 → 4.0.0-beta.20
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 +7 -4
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +512 -235
- package/src/canvas/CanvasPage.module.css +9 -47
- package/src/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/canvas/PageSelector.jsx +102 -0
- package/src/canvas/PageSelector.module.css +93 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +4 -0
- package/src/canvas/canvasReloadGuard.js +37 -0
- package/src/canvas/canvasReloadGuard.test.js +27 -0
- package/src/canvas/componentIsolate.jsx +135 -0
- package/src/canvas/useCanvas.js +6 -2
- package/src/canvas/widgets/ComponentWidget.jsx +67 -9
- package/src/canvas/widgets/ComponentWidget.module.css +9 -6
- package/src/canvas/widgets/FigmaEmbed.jsx +26 -4
- package/src/canvas/widgets/FigmaEmbed.module.css +0 -7
- package/src/canvas/widgets/MarkdownBlock.jsx +94 -21
- package/src/canvas/widgets/MarkdownBlock.module.css +110 -2
- package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +196 -40
- package/src/canvas/widgets/PrototypeEmbed.module.css +30 -3
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +471 -0
- package/src/canvas/widgets/StoryWidget.module.css +200 -0
- package/src/canvas/widgets/WidgetChrome.jsx +54 -18
- package/src/canvas/widgets/WidgetChrome.module.css +4 -7
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +155 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/index.js +2 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +31 -9
- package/src/context.jsx +138 -13
- package/src/hooks/useSceneData.js +4 -2
- package/src/story/StoryPage.jsx +152 -0
- package/src/story/StoryPage.module.css +73 -0
- package/src/vite/data-plugin.js +441 -58
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createElement, useCallback, useEffect, useRef, useState } from 'react'
|
|
1
|
+
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { flushSync } from 'react-dom'
|
|
3
3
|
import { Canvas } from '@dfosco/tiny-canvas'
|
|
4
4
|
import '@dfosco/tiny-canvas/style.css'
|
|
@@ -8,21 +8,35 @@ import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
|
|
|
8
8
|
import { getWidgetComponent } from './widgets/index.js'
|
|
9
9
|
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
10
10
|
import { getFeatures, isResizable } from './widgets/widgetConfig.js'
|
|
11
|
-
import {
|
|
11
|
+
import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
|
|
12
|
+
import { getPasteRules } from '@dfosco/storyboard-core'
|
|
12
13
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
13
14
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
14
15
|
import useUndoRedo from './useUndoRedo.js'
|
|
15
|
-
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
|
|
16
|
+
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage, getCanvas as getCanvasApi } from './canvasApi.js'
|
|
17
|
+
import PageSelector from './PageSelector.jsx'
|
|
18
|
+
import { stories as storyIndex } from 'virtual:storyboard-data-index'
|
|
16
19
|
import styles from './CanvasPage.module.css'
|
|
17
20
|
|
|
18
21
|
const ZOOM_MIN = 25
|
|
19
22
|
const ZOOM_MAX = 200
|
|
20
23
|
|
|
24
|
+
/** Saved viewport state older than this is considered stale — zoom-to-fit instead. */
|
|
25
|
+
const VIEWPORT_TTL_MS = 15 * 60 * 1000
|
|
26
|
+
|
|
21
27
|
const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
|
|
22
28
|
|
|
23
29
|
/** Matches branch-deploy base path prefixes like /branch--my-feature/ */
|
|
24
30
|
const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
|
|
25
31
|
|
|
32
|
+
// Build a reverse map from story route paths → { storyId, route }
|
|
33
|
+
const storyRouteIndex = new Map()
|
|
34
|
+
for (const [storyId, data] of Object.entries(storyIndex || {})) {
|
|
35
|
+
if (data?._route) {
|
|
36
|
+
storyRouteIndex.set(data._route.replace(/\/+$/, ''), storyId)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
function getToolbarColorMode(theme) {
|
|
27
41
|
return String(theme || 'light').startsWith('dark') ? 'dark' : 'light'
|
|
28
42
|
}
|
|
@@ -47,6 +61,36 @@ function resolveCanvasThemeFromStorage() {
|
|
|
47
61
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
48
62
|
}
|
|
49
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Get the copyable URL for a widget based on its type.
|
|
66
|
+
* Returns the most relevant URL/path for the widget content.
|
|
67
|
+
*/
|
|
68
|
+
// eslint-disable-next-line no-unused-vars
|
|
69
|
+
function getWidgetCopyableUrl(widget) {
|
|
70
|
+
const { type, props = {} } = widget
|
|
71
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
72
|
+
switch (type) {
|
|
73
|
+
case 'prototype':
|
|
74
|
+
// Prototype src is a path like "/MyPrototype" - make it a full URL
|
|
75
|
+
return props.src ? `${window.location.origin}${base.replace(/\/$/, '')}${props.src}` : ''
|
|
76
|
+
case 'figma-embed':
|
|
77
|
+
return props.url || ''
|
|
78
|
+
case 'link-preview':
|
|
79
|
+
return props.url || ''
|
|
80
|
+
case 'image':
|
|
81
|
+
// Return the served image URL
|
|
82
|
+
return props.src ? `${window.location.origin}${base.replace(/\/$/, '')}/_storyboard/canvas/images/${props.src}` : ''
|
|
83
|
+
case 'sticky-note':
|
|
84
|
+
// Sticky notes have text content, not a URL
|
|
85
|
+
return props.text || ''
|
|
86
|
+
case 'markdown':
|
|
87
|
+
// Markdown has content, not a URL
|
|
88
|
+
return props.content || ''
|
|
89
|
+
default:
|
|
90
|
+
return ''
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
50
94
|
/**
|
|
51
95
|
* Debounce helper — returns a function that delays invocation.
|
|
52
96
|
* Exposes `.cancel()` to abort pending calls (used by undo/redo).
|
|
@@ -71,6 +115,11 @@ function loadViewportState(canvasName) {
|
|
|
71
115
|
const raw = localStorage.getItem(getViewportStorageKey(canvasName))
|
|
72
116
|
if (!raw) return null
|
|
73
117
|
const state = JSON.parse(raw)
|
|
118
|
+
const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
|
|
119
|
+
if (Date.now() - timestamp > VIEWPORT_TTL_MS) {
|
|
120
|
+
localStorage.removeItem(getViewportStorageKey(canvasName))
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
74
123
|
return {
|
|
75
124
|
zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
|
|
76
125
|
scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
|
|
@@ -81,7 +130,10 @@ function loadViewportState(canvasName) {
|
|
|
81
130
|
|
|
82
131
|
function saveViewportState(canvasName, state) {
|
|
83
132
|
try {
|
|
84
|
-
localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify(
|
|
133
|
+
localStorage.setItem(getViewportStorageKey(canvasName), JSON.stringify({
|
|
134
|
+
...state,
|
|
135
|
+
timestamp: Date.now(),
|
|
136
|
+
}))
|
|
85
137
|
} catch { /* quota exceeded — non-critical */ }
|
|
86
138
|
}
|
|
87
139
|
|
|
@@ -158,7 +210,7 @@ const FIT_PADDING = 48
|
|
|
158
210
|
* Compute the axis-aligned bounding box that contains every widget and source.
|
|
159
211
|
* Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
|
|
160
212
|
*/
|
|
161
|
-
function computeCanvasBounds(widgets,
|
|
213
|
+
function computeCanvasBounds(widgets, componentEntries) {
|
|
162
214
|
let minX = Infinity
|
|
163
215
|
let minY = Infinity
|
|
164
216
|
let maxX = -Infinity
|
|
@@ -179,24 +231,18 @@ function computeCanvasBounds(widgets, sources, jsxExports) {
|
|
|
179
231
|
hasItems = true
|
|
180
232
|
}
|
|
181
233
|
|
|
182
|
-
//
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
}
|
|
234
|
+
// Component widgets (from jsxExports or sources fallback)
|
|
235
|
+
for (const entry of componentEntries) {
|
|
236
|
+
const x = entry.sourceData?.position?.x ?? 0
|
|
237
|
+
const y = entry.sourceData?.position?.y ?? 0
|
|
238
|
+
const fallback = WIDGET_FALLBACK_SIZES['component']
|
|
239
|
+
const width = entry.sourceData?.width ?? fallback.width
|
|
240
|
+
const height = entry.sourceData?.height ?? fallback.height
|
|
241
|
+
minX = Math.min(minX, x)
|
|
242
|
+
minY = Math.min(minY, y)
|
|
243
|
+
maxX = Math.max(maxX, x + width)
|
|
244
|
+
maxY = Math.max(maxY, y + height)
|
|
245
|
+
hasItems = true
|
|
200
246
|
}
|
|
201
247
|
|
|
202
248
|
return hasItems ? { minX, minY, maxX, maxY } : null
|
|
@@ -234,13 +280,16 @@ function ChromeWrappedWidget({
|
|
|
234
280
|
readOnly,
|
|
235
281
|
}) {
|
|
236
282
|
const widgetRef = useRef(null)
|
|
237
|
-
const features = getFeatures(widget.type)
|
|
283
|
+
const features = getFeatures(widget.type, { isLocalDev: !readOnly })
|
|
238
284
|
|
|
239
285
|
const handleAction = useCallback((actionId) => {
|
|
240
286
|
if (actionId === 'delete') {
|
|
241
287
|
onRemove?.(widget.id)
|
|
242
288
|
} else if (actionId === 'copy') {
|
|
243
289
|
onCopy?.(widget)
|
|
290
|
+
} else if (actionId === 'copy-text') {
|
|
291
|
+
const text = widget.props?.text || widget.props?.content || ''
|
|
292
|
+
navigator.clipboard?.writeText(text).catch(() => {})
|
|
244
293
|
}
|
|
245
294
|
}, [widget, onRemove, onCopy])
|
|
246
295
|
|
|
@@ -274,8 +323,8 @@ function ChromeWrappedWidget({
|
|
|
274
323
|
*
|
|
275
324
|
* @param {{ name: string }} props - Canvas name as indexed by the data plugin
|
|
276
325
|
*/
|
|
277
|
-
export default function CanvasPage({ name }) {
|
|
278
|
-
const { canvas, jsxExports, loading } = useCanvas(name)
|
|
326
|
+
export default function CanvasPage({ name, siblingPages = [], canvasMeta = null }) {
|
|
327
|
+
const { canvas, jsxExports, jsxError, loading } = useCanvas(name)
|
|
279
328
|
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
|
|
280
329
|
|
|
281
330
|
// Local mutable copy of widgets for instant UI updates
|
|
@@ -287,12 +336,45 @@ export default function CanvasPage({ name }) {
|
|
|
287
336
|
const zoomRef = useRef(initialViewport?.zoom ?? 100)
|
|
288
337
|
const scrollRef = useRef(null)
|
|
289
338
|
const pendingScrollRestore = useRef(initialViewport)
|
|
290
|
-
|
|
291
|
-
|
|
339
|
+
// Gate viewport persistence until initial positioning is complete.
|
|
340
|
+
// Tracks which canvas name was last initialized — save effects only
|
|
341
|
+
// write when this matches `name`, preventing cross-canvas corruption.
|
|
342
|
+
const viewportInitName = useRef(null)
|
|
292
343
|
const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
|
|
293
344
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
294
345
|
const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
|
|
295
346
|
const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
|
|
347
|
+
// Refs for snap settings (used by drop handler inside effect closure)
|
|
348
|
+
const snapEnabledRef = useRef(snapEnabled)
|
|
349
|
+
const snapGridSizeRef = useRef(snapGridSize)
|
|
350
|
+
|
|
351
|
+
// Centralized list of component export names.
|
|
352
|
+
// When jsxExports is available, use it (discovers new exports not yet in sources).
|
|
353
|
+
// When jsxExports is null (module import failed), fall back to sources so iframes
|
|
354
|
+
// still render — the error is contained inside each iframe.
|
|
355
|
+
const componentEntries = useMemo(() => {
|
|
356
|
+
const sourceMap = Object.fromEntries(
|
|
357
|
+
(localSources || []).filter((s) => s?.export).map((s) => [s.export, s]),
|
|
358
|
+
)
|
|
359
|
+
if (jsxExports) {
|
|
360
|
+
return Object.keys(jsxExports).map((exportName) => ({
|
|
361
|
+
exportName,
|
|
362
|
+
Component: jsxExports[exportName],
|
|
363
|
+
sourceData: sourceMap[exportName] || {},
|
|
364
|
+
}))
|
|
365
|
+
}
|
|
366
|
+
// Fallback: use sources when module import failed (iframe isolation still works)
|
|
367
|
+
if (jsxError && canvas?._jsxModule) {
|
|
368
|
+
return (localSources || [])
|
|
369
|
+
.filter((s) => s?.export)
|
|
370
|
+
.map((s) => ({
|
|
371
|
+
exportName: s.export,
|
|
372
|
+
Component: null,
|
|
373
|
+
sourceData: s,
|
|
374
|
+
}))
|
|
375
|
+
}
|
|
376
|
+
return []
|
|
377
|
+
}, [jsxExports, jsxError, localSources, canvas?._jsxModule])
|
|
296
378
|
|
|
297
379
|
// Undo/redo history — tracks both widgets and sources as a combined snapshot
|
|
298
380
|
const undoRedo = useUndoRedo()
|
|
@@ -349,13 +431,13 @@ export default function CanvasPage({ name }) {
|
|
|
349
431
|
// Flag to suppress the click-based selection reset that fires after a drag
|
|
350
432
|
const justDraggedRef = useRef(false)
|
|
351
433
|
|
|
352
|
-
const handleItemDragStart = useCallback((dragId
|
|
434
|
+
const handleItemDragStart = useCallback((dragId) => {
|
|
353
435
|
const ids = selectedIdsRef.current
|
|
354
436
|
peerArticlesRef.current.clear()
|
|
355
437
|
if (ids.size <= 1 || !ids.has(dragId)) return
|
|
356
438
|
|
|
357
439
|
// Suppress selection changes for the duration of the drag
|
|
358
|
-
justDraggedRef.current = true
|
|
440
|
+
justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
|
|
359
441
|
|
|
360
442
|
// Collect peer article elements for transition on drag end
|
|
361
443
|
for (const id of ids) {
|
|
@@ -394,8 +476,17 @@ export default function CanvasPage({ name }) {
|
|
|
394
476
|
setTrackedCanvas(canvas)
|
|
395
477
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
396
478
|
setLocalSources(canvas?.sources ?? [])
|
|
397
|
-
|
|
479
|
+
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
480
|
+
setSnapGridSize(canvas?.gridSize || 40)
|
|
398
481
|
undoRedo.reset()
|
|
482
|
+
// Block saves until the new canvas's viewport is fully restored.
|
|
483
|
+
viewportInitName.current = null
|
|
484
|
+
const newViewport = loadViewportState(name)
|
|
485
|
+
pendingScrollRestore.current = newViewport
|
|
486
|
+
// Restore zoom from the new canvas's saved state
|
|
487
|
+
const newZoom = newViewport?.zoom ?? 100
|
|
488
|
+
zoomRef.current = newZoom
|
|
489
|
+
setZoom(newZoom)
|
|
399
490
|
}
|
|
400
491
|
|
|
401
492
|
// Debounced save to server
|
|
@@ -407,27 +498,6 @@ export default function CanvasPage({ name }) {
|
|
|
407
498
|
}, 2000)
|
|
408
499
|
).current
|
|
409
500
|
|
|
410
|
-
const debouncedTitleSave = useRef(
|
|
411
|
-
debounce((canvasName, title) => {
|
|
412
|
-
updateCanvas(canvasName, { settings: { title } }).catch((err) =>
|
|
413
|
-
console.error('[canvas] Failed to save title:', err)
|
|
414
|
-
)
|
|
415
|
-
}, 1000)
|
|
416
|
-
).current
|
|
417
|
-
|
|
418
|
-
const handleTitleChange = useCallback((e) => {
|
|
419
|
-
const newTitle = e.target.value
|
|
420
|
-
setCanvasTitle(newTitle)
|
|
421
|
-
debouncedTitleSave(name, newTitle)
|
|
422
|
-
}, [name, debouncedTitleSave])
|
|
423
|
-
|
|
424
|
-
const handleTitleKeyDown = useCallback((e) => {
|
|
425
|
-
if (e.key === 'Enter') {
|
|
426
|
-
e.target.blur()
|
|
427
|
-
}
|
|
428
|
-
e.stopPropagation()
|
|
429
|
-
}, [])
|
|
430
|
-
|
|
431
501
|
const handleWidgetUpdate = useCallback((widgetId, updates) => {
|
|
432
502
|
undoRedo.snapshot(stateRef.current, 'edit', widgetId)
|
|
433
503
|
// Snap width/height to grid when snap is enabled
|
|
@@ -521,7 +591,7 @@ export default function CanvasPage({ name }) {
|
|
|
521
591
|
if (ids.size > 1 && ids.has(dragId)) {
|
|
522
592
|
transitionPeers()
|
|
523
593
|
// Suppress the click-based selection reset that fires after pointerup
|
|
524
|
-
justDraggedRef.current = true
|
|
594
|
+
justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
|
|
525
595
|
requestAnimationFrame(() => { justDraggedRef.current = false })
|
|
526
596
|
undoRedo.snapshot(stateRef.current, 'multi-move')
|
|
527
597
|
|
|
@@ -637,15 +707,38 @@ export default function CanvasPage({ name }) {
|
|
|
637
707
|
zoomRef.current = zoom
|
|
638
708
|
}, [zoom])
|
|
639
709
|
|
|
640
|
-
// Restore scroll position from localStorage after first render
|
|
710
|
+
// Restore scroll position from localStorage after first render.
|
|
711
|
+
// When saved state is fresh (< 15 min), restore it. Otherwise zoom-to-fit
|
|
712
|
+
// all objects so the user sees a useful overview instead of stale coordinates.
|
|
641
713
|
useEffect(() => {
|
|
642
714
|
const el = scrollRef.current
|
|
715
|
+
if (!el || loading) return
|
|
643
716
|
const saved = pendingScrollRestore.current
|
|
644
|
-
if (
|
|
717
|
+
if (saved) {
|
|
718
|
+
// Fresh saved viewport — restore exactly
|
|
645
719
|
if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
|
|
646
720
|
if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
|
|
647
721
|
pendingScrollRestore.current = null
|
|
722
|
+
} else {
|
|
723
|
+
// No saved state or stale — zoom-to-fit all objects
|
|
724
|
+
const bounds = computeCanvasBounds(localWidgets, componentEntries)
|
|
725
|
+
if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
|
|
726
|
+
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
727
|
+
const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
|
|
728
|
+
const fitScale = Math.min(el.clientWidth / boxW, el.clientHeight / boxH)
|
|
729
|
+
const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
|
|
730
|
+
const newScale = fitZoom / 100
|
|
731
|
+
zoomRef.current = fitZoom
|
|
732
|
+
flushSync(() => setZoom(fitZoom))
|
|
733
|
+
el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
|
|
734
|
+
el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
|
|
735
|
+
} else {
|
|
736
|
+
el.scrollLeft = 0
|
|
737
|
+
el.scrollTop = 0
|
|
738
|
+
}
|
|
648
739
|
}
|
|
740
|
+
// Allow save effects for this canvas now that positioning is settled.
|
|
741
|
+
viewportInitName.current = name
|
|
649
742
|
}, [name, loading])
|
|
650
743
|
|
|
651
744
|
// Center on a specific widget if `?widget=<id>` is in the URL
|
|
@@ -673,16 +766,13 @@ export default function CanvasPage({ name }) {
|
|
|
673
766
|
// Check JSX sources (jsx-ExportName)
|
|
674
767
|
if (!widget && targetId.startsWith('jsx-')) {
|
|
675
768
|
const exportName = targetId.slice(4)
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
)
|
|
679
|
-
const sourceData = sourceMap[exportName]
|
|
680
|
-
if (sourceData || (jsxExports && exportName in jsxExports)) {
|
|
769
|
+
const entry = componentEntries.find((e) => e.exportName === exportName)
|
|
770
|
+
if (entry) {
|
|
681
771
|
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
|
|
772
|
+
x = entry.sourceData?.position?.x ?? 0
|
|
773
|
+
y = entry.sourceData?.position?.y ?? 0
|
|
774
|
+
w = entry.sourceData?.width ?? fallback.width
|
|
775
|
+
h = entry.sourceData?.height ?? fallback.height
|
|
686
776
|
}
|
|
687
777
|
}
|
|
688
778
|
|
|
@@ -696,11 +786,18 @@ export default function CanvasPage({ name }) {
|
|
|
696
786
|
const url = new URL(window.location.href)
|
|
697
787
|
url.searchParams.delete('widget')
|
|
698
788
|
window.history.replaceState({}, '', url.toString())
|
|
699
|
-
}, [loading, localWidgets,
|
|
789
|
+
}, [loading, localWidgets, componentEntries])
|
|
700
790
|
|
|
701
|
-
// Persist viewport state (zoom
|
|
791
|
+
// Persist viewport state (zoom only) to localStorage on zoom changes.
|
|
792
|
+
// Scroll position is persisted separately by the debounced scroll handler,
|
|
793
|
+
// cleanup handler, and beforeunload — never here, because imperative zoom
|
|
794
|
+
// operations (applyZoom, zoom-to-fit) adjust scroll AFTER setZoom, so the
|
|
795
|
+
// scroll values would be stale at this point.
|
|
702
796
|
useEffect(() => {
|
|
797
|
+
if (viewportInitName.current !== name) return
|
|
703
798
|
const el = scrollRef.current
|
|
799
|
+
// Read current scroll so the zoom entry doesn't zero-out position,
|
|
800
|
+
// but the authoritative scroll save comes from the scroll handler.
|
|
704
801
|
saveViewportState(name, {
|
|
705
802
|
zoom,
|
|
706
803
|
scrollLeft: el?.scrollLeft ?? 0,
|
|
@@ -711,28 +808,35 @@ export default function CanvasPage({ name }) {
|
|
|
711
808
|
useEffect(() => {
|
|
712
809
|
const el = scrollRef.current
|
|
713
810
|
if (!el) return
|
|
714
|
-
|
|
811
|
+
const saveNow = () => {
|
|
812
|
+
if (viewportInitName.current !== name) return
|
|
715
813
|
saveViewportState(name, {
|
|
716
814
|
zoom: zoomRef.current,
|
|
717
815
|
scrollLeft: el.scrollLeft,
|
|
718
816
|
scrollTop: el.scrollTop,
|
|
719
817
|
})
|
|
720
818
|
}
|
|
819
|
+
const debouncedScrollSave = debounce(saveNow, 150)
|
|
820
|
+
function handleScroll() {
|
|
821
|
+
if (viewportInitName.current !== name) return
|
|
822
|
+
debouncedScrollSave()
|
|
823
|
+
}
|
|
721
824
|
el.addEventListener('scroll', handleScroll, { passive: true })
|
|
722
825
|
|
|
723
826
|
// Flush viewport state on page unload so a refresh never misses it
|
|
724
827
|
function handleBeforeUnload() {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
scrollLeft: el.scrollLeft,
|
|
728
|
-
scrollTop: el.scrollTop,
|
|
729
|
-
})
|
|
828
|
+
debouncedScrollSave.cancel()
|
|
829
|
+
saveNow()
|
|
730
830
|
}
|
|
731
831
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
732
832
|
|
|
733
833
|
return () => {
|
|
834
|
+
debouncedScrollSave.cancel()
|
|
734
835
|
el.removeEventListener('scroll', handleScroll)
|
|
735
836
|
window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
837
|
+
// Save final state on cleanup (covers SPA navigation where
|
|
838
|
+
// beforeunload doesn't fire).
|
|
839
|
+
saveNow()
|
|
736
840
|
}
|
|
737
841
|
}, [name, loading])
|
|
738
842
|
|
|
@@ -771,6 +875,17 @@ export default function CanvasPage({ name }) {
|
|
|
771
875
|
// Scroll so the same canvas point stays under the anchor
|
|
772
876
|
el.scrollLeft = canvasX * newScale - anchorX
|
|
773
877
|
el.scrollTop = canvasY * newScale - anchorY
|
|
878
|
+
|
|
879
|
+
// Persist after both zoom and scroll are settled (the zoom effect
|
|
880
|
+
// fires inside flushSync before the scroll adjustment above, so it
|
|
881
|
+
// would capture stale scroll values).
|
|
882
|
+
if (viewportInitName.current === name) {
|
|
883
|
+
saveViewportState(name, {
|
|
884
|
+
zoom: clampedZoom,
|
|
885
|
+
scrollLeft: el.scrollLeft,
|
|
886
|
+
scrollTop: el.scrollTop,
|
|
887
|
+
})
|
|
888
|
+
}
|
|
774
889
|
}
|
|
775
890
|
|
|
776
891
|
// Signal canvas mount/unmount to CoreUIBar
|
|
@@ -834,14 +949,41 @@ export default function CanvasPage({ name }) {
|
|
|
834
949
|
}
|
|
835
950
|
}, [name, undoRedo])
|
|
836
951
|
|
|
952
|
+
// Add a story widget by storyId — used by CanvasControls story picker
|
|
953
|
+
const addStoryWidget = useCallback(async (storyId) => {
|
|
954
|
+
const storyProps = { storyId, exportName: '', width: 600, height: 400 }
|
|
955
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
956
|
+
const pos = centerPositionForWidget(center, 'story', storyProps)
|
|
957
|
+
try {
|
|
958
|
+
const result = await addWidgetApi(name, {
|
|
959
|
+
type: 'story',
|
|
960
|
+
props: storyProps,
|
|
961
|
+
position: pos,
|
|
962
|
+
})
|
|
963
|
+
if (result.success && result.widget) {
|
|
964
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
965
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
966
|
+
}
|
|
967
|
+
} catch (err) {
|
|
968
|
+
console.error('[canvas] Failed to add story widget:', err)
|
|
969
|
+
}
|
|
970
|
+
}, [name, undoRedo])
|
|
971
|
+
|
|
837
972
|
// Listen for CoreUIBar add-widget events
|
|
838
973
|
useEffect(() => {
|
|
839
974
|
function handleAddWidget(e) {
|
|
840
975
|
addWidget(e.detail.type)
|
|
841
976
|
}
|
|
977
|
+
function handleAddStoryWidget(e) {
|
|
978
|
+
addStoryWidget(e.detail.storyId)
|
|
979
|
+
}
|
|
842
980
|
document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
843
|
-
|
|
844
|
-
|
|
981
|
+
document.addEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
|
|
982
|
+
return () => {
|
|
983
|
+
document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
984
|
+
document.removeEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
|
|
985
|
+
}
|
|
986
|
+
}, [addWidget, addStoryWidget])
|
|
845
987
|
|
|
846
988
|
// Listen for zoom changes from CoreUIBar
|
|
847
989
|
useEffect(() => {
|
|
@@ -860,7 +1002,7 @@ export default function CanvasPage({ name }) {
|
|
|
860
1002
|
function handleSnapToggle() {
|
|
861
1003
|
setSnapEnabled((prev) => {
|
|
862
1004
|
const next = !prev
|
|
863
|
-
updateCanvas(name, { snapToGrid: next }).catch((err) =>
|
|
1005
|
+
updateCanvas(name, { settings: { snapToGrid: next } }).catch((err) =>
|
|
864
1006
|
console.error('[canvas] Failed to persist snap setting:', err)
|
|
865
1007
|
)
|
|
866
1008
|
return next
|
|
@@ -875,8 +1017,20 @@ export default function CanvasPage({ name }) {
|
|
|
875
1017
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
|
|
876
1018
|
detail: { snapEnabled }
|
|
877
1019
|
}))
|
|
1020
|
+
snapEnabledRef.current = snapEnabled
|
|
878
1021
|
}, [snapEnabled])
|
|
879
1022
|
|
|
1023
|
+
// Respond to snap-state requests from Svelte toolbar (handles mount-order race)
|
|
1024
|
+
useEffect(() => {
|
|
1025
|
+
function handleRequest() {
|
|
1026
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
|
|
1027
|
+
detail: { snapEnabled: snapEnabledRef.current }
|
|
1028
|
+
}))
|
|
1029
|
+
}
|
|
1030
|
+
document.addEventListener('storyboard:canvas:snap-state-request', handleRequest)
|
|
1031
|
+
return () => document.removeEventListener('storyboard:canvas:snap-state-request', handleRequest)
|
|
1032
|
+
}, [])
|
|
1033
|
+
|
|
880
1034
|
// Listen for gridSize from Svelte toolbar config
|
|
881
1035
|
useEffect(() => {
|
|
882
1036
|
function handleGridSize(e) {
|
|
@@ -887,13 +1041,18 @@ export default function CanvasPage({ name }) {
|
|
|
887
1041
|
return () => document.removeEventListener('storyboard:canvas:grid-size', handleGridSize)
|
|
888
1042
|
}, [])
|
|
889
1043
|
|
|
1044
|
+
// Keep snapGridSize ref in sync for drop handler
|
|
1045
|
+
useEffect(() => {
|
|
1046
|
+
snapGridSizeRef.current = snapGridSize
|
|
1047
|
+
}, [snapGridSize])
|
|
1048
|
+
|
|
890
1049
|
// Listen for zoom-to-fit from CoreUIBar
|
|
891
1050
|
useEffect(() => {
|
|
892
1051
|
function handleZoomToFit() {
|
|
893
1052
|
const el = scrollRef.current
|
|
894
1053
|
if (!el) return
|
|
895
1054
|
|
|
896
|
-
const bounds = computeCanvasBounds(localWidgets,
|
|
1055
|
+
const bounds = computeCanvasBounds(localWidgets, componentEntries)
|
|
897
1056
|
if (!bounds) return
|
|
898
1057
|
|
|
899
1058
|
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
@@ -914,10 +1073,19 @@ export default function CanvasPage({ name }) {
|
|
|
914
1073
|
// Scroll so the bounding box top-left (with padding) is at viewport top-left
|
|
915
1074
|
el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
|
|
916
1075
|
el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
|
|
1076
|
+
|
|
1077
|
+
// Persist after both zoom and scroll are settled
|
|
1078
|
+
if (viewportInitName.current === name) {
|
|
1079
|
+
saveViewportState(name, {
|
|
1080
|
+
zoom: fitZoom,
|
|
1081
|
+
scrollLeft: el.scrollLeft,
|
|
1082
|
+
scrollTop: el.scrollTop,
|
|
1083
|
+
})
|
|
1084
|
+
}
|
|
917
1085
|
}
|
|
918
1086
|
document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
919
1087
|
return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
920
|
-
}, [localWidgets,
|
|
1088
|
+
}, [localWidgets, componentEntries])
|
|
921
1089
|
|
|
922
1090
|
// Canvas background should follow toolbar theme target.
|
|
923
1091
|
useEffect(() => {
|
|
@@ -958,6 +1126,14 @@ export default function CanvasPage({ name }) {
|
|
|
958
1126
|
e.preventDefault()
|
|
959
1127
|
setSelectedWidgetIds(new Set())
|
|
960
1128
|
}
|
|
1129
|
+
// Copy shortcut (single widget selected):
|
|
1130
|
+
// cmd+c → copy canvasName::widgetId (for cross-canvas paste-duplicate)
|
|
1131
|
+
const mod = e.metaKey || e.ctrlKey
|
|
1132
|
+
if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size === 1) {
|
|
1133
|
+
const widgetId = [...selectedWidgetIds][0]
|
|
1134
|
+
e.preventDefault()
|
|
1135
|
+
navigator.clipboard.writeText(`${name}::${widgetId}`).catch(() => {})
|
|
1136
|
+
}
|
|
961
1137
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
962
1138
|
e.preventDefault()
|
|
963
1139
|
if (selectedWidgetIds.size > 1) {
|
|
@@ -983,50 +1159,17 @@ export default function CanvasPage({ name }) {
|
|
|
983
1159
|
}
|
|
984
1160
|
document.addEventListener('keydown', handleKeyDown)
|
|
985
1161
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
986
|
-
}, [selectedWidgetIds, handleWidgetRemove, undoRedo, name, debouncedSave])
|
|
1162
|
+
}, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, name, debouncedSave])
|
|
987
1163
|
|
|
988
|
-
//
|
|
1164
|
+
// Ref to store processImageFile for use by drop effect
|
|
1165
|
+
const processImageFileRef = useRef(null)
|
|
1166
|
+
|
|
1167
|
+
// Paste and drop handler — images become image widgets, same-origin URLs become prototypes,
|
|
989
1168
|
// other URLs become link previews, text becomes markdown
|
|
990
1169
|
useEffect(() => {
|
|
991
1170
|
const origin = window.location.origin
|
|
992
1171
|
const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
993
|
-
const
|
|
994
|
-
|
|
995
|
-
// Check if a URL is same-origin, accounting for branch-deploy prefixes.
|
|
996
|
-
// e.g. https://site.com/branch--my-feature/Proto and https://site.com/storyboard/Proto
|
|
997
|
-
// are both same-origin prototype URLs.
|
|
998
|
-
function isSameOriginPrototype(url) {
|
|
999
|
-
if (!url.startsWith(origin)) return false
|
|
1000
|
-
if (url.startsWith(baseUrl)) return true
|
|
1001
|
-
// Match branch deploy URLs: origin + /branch--*/...
|
|
1002
|
-
const pathAfterOrigin = url.slice(origin.length)
|
|
1003
|
-
return BRANCH_PREFIX_RE.test(pathAfterOrigin)
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
// Strip the base path (or any branch prefix) from a pathname to get a portable src.
|
|
1007
|
-
function extractPrototypeSrc(pathname) {
|
|
1008
|
-
// Strip current base path
|
|
1009
|
-
if (basePath && pathname.startsWith(basePath)) {
|
|
1010
|
-
return pathname.slice(basePath.length) || '/'
|
|
1011
|
-
}
|
|
1012
|
-
// Strip branch prefix: /branch--name/rest → /rest
|
|
1013
|
-
const branchMatch = pathname.match(BRANCH_PREFIX_RE)
|
|
1014
|
-
if (branchMatch) {
|
|
1015
|
-
return pathname.slice(branchMatch[0].length) || '/'
|
|
1016
|
-
}
|
|
1017
|
-
return pathname
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
/** Parse text as a web URL (http/https only). Returns URL object or null. */
|
|
1021
|
-
function looksLikeWebUrl(text) {
|
|
1022
|
-
try {
|
|
1023
|
-
const url = new URL(text)
|
|
1024
|
-
if (url.protocol === 'http:' || url.protocol === 'https:') return url
|
|
1025
|
-
return null
|
|
1026
|
-
} catch {
|
|
1027
|
-
return null
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1172
|
+
const pasteCtx = createPasteContext(origin, basePath)
|
|
1030
1173
|
|
|
1031
1174
|
function blobToDataUrl(blob) {
|
|
1032
1175
|
return new Promise((resolve, reject) => {
|
|
@@ -1046,6 +1189,59 @@ export default function CanvasPage({ name }) {
|
|
|
1046
1189
|
})
|
|
1047
1190
|
}
|
|
1048
1191
|
|
|
1192
|
+
/**
|
|
1193
|
+
* Process an image file (from paste or drop) and add it as a widget.
|
|
1194
|
+
* @param {File|Blob} file - Image file to process
|
|
1195
|
+
* @param {{ x: number, y: number }|null} position - Drop position, or null to use viewport center
|
|
1196
|
+
*/
|
|
1197
|
+
async function processImageFile(file, position = null) {
|
|
1198
|
+
try {
|
|
1199
|
+
const dataUrl = await blobToDataUrl(file)
|
|
1200
|
+
const { width: natW, height: natH } = await getImageDimensions(dataUrl)
|
|
1201
|
+
|
|
1202
|
+
// Display at 2x retina: halve natural dimensions, then cap at 600px
|
|
1203
|
+
const maxWidth = 600
|
|
1204
|
+
let displayW = Math.round(natW / 2)
|
|
1205
|
+
let displayH = Math.round(natH / 2)
|
|
1206
|
+
if (displayW > maxWidth) {
|
|
1207
|
+
displayH = Math.round(displayH * (maxWidth / displayW))
|
|
1208
|
+
displayW = maxWidth
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const uploadResult = await uploadImage(dataUrl, name)
|
|
1212
|
+
if (!uploadResult.success) {
|
|
1213
|
+
console.error('[canvas] Image upload failed:', uploadResult.error)
|
|
1214
|
+
return false
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// Use provided position or fall back to viewport center
|
|
1218
|
+
let pos
|
|
1219
|
+
if (position) {
|
|
1220
|
+
pos = { x: position.x, y: position.y }
|
|
1221
|
+
} else {
|
|
1222
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1223
|
+
pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const result = await addWidgetApi(name, {
|
|
1227
|
+
type: 'image',
|
|
1228
|
+
props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
|
|
1229
|
+
position: pos,
|
|
1230
|
+
})
|
|
1231
|
+
if (result.success && result.widget) {
|
|
1232
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1233
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1234
|
+
}
|
|
1235
|
+
return true
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
console.error('[canvas] Failed to process image:', err)
|
|
1238
|
+
return false
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Store in ref for use by drag/drop effect
|
|
1243
|
+
processImageFileRef.current = processImageFile
|
|
1244
|
+
|
|
1049
1245
|
async function handleImagePaste(e) {
|
|
1050
1246
|
const items = e.clipboardData?.items
|
|
1051
1247
|
if (!items) return false
|
|
@@ -1057,40 +1253,7 @@ export default function CanvasPage({ name }) {
|
|
|
1057
1253
|
if (!blob) continue
|
|
1058
1254
|
|
|
1059
1255
|
e.preventDefault()
|
|
1060
|
-
|
|
1061
|
-
try {
|
|
1062
|
-
const dataUrl = await blobToDataUrl(blob)
|
|
1063
|
-
const { width: natW, height: natH } = await getImageDimensions(dataUrl)
|
|
1064
|
-
|
|
1065
|
-
// Display at 2x retina: halve natural dimensions, then cap at 600px
|
|
1066
|
-
const maxWidth = 600
|
|
1067
|
-
let displayW = Math.round(natW / 2)
|
|
1068
|
-
let displayH = Math.round(natH / 2)
|
|
1069
|
-
if (displayW > maxWidth) {
|
|
1070
|
-
displayH = Math.round(displayH * (maxWidth / displayW))
|
|
1071
|
-
displayW = maxWidth
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
const uploadResult = await uploadImage(dataUrl, name)
|
|
1075
|
-
if (!uploadResult.success) {
|
|
1076
|
-
console.error('[canvas] Image upload failed:', uploadResult.error)
|
|
1077
|
-
return true
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1081
|
-
const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
|
|
1082
|
-
const result = await addWidgetApi(name, {
|
|
1083
|
-
type: 'image',
|
|
1084
|
-
props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
|
|
1085
|
-
position: pos,
|
|
1086
|
-
})
|
|
1087
|
-
if (result.success && result.widget) {
|
|
1088
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
1089
|
-
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1090
|
-
}
|
|
1091
|
-
} catch (err) {
|
|
1092
|
-
console.error('[canvas] Failed to paste image:', err)
|
|
1093
|
-
}
|
|
1256
|
+
await processImageFile(blob, null)
|
|
1094
1257
|
return true
|
|
1095
1258
|
}
|
|
1096
1259
|
return false
|
|
@@ -1107,28 +1270,48 @@ export default function CanvasPage({ name }) {
|
|
|
1107
1270
|
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
1108
1271
|
if (!text) return
|
|
1109
1272
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
const
|
|
1114
|
-
if (
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1273
|
+
// Detect canvasName::widgetId format for widget duplication (cross-canvas copy-paste)
|
|
1274
|
+
// Also supports legacy canvasName/widgetId for basenames without slashes,
|
|
1275
|
+
// but only when the second segment looks like a widget ID (type-hash).
|
|
1276
|
+
const widgetRefMatch = text.match(/^(.+)::([^:]+)$/) || (text.indexOf('::') === -1 && text.match(/^([^/]+)\/((?:sticky-note|markdown|prototype|link-preview|figma-embed|component|image)-[a-z0-9]+)$/))
|
|
1277
|
+
if (widgetRefMatch) {
|
|
1278
|
+
e.preventDefault()
|
|
1279
|
+
const [, sourceCanvas, sourceWidgetId] = widgetRefMatch
|
|
1280
|
+
// Component widgets are code, not duplicable data — silently consume the ref
|
|
1281
|
+
if (sourceWidgetId.startsWith('jsx-')) return
|
|
1282
|
+
try {
|
|
1283
|
+
let sourceWidget = null
|
|
1284
|
+
if (sourceCanvas === name) {
|
|
1285
|
+
sourceWidget = (localWidgets ?? []).find(w => w.id === sourceWidgetId)
|
|
1286
|
+
} else {
|
|
1287
|
+
const canvasData = await getCanvasApi(sourceCanvas)
|
|
1288
|
+
sourceWidget = (canvasData?.widgets ?? []).find(w => w.id === sourceWidgetId)
|
|
1289
|
+
}
|
|
1290
|
+
if (sourceWidget) {
|
|
1291
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1292
|
+
const pos = centerPositionForWidget(center, sourceWidget.type, sourceWidget.props)
|
|
1293
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1294
|
+
const result = await addWidgetApi(name, {
|
|
1295
|
+
type: sourceWidget.type,
|
|
1296
|
+
props: { ...sourceWidget.props },
|
|
1297
|
+
position: pos,
|
|
1298
|
+
})
|
|
1299
|
+
if (result.success && result.widget) {
|
|
1300
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
} catch (err) {
|
|
1304
|
+
console.error('[canvas] Failed to paste widget reference:', err)
|
|
1126
1305
|
}
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
props = { content: text }
|
|
1306
|
+
// Always consume the ref — never fall through to markdown creation
|
|
1307
|
+
return
|
|
1130
1308
|
}
|
|
1131
1309
|
|
|
1310
|
+
e.preventDefault()
|
|
1311
|
+
const resolved = resolvePaste(text, pasteCtx, getPasteRules())
|
|
1312
|
+
if (!resolved) return
|
|
1313
|
+
const { type, props } = resolved
|
|
1314
|
+
|
|
1132
1315
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1133
1316
|
const pos = centerPositionForWidget(center, type, props)
|
|
1134
1317
|
try {
|
|
@@ -1145,9 +1328,74 @@ export default function CanvasPage({ name }) {
|
|
|
1145
1328
|
console.error('[canvas] Failed to add widget from paste:', err)
|
|
1146
1329
|
}
|
|
1147
1330
|
}
|
|
1331
|
+
|
|
1148
1332
|
document.addEventListener('paste', handlePaste)
|
|
1149
1333
|
return () => document.removeEventListener('paste', handlePaste)
|
|
1150
|
-
}, [name, undoRedo])
|
|
1334
|
+
}, [name, undoRedo, localWidgets])
|
|
1335
|
+
|
|
1336
|
+
// --- Drag and drop handlers for images from Finder/file manager ---
|
|
1337
|
+
// Separate effect to ensure listeners attach after scroll container mounts (loading=false)
|
|
1338
|
+
useEffect(() => {
|
|
1339
|
+
if (loading) return // Don't attach until canvas is loaded and scroll container exists
|
|
1340
|
+
|
|
1341
|
+
const scrollEl = scrollRef.current
|
|
1342
|
+
if (!scrollEl) return
|
|
1343
|
+
|
|
1344
|
+
function handleDragOver(e) {
|
|
1345
|
+
// Only handle if dragging files (not internal widget drag)
|
|
1346
|
+
if (!e.dataTransfer?.types?.includes('Files')) return
|
|
1347
|
+
e.preventDefault()
|
|
1348
|
+
e.dataTransfer.dropEffect = 'copy'
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
async function handleDrop(e) {
|
|
1352
|
+
// Only handle file drops, not internal widget drags
|
|
1353
|
+
if (!e.dataTransfer?.types?.includes('Files')) return
|
|
1354
|
+
|
|
1355
|
+
// Prevent browser default (opening file) immediately for any file drop
|
|
1356
|
+
e.preventDefault()
|
|
1357
|
+
e.stopPropagation()
|
|
1358
|
+
|
|
1359
|
+
const files = e.dataTransfer.files
|
|
1360
|
+
if (!files || files.length === 0) return
|
|
1361
|
+
|
|
1362
|
+
// Filter to image files only — non-images are silently ignored (default already prevented)
|
|
1363
|
+
const imageFiles = Array.from(files).filter((f) => f.type.startsWith('image/'))
|
|
1364
|
+
if (imageFiles.length === 0) return
|
|
1365
|
+
|
|
1366
|
+
// Convert drop coordinates to canvas coordinates
|
|
1367
|
+
const rect = scrollEl.getBoundingClientRect()
|
|
1368
|
+
const scale = zoomRef.current / 100
|
|
1369
|
+
|
|
1370
|
+
// Mouse position relative to scroll container
|
|
1371
|
+
const mouseX = e.clientX - rect.left
|
|
1372
|
+
const mouseY = e.clientY - rect.top
|
|
1373
|
+
|
|
1374
|
+
// Convert to canvas coordinates (account for scroll and zoom)
|
|
1375
|
+
const canvasX = (scrollEl.scrollLeft + mouseX) / scale
|
|
1376
|
+
const canvasY = (scrollEl.scrollTop + mouseY) / scale
|
|
1377
|
+
|
|
1378
|
+
// Snap to grid if enabled, using current grid size
|
|
1379
|
+
const gridSize = snapGridSizeRef.current
|
|
1380
|
+
const shouldSnap = snapEnabledRef.current
|
|
1381
|
+
const snappedX = shouldSnap ? Math.round(canvasX / gridSize) * gridSize : Math.round(canvasX)
|
|
1382
|
+
const snappedY = shouldSnap ? Math.round(canvasY / gridSize) * gridSize : Math.round(canvasY)
|
|
1383
|
+
|
|
1384
|
+
// Process each image file, offsetting subsequent images
|
|
1385
|
+
for (let i = 0; i < imageFiles.length; i++) {
|
|
1386
|
+
const offset = shouldSnap ? i * gridSize : i * 24
|
|
1387
|
+
await processImageFileRef.current?.(imageFiles[i], { x: snappedX + offset, y: snappedY + offset })
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
scrollEl.addEventListener('dragover', handleDragOver)
|
|
1392
|
+
scrollEl.addEventListener('drop', handleDrop)
|
|
1393
|
+
|
|
1394
|
+
return () => {
|
|
1395
|
+
scrollEl.removeEventListener('dragover', handleDragOver)
|
|
1396
|
+
scrollEl.removeEventListener('drop', handleDrop)
|
|
1397
|
+
}
|
|
1398
|
+
}, [loading])
|
|
1151
1399
|
|
|
1152
1400
|
// --- Undo / Redo ---
|
|
1153
1401
|
const handleUndo = useCallback(() => {
|
|
@@ -1235,6 +1483,55 @@ export default function CanvasPage({ name }) {
|
|
|
1235
1483
|
return () => document.removeEventListener('wheel', handleWheel)
|
|
1236
1484
|
}, [])
|
|
1237
1485
|
|
|
1486
|
+
// Touch pinch-to-zoom for mobile — two-finger pinch zooms the canvas
|
|
1487
|
+
const pinchState = useRef({ active: false, startDist: 0, startZoom: 0, centerX: 0, centerY: 0 })
|
|
1488
|
+
useEffect(() => {
|
|
1489
|
+
const el = scrollRef.current
|
|
1490
|
+
if (!el) return
|
|
1491
|
+
|
|
1492
|
+
function getTouchDist(t1, t2) {
|
|
1493
|
+
const dx = t1.clientX - t2.clientX
|
|
1494
|
+
const dy = t1.clientY - t2.clientY
|
|
1495
|
+
return Math.sqrt(dx * dx + dy * dy)
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function handleTouchStart(e) {
|
|
1499
|
+
if (e.touches.length !== 2) return
|
|
1500
|
+
const dist = getTouchDist(e.touches[0], e.touches[1])
|
|
1501
|
+
pinchState.current = {
|
|
1502
|
+
active: true,
|
|
1503
|
+
startDist: dist,
|
|
1504
|
+
startZoom: zoomRef.current,
|
|
1505
|
+
centerX: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
|
1506
|
+
centerY: (e.touches[0].clientY + e.touches[1].clientY) / 2,
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function handleTouchMove(e) {
|
|
1511
|
+
if (!pinchState.current.active || e.touches.length !== 2) return
|
|
1512
|
+
e.preventDefault()
|
|
1513
|
+
const dist = getTouchDist(e.touches[0], e.touches[1])
|
|
1514
|
+
const ratio = dist / pinchState.current.startDist
|
|
1515
|
+
const newZoom = Math.round(pinchState.current.startZoom * ratio)
|
|
1516
|
+
applyZoom(newZoom, pinchState.current.centerX, pinchState.current.centerY)
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function handleTouchEnd() {
|
|
1520
|
+
pinchState.current.active = false
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
el.addEventListener('touchstart', handleTouchStart, { passive: true })
|
|
1524
|
+
el.addEventListener('touchmove', handleTouchMove, { passive: false })
|
|
1525
|
+
el.addEventListener('touchend', handleTouchEnd)
|
|
1526
|
+
el.addEventListener('touchcancel', handleTouchEnd)
|
|
1527
|
+
return () => {
|
|
1528
|
+
el.removeEventListener('touchstart', handleTouchStart)
|
|
1529
|
+
el.removeEventListener('touchmove', handleTouchMove)
|
|
1530
|
+
el.removeEventListener('touchend', handleTouchEnd)
|
|
1531
|
+
el.removeEventListener('touchcancel', handleTouchEnd)
|
|
1532
|
+
}
|
|
1533
|
+
}, [])
|
|
1534
|
+
|
|
1238
1535
|
// Space + drag to pan the canvas
|
|
1239
1536
|
const [spaceHeld, setSpaceHeld] = useState(false)
|
|
1240
1537
|
const isPanning = useRef(false)
|
|
@@ -1328,54 +1625,50 @@ export default function CanvasPage({ name }) {
|
|
|
1328
1625
|
// Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
|
|
1329
1626
|
const allChildren = []
|
|
1330
1627
|
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1628
|
+
// 1. Component widgets (from jsxExports or sources fallback)
|
|
1629
|
+
const componentFeatures = getFeatures('component', { isLocalDev })
|
|
1630
|
+
for (const entry of componentEntries) {
|
|
1631
|
+
const { exportName, Component, sourceData } = entry
|
|
1632
|
+
const sourcePosition = sourceData.position || { x: 0, y: 0 }
|
|
1633
|
+
allChildren.push(
|
|
1634
|
+
<div
|
|
1635
|
+
key={`jsx-${exportName}`}
|
|
1636
|
+
id={`jsx-${exportName}`}
|
|
1637
|
+
data-tc-x={sourcePosition.x}
|
|
1638
|
+
data-tc-y={sourcePosition.y}
|
|
1639
|
+
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
1640
|
+
{...canvasPrimerAttrs}
|
|
1641
|
+
style={canvasThemeVars}
|
|
1642
|
+
onClick={isLocalDev ? (e) => {
|
|
1643
|
+
e.stopPropagation()
|
|
1644
|
+
if (!e.target.closest('.tc-drag-handle')) {
|
|
1645
|
+
handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
|
|
1646
|
+
}
|
|
1647
|
+
} : undefined}
|
|
1648
|
+
>
|
|
1649
|
+
<WidgetChrome
|
|
1650
|
+
widgetId={`jsx-${exportName}`}
|
|
1651
|
+
features={componentFeatures}
|
|
1652
|
+
selected={selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1653
|
+
multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1654
|
+
onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
|
|
1655
|
+
onDeselect={() => setSelectedWidgetIds(new Set())}
|
|
1656
|
+
readOnly={!isLocalDev}
|
|
1358
1657
|
>
|
|
1359
|
-
<
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
resizable={isResizable('component') && isLocalDev}
|
|
1374
|
-
/>
|
|
1375
|
-
</WidgetChrome>
|
|
1376
|
-
</div>
|
|
1377
|
-
)
|
|
1378
|
-
}
|
|
1658
|
+
<ComponentWidget
|
|
1659
|
+
component={Component}
|
|
1660
|
+
jsxModule={canvas?._jsxModule}
|
|
1661
|
+
exportName={exportName}
|
|
1662
|
+
canvasTheme={canvasTheme}
|
|
1663
|
+
isLocalDev={isLocalDev}
|
|
1664
|
+
width={sourceData.width}
|
|
1665
|
+
height={sourceData.height}
|
|
1666
|
+
onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
|
|
1667
|
+
resizable={isResizable('component') && isLocalDev}
|
|
1668
|
+
/>
|
|
1669
|
+
</WidgetChrome>
|
|
1670
|
+
</div>
|
|
1671
|
+
)
|
|
1379
1672
|
}
|
|
1380
1673
|
|
|
1381
1674
|
// 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
|
|
@@ -1419,24 +1712,8 @@ export default function CanvasPage({ name }) {
|
|
|
1419
1712
|
return (
|
|
1420
1713
|
<>
|
|
1421
1714
|
<div className={styles.canvasTitle}>
|
|
1422
|
-
<
|
|
1423
|
-
|
|
1424
|
-
{isLocalDev ? (
|
|
1425
|
-
<input
|
|
1426
|
-
ref={titleInputRef}
|
|
1427
|
-
className={styles.canvasTitleInput}
|
|
1428
|
-
value={canvasTitle}
|
|
1429
|
-
size={1}
|
|
1430
|
-
onChange={handleTitleChange}
|
|
1431
|
-
onKeyDown={handleTitleKeyDown}
|
|
1432
|
-
onMouseDown={(e) => e.stopPropagation()}
|
|
1433
|
-
spellCheck={false}
|
|
1434
|
-
aria-label="Canvas title"
|
|
1435
|
-
/>
|
|
1436
|
-
) : (
|
|
1437
|
-
<h1 className={styles.canvasTitleStatic}>{canvasTitle}</h1>
|
|
1438
|
-
)}
|
|
1439
|
-
</div>
|
|
1715
|
+
<h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || name.split('/').pop()}</h1>
|
|
1716
|
+
<PageSelector currentName={name} pages={siblingPages} />
|
|
1440
1717
|
{isLocalDev && (
|
|
1441
1718
|
<span className={styles.localEditingLabel}>Local editing</span>
|
|
1442
1719
|
)}
|