@dfosco/storyboard-react 4.0.0-beta.9 → 4.0.0
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 +6 -3
- package/src/AuthModal/AuthModal.jsx +134 -0
- package/src/AuthModal/AuthModal.module.css +221 -0
- package/src/BranchBar/BranchBar.jsx +56 -0
- package/src/BranchBar/BranchBar.module.css +230 -0
- package/src/BranchBar/useBranches.js +79 -0
- package/src/CommandPalette/CommandPalette.jsx +936 -0
- package/src/CommandPalette/CreateDialog.jsx +219 -0
- package/src/CommandPalette/command-palette.css +111 -0
- package/src/Icon.jsx +180 -0
- package/src/Viewfinder.jsx +1104 -57
- package/src/Viewfinder.module.css +1107 -149
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +807 -251
- package/src/canvas/CanvasPage.module.css +98 -50
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/MarqueeOverlay.jsx +20 -0
- package/src/canvas/PageSelector.jsx +239 -0
- package/src/canvas/PageSelector.module.css +165 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- package/src/canvas/canvasTheme.js +96 -52
- package/src/canvas/componentIsolate.jsx +33 -7
- package/src/canvas/useCanvas.js +9 -8
- package/src/canvas/useCanvas.test.js +4 -4
- package/src/canvas/useMarqueeSelect.js +187 -0
- package/src/canvas/useMarqueeSelect.test.js +78 -0
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +42 -10
- package/src/canvas/widgets/ComponentWidget.module.css +6 -5
- package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
- package/src/canvas/widgets/LinkPreview.jsx +297 -11
- package/src/canvas/widgets/LinkPreview.module.css +386 -18
- package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
- package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
- package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
- package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +277 -0
- package/src/canvas/widgets/StoryWidget.module.css +211 -0
- package/src/canvas/widgets/WidgetChrome.jsx +76 -20
- package/src/canvas/widgets/WidgetChrome.module.css +2 -6
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/codepenUrl.js +75 -0
- package/src/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/embedTheme.js +138 -39
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +34 -12
- package/src/context.jsx +145 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/hooks/useThemeState.js +61 -0
- package/src/hooks/useThemeState.test.js +66 -0
- package/src/index.js +10 -0
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +348 -66
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import { flushSync } from 'react-dom'
|
|
1
|
+
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
3
2
|
import { Canvas } from '@dfosco/tiny-canvas'
|
|
4
3
|
import '@dfosco/tiny-canvas/style.css'
|
|
5
4
|
import { useCanvas } from './useCanvas.js'
|
|
@@ -8,28 +7,58 @@ import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
|
|
|
8
7
|
import { getWidgetComponent } from './widgets/index.js'
|
|
9
8
|
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
10
9
|
import { getFeatures, isResizable } from './widgets/widgetConfig.js'
|
|
11
|
-
import {
|
|
10
|
+
import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
|
|
11
|
+
import { getPasteRules } from '@dfosco/storyboard-core'
|
|
12
|
+
import { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
|
|
13
|
+
import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
|
|
12
14
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
13
15
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
14
16
|
import useUndoRedo from './useUndoRedo.js'
|
|
15
|
-
import
|
|
17
|
+
import useMarqueeSelect from './useMarqueeSelect.js'
|
|
18
|
+
import MarqueeOverlay from './MarqueeOverlay.jsx'
|
|
19
|
+
import {
|
|
20
|
+
addWidget as addWidgetApi,
|
|
21
|
+
checkGitHubCliAvailable,
|
|
22
|
+
fetchGitHubEmbed,
|
|
23
|
+
getCanvas as getCanvasApi,
|
|
24
|
+
removeWidget as removeWidgetApi,
|
|
25
|
+
updateCanvas,
|
|
26
|
+
uploadImage,
|
|
27
|
+
} from './canvasApi.js'
|
|
28
|
+
import PageSelector from './PageSelector.jsx'
|
|
29
|
+
import Icon from '../Icon.jsx'
|
|
30
|
+
import { stories as storyIndex } from 'virtual:storyboard-data-index'
|
|
16
31
|
import styles from './CanvasPage.module.css'
|
|
17
32
|
|
|
18
33
|
const ZOOM_MIN = 25
|
|
19
34
|
const ZOOM_MAX = 200
|
|
20
35
|
|
|
36
|
+
/** Saved viewport state older than this is considered stale — zoom-to-fit instead. */
|
|
37
|
+
const VIEWPORT_TTL_MS = 15 * 60 * 1000
|
|
38
|
+
|
|
21
39
|
const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
|
|
40
|
+
const GH_INSTALL_URL = 'https://github.com/cli/cli'
|
|
41
|
+
|
|
42
|
+
registerSmoothCorners()
|
|
22
43
|
|
|
23
44
|
/** Matches branch-deploy base path prefixes like /branch--my-feature/ */
|
|
24
45
|
const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
|
|
25
46
|
|
|
47
|
+
// Build a reverse map from story route paths → { storyId, route }
|
|
48
|
+
const storyRouteIndex = new Map()
|
|
49
|
+
for (const [storyId, data] of Object.entries(storyIndex || {})) {
|
|
50
|
+
if (data?._route) {
|
|
51
|
+
storyRouteIndex.set(data._route.replace(/\/+$/, ''), storyId)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
26
55
|
function getToolbarColorMode(theme) {
|
|
27
56
|
return String(theme || 'light').startsWith('dark') ? 'dark' : 'light'
|
|
28
57
|
}
|
|
29
58
|
|
|
30
59
|
function resolveCanvasThemeFromStorage() {
|
|
31
60
|
if (typeof localStorage === 'undefined') return 'light'
|
|
32
|
-
let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas:
|
|
61
|
+
let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: true }
|
|
33
62
|
try {
|
|
34
63
|
const rawSync = localStorage.getItem('sb-theme-sync')
|
|
35
64
|
if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
|
|
@@ -51,6 +80,7 @@ function resolveCanvasThemeFromStorage() {
|
|
|
51
80
|
* Get the copyable URL for a widget based on its type.
|
|
52
81
|
* Returns the most relevant URL/path for the widget content.
|
|
53
82
|
*/
|
|
83
|
+
// eslint-disable-next-line no-unused-vars
|
|
54
84
|
function getWidgetCopyableUrl(widget) {
|
|
55
85
|
const { type, props = {} } = widget
|
|
56
86
|
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
@@ -91,15 +121,20 @@ function debounce(fn, ms) {
|
|
|
91
121
|
}
|
|
92
122
|
|
|
93
123
|
/** Per-canvas viewport state persistence (zoom + scroll position). */
|
|
94
|
-
function getViewportStorageKey(
|
|
95
|
-
return `sb-canvas-viewport:${
|
|
124
|
+
function getViewportStorageKey(canvasId) {
|
|
125
|
+
return `sb-canvas-viewport:${canvasId}`
|
|
96
126
|
}
|
|
97
127
|
|
|
98
|
-
function loadViewportState(
|
|
128
|
+
function loadViewportState(canvasId) {
|
|
99
129
|
try {
|
|
100
|
-
const raw = localStorage.getItem(getViewportStorageKey(
|
|
130
|
+
const raw = localStorage.getItem(getViewportStorageKey(canvasId))
|
|
101
131
|
if (!raw) return null
|
|
102
132
|
const state = JSON.parse(raw)
|
|
133
|
+
const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
|
|
134
|
+
if (Date.now() - timestamp > VIEWPORT_TTL_MS) {
|
|
135
|
+
localStorage.removeItem(getViewportStorageKey(canvasId))
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
103
138
|
return {
|
|
104
139
|
zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
|
|
105
140
|
scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
|
|
@@ -108,9 +143,12 @@ function loadViewportState(canvasName) {
|
|
|
108
143
|
} catch { return null }
|
|
109
144
|
}
|
|
110
145
|
|
|
111
|
-
function saveViewportState(
|
|
146
|
+
function saveViewportState(canvasId, state) {
|
|
112
147
|
try {
|
|
113
|
-
localStorage.setItem(getViewportStorageKey(
|
|
148
|
+
localStorage.setItem(getViewportStorageKey(canvasId), JSON.stringify({
|
|
149
|
+
...state,
|
|
150
|
+
timestamp: Date.now(),
|
|
151
|
+
}))
|
|
114
152
|
} catch { /* quota exceeded — non-critical */ }
|
|
115
153
|
}
|
|
116
154
|
|
|
@@ -226,7 +264,7 @@ function computeCanvasBounds(widgets, componentEntries) {
|
|
|
226
264
|
}
|
|
227
265
|
|
|
228
266
|
/** Renders a single JSON-defined widget by type lookup. */
|
|
229
|
-
function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
267
|
+
function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub }) {
|
|
230
268
|
const Component = getWidgetComponent(widget.type)
|
|
231
269
|
if (!Component) {
|
|
232
270
|
console.warn(`[canvas] Unknown widget type: ${widget.type}`)
|
|
@@ -234,7 +272,14 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
|
234
272
|
}
|
|
235
273
|
const resizable = isResizable(widget.type) && !!onUpdate
|
|
236
274
|
// Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
|
|
237
|
-
const elementProps = {
|
|
275
|
+
const elementProps = {
|
|
276
|
+
id: widget.id,
|
|
277
|
+
props: widget.props,
|
|
278
|
+
onUpdate,
|
|
279
|
+
resizable,
|
|
280
|
+
onRefreshGitHub,
|
|
281
|
+
canRefreshGitHub,
|
|
282
|
+
}
|
|
238
283
|
if (Component.$$typeof === Symbol.for('react.forward_ref')) {
|
|
239
284
|
elementProps.ref = widgetRef
|
|
240
285
|
}
|
|
@@ -244,8 +289,10 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
|
244
289
|
/**
|
|
245
290
|
* Wrapper for each JSON widget that holds its own ref for imperative actions.
|
|
246
291
|
* This allows WidgetChrome to dispatch actions to the widget via ref.
|
|
292
|
+
*
|
|
293
|
+
* Memoized to prevent re-renders during zoom and unrelated state changes.
|
|
247
294
|
*/
|
|
248
|
-
function ChromeWrappedWidget({
|
|
295
|
+
const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
249
296
|
widget,
|
|
250
297
|
selected,
|
|
251
298
|
multiSelected,
|
|
@@ -254,18 +301,64 @@ function ChromeWrappedWidget({
|
|
|
254
301
|
onUpdate,
|
|
255
302
|
onRemove,
|
|
256
303
|
onCopy,
|
|
304
|
+
onRefreshGitHub,
|
|
305
|
+
canRefreshGitHub,
|
|
257
306
|
readOnly,
|
|
258
307
|
}) {
|
|
259
308
|
const widgetRef = useRef(null)
|
|
260
|
-
const
|
|
309
|
+
const rawFeatures = getFeatures(widget.type, { isLocalDev: !readOnly })
|
|
310
|
+
|
|
311
|
+
// Dynamically adjust features based on widget state
|
|
312
|
+
const features = useMemo(() => {
|
|
313
|
+
const isGitHub = !!widget.props?.github
|
|
314
|
+
return rawFeatures.map((f) => {
|
|
315
|
+
// Toggle collapse label and hide when content is short (no github = no collapse)
|
|
316
|
+
if (f.action === 'toggle-collapse') {
|
|
317
|
+
if (!isGitHub) return null
|
|
318
|
+
return {
|
|
319
|
+
...f,
|
|
320
|
+
label: widget.props?.collapsed ? 'Expand height' : 'Collapse height',
|
|
321
|
+
icon: widget.props?.collapsed ? 'unfold' : 'fold',
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// Hide refresh-github for non-GitHub link previews
|
|
325
|
+
if (f.action === 'refresh-github' && !isGitHub) return null
|
|
326
|
+
return f
|
|
327
|
+
}).filter(Boolean)
|
|
328
|
+
}, [rawFeatures, widget.props?.github, widget.props?.collapsed])
|
|
261
329
|
|
|
262
330
|
const handleAction = useCallback((actionId) => {
|
|
263
331
|
if (actionId === 'delete') {
|
|
264
332
|
onRemove?.(widget.id)
|
|
265
333
|
} else if (actionId === 'copy') {
|
|
266
334
|
onCopy?.(widget)
|
|
335
|
+
} else if (actionId === 'copy-text') {
|
|
336
|
+
const title = widget.props?.title || ''
|
|
337
|
+
const body = widget.props?.text || widget.props?.content || widget.props?.github?.body || ''
|
|
338
|
+
const text = title && body ? `# ${title}\n\n${body}` : title || body
|
|
339
|
+
navigator.clipboard?.writeText(text).catch(() => {})
|
|
340
|
+
} else if (actionId === 'open-external') {
|
|
341
|
+
const url = widget.props?.url || widget.props?.src
|
|
342
|
+
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
343
|
+
} else if (actionId === 'refresh-github') {
|
|
344
|
+
const url = widget.props?.url
|
|
345
|
+
if (url && onRefreshGitHub) onRefreshGitHub(widget.id, url)
|
|
346
|
+
} else if (actionId === 'toggle-collapse') {
|
|
347
|
+
const wasCollapsed = !!widget.props?.collapsed
|
|
348
|
+
onUpdate?.(widget.id, { collapsed: !wasCollapsed })
|
|
349
|
+
// When collapsing, pan viewport to center the widget
|
|
350
|
+
if (!wasCollapsed) {
|
|
351
|
+
requestAnimationFrame(() => {
|
|
352
|
+
const el = document.getElementById(widget.id)
|
|
353
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
354
|
+
})
|
|
355
|
+
}
|
|
267
356
|
}
|
|
268
|
-
}, [widget, onRemove, onCopy])
|
|
357
|
+
}, [widget, onRemove, onCopy, onRefreshGitHub])
|
|
358
|
+
|
|
359
|
+
const handleWidgetFieldUpdate = useCallback((updates) => {
|
|
360
|
+
onUpdate?.(widget.id, updates)
|
|
361
|
+
}, [onUpdate, widget.id])
|
|
269
362
|
|
|
270
363
|
return (
|
|
271
364
|
<WidgetChrome
|
|
@@ -279,43 +372,68 @@ function ChromeWrappedWidget({
|
|
|
279
372
|
onSelect={onSelect}
|
|
280
373
|
onDeselect={onDeselect}
|
|
281
374
|
onAction={handleAction}
|
|
282
|
-
onUpdate={onUpdate ?
|
|
375
|
+
onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
|
|
283
376
|
readOnly={readOnly}
|
|
284
377
|
>
|
|
285
378
|
<WidgetRenderer
|
|
286
379
|
widget={widget}
|
|
287
|
-
onUpdate={onUpdate ?
|
|
380
|
+
onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
|
|
288
381
|
widgetRef={widgetRef}
|
|
382
|
+
onRefreshGitHub={onRefreshGitHub}
|
|
383
|
+
canRefreshGitHub={canRefreshGitHub}
|
|
289
384
|
/>
|
|
290
385
|
</WidgetChrome>
|
|
291
386
|
)
|
|
292
|
-
}
|
|
387
|
+
}, function chromeWidgetAreEqual(prev, next) {
|
|
388
|
+
return (
|
|
389
|
+
prev.widget === next.widget &&
|
|
390
|
+
prev.selected === next.selected &&
|
|
391
|
+
prev.multiSelected === next.multiSelected &&
|
|
392
|
+
prev.readOnly === next.readOnly &&
|
|
393
|
+
prev.onSelect === next.onSelect &&
|
|
394
|
+
prev.onDeselect === next.onDeselect &&
|
|
395
|
+
prev.onUpdate === next.onUpdate &&
|
|
396
|
+
prev.onRemove === next.onRemove &&
|
|
397
|
+
prev.onCopy === next.onCopy
|
|
398
|
+
)
|
|
399
|
+
})
|
|
293
400
|
|
|
294
401
|
/**
|
|
295
402
|
* Generic canvas page component.
|
|
296
403
|
* Reads canvas data from the index and renders all widgets on a draggable surface.
|
|
297
404
|
*
|
|
298
|
-
* @param {{
|
|
405
|
+
* @param {{ canvasId: string }} props - Canvas name as indexed by the data plugin
|
|
299
406
|
*/
|
|
300
|
-
export default function CanvasPage({ name }) {
|
|
301
|
-
const
|
|
407
|
+
export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages = [], canvasMeta = null }) {
|
|
408
|
+
const canvasId = canvasIdProp || name || ''
|
|
409
|
+
const { canvas, jsxExports, jsxError, loading } = useCanvas(canvasId)
|
|
302
410
|
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
|
|
303
411
|
|
|
304
412
|
// Local mutable copy of widgets for instant UI updates
|
|
305
413
|
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
306
414
|
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
307
415
|
const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
|
|
308
|
-
const initialViewport = loadViewportState(
|
|
416
|
+
const initialViewport = loadViewportState(canvasId)
|
|
309
417
|
const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
|
|
310
418
|
const zoomRef = useRef(initialViewport?.zoom ?? 100)
|
|
311
419
|
const scrollRef = useRef(null)
|
|
420
|
+
const zoomElRef = useRef(null)
|
|
421
|
+
const zoomCommitTimer = useRef(null)
|
|
422
|
+
const zoomEventTimer = useRef(null)
|
|
312
423
|
const pendingScrollRestore = useRef(initialViewport)
|
|
313
|
-
|
|
314
|
-
|
|
424
|
+
// Gate viewport persistence until initial positioning is complete.
|
|
425
|
+
// Tracks which canvasId was last initialized — save effects only
|
|
426
|
+
// write when this matches `canvasId`, preventing cross-canvas corruption.
|
|
427
|
+
const viewportInitName = useRef(null)
|
|
315
428
|
const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
|
|
316
429
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
317
430
|
const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
|
|
318
431
|
const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
|
|
432
|
+
const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
|
|
433
|
+
|
|
434
|
+
// Refs for snap settings (used by drop handler inside effect closure)
|
|
435
|
+
const snapEnabledRef = useRef(snapEnabled)
|
|
436
|
+
const snapGridSizeRef = useRef(snapGridSize)
|
|
319
437
|
|
|
320
438
|
// Centralized list of component export names.
|
|
321
439
|
// When jsxExports is available, use it (discovers new exports not yet in sources).
|
|
@@ -400,13 +518,13 @@ export default function CanvasPage({ name }) {
|
|
|
400
518
|
// Flag to suppress the click-based selection reset that fires after a drag
|
|
401
519
|
const justDraggedRef = useRef(false)
|
|
402
520
|
|
|
403
|
-
const handleItemDragStart = useCallback((dragId
|
|
521
|
+
const handleItemDragStart = useCallback((dragId) => {
|
|
404
522
|
const ids = selectedIdsRef.current
|
|
405
523
|
peerArticlesRef.current.clear()
|
|
406
524
|
if (ids.size <= 1 || !ids.has(dragId)) return
|
|
407
525
|
|
|
408
526
|
// Suppress selection changes for the duration of the drag
|
|
409
|
-
justDraggedRef.current = true
|
|
527
|
+
justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
|
|
410
528
|
|
|
411
529
|
// Collect peer article elements for transition on drag end
|
|
412
530
|
for (const id of ids) {
|
|
@@ -445,40 +563,28 @@ export default function CanvasPage({ name }) {
|
|
|
445
563
|
setTrackedCanvas(canvas)
|
|
446
564
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
447
565
|
setLocalSources(canvas?.sources ?? [])
|
|
448
|
-
|
|
566
|
+
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
567
|
+
setSnapGridSize(canvas?.gridSize || 40)
|
|
449
568
|
undoRedo.reset()
|
|
569
|
+
// Block saves until the new canvas's viewport is fully restored.
|
|
570
|
+
viewportInitName.current = null
|
|
571
|
+
const newViewport = loadViewportState(canvasId)
|
|
572
|
+
pendingScrollRestore.current = newViewport
|
|
573
|
+
// Restore zoom from the new canvas's saved state
|
|
574
|
+
const newZoom = newViewport?.zoom ?? 100
|
|
575
|
+
zoomRef.current = newZoom
|
|
576
|
+
setZoom(newZoom)
|
|
450
577
|
}
|
|
451
578
|
|
|
452
579
|
// Debounced save to server
|
|
453
580
|
const debouncedSave = useRef(
|
|
454
|
-
debounce((
|
|
455
|
-
updateCanvas(
|
|
581
|
+
debounce((canvasId, widgets) => {
|
|
582
|
+
updateCanvas(canvasId, { widgets }).catch((err) =>
|
|
456
583
|
console.error('[canvas] Failed to save:', err)
|
|
457
584
|
)
|
|
458
585
|
}, 2000)
|
|
459
586
|
).current
|
|
460
587
|
|
|
461
|
-
const debouncedTitleSave = useRef(
|
|
462
|
-
debounce((canvasName, title) => {
|
|
463
|
-
updateCanvas(canvasName, { settings: { title } }).catch((err) =>
|
|
464
|
-
console.error('[canvas] Failed to save title:', err)
|
|
465
|
-
)
|
|
466
|
-
}, 1000)
|
|
467
|
-
).current
|
|
468
|
-
|
|
469
|
-
const handleTitleChange = useCallback((e) => {
|
|
470
|
-
const newTitle = e.target.value
|
|
471
|
-
setCanvasTitle(newTitle)
|
|
472
|
-
debouncedTitleSave(name, newTitle)
|
|
473
|
-
}, [name, debouncedTitleSave])
|
|
474
|
-
|
|
475
|
-
const handleTitleKeyDown = useCallback((e) => {
|
|
476
|
-
if (e.key === 'Enter') {
|
|
477
|
-
e.target.blur()
|
|
478
|
-
}
|
|
479
|
-
e.stopPropagation()
|
|
480
|
-
}, [])
|
|
481
|
-
|
|
482
588
|
const handleWidgetUpdate = useCallback((widgetId, updates) => {
|
|
483
589
|
undoRedo.snapshot(stateRef.current, 'edit', widgetId)
|
|
484
590
|
// Snap width/height to grid when snap is enabled
|
|
@@ -492,20 +598,20 @@ export default function CanvasPage({ name }) {
|
|
|
492
598
|
const next = prev.map((w) =>
|
|
493
599
|
w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
|
|
494
600
|
)
|
|
495
|
-
debouncedSave(
|
|
601
|
+
debouncedSave(canvasId, next)
|
|
496
602
|
return next
|
|
497
603
|
})
|
|
498
|
-
}, [
|
|
604
|
+
}, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
|
|
499
605
|
|
|
500
606
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
501
607
|
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
502
608
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
503
609
|
queueWrite(() =>
|
|
504
|
-
removeWidgetApi(
|
|
610
|
+
removeWidgetApi(canvasId, widgetId).catch((err) =>
|
|
505
611
|
console.error('[canvas] Failed to remove widget:', err)
|
|
506
612
|
)
|
|
507
613
|
)
|
|
508
|
-
}, [
|
|
614
|
+
}, [canvasId, undoRedo])
|
|
509
615
|
|
|
510
616
|
const handleWidgetCopy = useCallback(async (widget) => {
|
|
511
617
|
// Find the next free offset — check how many copies already exist at +n*40
|
|
@@ -521,7 +627,7 @@ export default function CanvasPage({ name }) {
|
|
|
521
627
|
const position = { x: baseX + n * 40, y: baseY + n * 40 }
|
|
522
628
|
try {
|
|
523
629
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
524
|
-
const result = await addWidgetApi(
|
|
630
|
+
const result = await addWidgetApi(canvasId, {
|
|
525
631
|
type: widget.type,
|
|
526
632
|
props: { ...widget.props },
|
|
527
633
|
position,
|
|
@@ -532,11 +638,63 @@ export default function CanvasPage({ name }) {
|
|
|
532
638
|
} catch (err) {
|
|
533
639
|
console.error('[canvas] Failed to copy widget:', err)
|
|
534
640
|
}
|
|
535
|
-
}, [
|
|
641
|
+
}, [canvasId, localWidgets, undoRedo])
|
|
642
|
+
|
|
643
|
+
const showMissingGhBanner = useCallback(() => {
|
|
644
|
+
setShowGhInstallBanner(true)
|
|
645
|
+
}, [])
|
|
646
|
+
|
|
647
|
+
const buildGitHubPreviewUpdates = useCallback(async (url) => {
|
|
648
|
+
try {
|
|
649
|
+
const availability = await checkGitHubCliAvailable()
|
|
650
|
+
if (!availability?.available) {
|
|
651
|
+
showMissingGhBanner()
|
|
652
|
+
return null
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const result = await fetchGitHubEmbed(url)
|
|
656
|
+
if (result?.code === 'gh_unavailable') {
|
|
657
|
+
showMissingGhBanner()
|
|
658
|
+
return null
|
|
659
|
+
}
|
|
660
|
+
if (!result?.success || !result?.snapshot) return null
|
|
661
|
+
|
|
662
|
+
const snapshot = result.snapshot
|
|
663
|
+
return {
|
|
664
|
+
title: snapshot.title || '',
|
|
665
|
+
width: 580,
|
|
666
|
+
height: 400,
|
|
667
|
+
github: {
|
|
668
|
+
kind: snapshot.kind || 'issue',
|
|
669
|
+
parentKind: snapshot.parentKind || snapshot.kind || 'issue',
|
|
670
|
+
context: snapshot.context || '',
|
|
671
|
+
body: snapshot.body || '',
|
|
672
|
+
bodyHtml: snapshot.bodyHtml || '',
|
|
673
|
+
authors: Array.isArray(snapshot.authors)
|
|
674
|
+
? snapshot.authors.filter((author) => typeof author === 'string' && author.trim())
|
|
675
|
+
: [],
|
|
676
|
+
createdAt: snapshot.createdAt ?? null,
|
|
677
|
+
updatedAt: snapshot.updatedAt ?? null,
|
|
678
|
+
fetchedAt: new Date().toISOString(),
|
|
679
|
+
},
|
|
680
|
+
}
|
|
681
|
+
} catch (err) {
|
|
682
|
+
console.error('[canvas] Failed to fetch GitHub embed metadata:', err)
|
|
683
|
+
return null
|
|
684
|
+
}
|
|
685
|
+
}, [showMissingGhBanner])
|
|
686
|
+
|
|
687
|
+
const handleRefreshGitHubWidget = useCallback(async (widgetId, url) => {
|
|
688
|
+
if (!widgetId || !url) return { updated: false }
|
|
689
|
+
const updates = await buildGitHubPreviewUpdates(url)
|
|
690
|
+
if (!updates) return { updated: false }
|
|
691
|
+
handleWidgetUpdate(widgetId, updates)
|
|
692
|
+
return { updated: true }
|
|
693
|
+
}, [buildGitHubPreviewUpdates, handleWidgetUpdate])
|
|
536
694
|
|
|
537
695
|
const debouncedSourceSave = useRef(
|
|
538
|
-
debounce((
|
|
539
|
-
updateCanvas(
|
|
696
|
+
debounce((canvasId, sources) => {
|
|
697
|
+
updateCanvas(canvasId, { sources }).catch((err) =>
|
|
540
698
|
console.error('[canvas] Failed to save sources:', err)
|
|
541
699
|
)
|
|
542
700
|
}, 2000)
|
|
@@ -554,10 +712,10 @@ export default function CanvasPage({ name }) {
|
|
|
554
712
|
const next = current.some((s) => s?.export === exportName)
|
|
555
713
|
? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
|
|
556
714
|
: [...current, { export: exportName, ...snapped }]
|
|
557
|
-
debouncedSourceSave(
|
|
715
|
+
debouncedSourceSave(canvasId, next)
|
|
558
716
|
return next
|
|
559
717
|
})
|
|
560
|
-
}, [
|
|
718
|
+
}, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
|
|
561
719
|
|
|
562
720
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
563
721
|
if (!dragId || !position) {
|
|
@@ -572,7 +730,7 @@ export default function CanvasPage({ name }) {
|
|
|
572
730
|
if (ids.size > 1 && ids.has(dragId)) {
|
|
573
731
|
transitionPeers()
|
|
574
732
|
// Suppress the click-based selection reset that fires after pointerup
|
|
575
|
-
justDraggedRef.current = true
|
|
733
|
+
justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
|
|
576
734
|
requestAnimationFrame(() => { justDraggedRef.current = false })
|
|
577
735
|
undoRedo.snapshot(stateRef.current, 'multi-move')
|
|
578
736
|
|
|
@@ -609,7 +767,7 @@ export default function CanvasPage({ name }) {
|
|
|
609
767
|
return w
|
|
610
768
|
})
|
|
611
769
|
queueWrite(() =>
|
|
612
|
-
updateCanvas(
|
|
770
|
+
updateCanvas(canvasId, { widgets: next }).catch((err) =>
|
|
613
771
|
console.error('[canvas] Failed to save multi-move:', err)
|
|
614
772
|
)
|
|
615
773
|
)
|
|
@@ -641,7 +799,7 @@ export default function CanvasPage({ name }) {
|
|
|
641
799
|
})
|
|
642
800
|
if (changed) {
|
|
643
801
|
queueWrite(() =>
|
|
644
|
-
updateCanvas(
|
|
802
|
+
updateCanvas(canvasId, { sources: next }).catch((err) =>
|
|
645
803
|
console.error('[canvas] Failed to save multi-move sources:', err)
|
|
646
804
|
)
|
|
647
805
|
)
|
|
@@ -660,7 +818,7 @@ export default function CanvasPage({ name }) {
|
|
|
660
818
|
? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
|
|
661
819
|
: [...current, { export: sourceExport, position: rounded }]
|
|
662
820
|
queueWrite(() =>
|
|
663
|
-
updateCanvas(
|
|
821
|
+
updateCanvas(canvasId, { sources: next }).catch((err) =>
|
|
664
822
|
console.error('[canvas] Failed to save source position:', err)
|
|
665
823
|
)
|
|
666
824
|
)
|
|
@@ -676,28 +834,65 @@ export default function CanvasPage({ name }) {
|
|
|
676
834
|
w.id === dragId ? { ...w, position: rounded } : w
|
|
677
835
|
)
|
|
678
836
|
queueWrite(() =>
|
|
679
|
-
updateCanvas(
|
|
837
|
+
updateCanvas(canvasId, { widgets: next }).catch((err) =>
|
|
680
838
|
console.error('[canvas] Failed to save widget position:', err)
|
|
681
839
|
)
|
|
682
840
|
)
|
|
683
841
|
return next
|
|
684
842
|
})
|
|
685
|
-
}, [
|
|
843
|
+
}, [canvasId, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
|
|
686
844
|
|
|
845
|
+
// Keep zoomRef in sync when React state is set (e.g. by toolbar or zoom-to-fit)
|
|
687
846
|
useEffect(() => {
|
|
688
847
|
zoomRef.current = zoom
|
|
689
848
|
}, [zoom])
|
|
690
849
|
|
|
691
|
-
//
|
|
850
|
+
// Cleanup zoom timers on unmount
|
|
851
|
+
useEffect(() => () => {
|
|
852
|
+
clearTimeout(zoomCommitTimer.current)
|
|
853
|
+
clearTimeout(zoomEventTimer.current)
|
|
854
|
+
}, [])
|
|
855
|
+
|
|
856
|
+
// Restore scroll position from localStorage after first render.
|
|
857
|
+
// When saved state is fresh (< 15 min), restore it. Otherwise zoom-to-fit
|
|
858
|
+
// all objects so the user sees a useful overview instead of stale coordinates.
|
|
692
859
|
useEffect(() => {
|
|
693
860
|
const el = scrollRef.current
|
|
861
|
+
if (!el || loading) return
|
|
694
862
|
const saved = pendingScrollRestore.current
|
|
695
|
-
if (
|
|
863
|
+
if (saved) {
|
|
864
|
+
// Fresh saved viewport — restore exactly
|
|
696
865
|
if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
|
|
697
866
|
if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
|
|
698
867
|
pendingScrollRestore.current = null
|
|
868
|
+
} else {
|
|
869
|
+
// No saved state or stale — zoom-to-fit all objects
|
|
870
|
+
const bounds = computeCanvasBounds(localWidgets, componentEntries)
|
|
871
|
+
if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
|
|
872
|
+
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
873
|
+
const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
|
|
874
|
+
const fitScale = Math.min(el.clientWidth / boxW, el.clientHeight / boxH)
|
|
875
|
+
const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
|
|
876
|
+
const newScale = fitZoom / 100
|
|
877
|
+
zoomRef.current = fitZoom
|
|
878
|
+
// Imperative DOM update for initial zoom-to-fit — same path as applyZoom
|
|
879
|
+
const zoomEl = zoomElRef.current
|
|
880
|
+
if (zoomEl) {
|
|
881
|
+
zoomEl.style.transform = `scale(${newScale})`
|
|
882
|
+
zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
|
|
883
|
+
zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
|
|
884
|
+
}
|
|
885
|
+
setZoom(fitZoom)
|
|
886
|
+
el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
|
|
887
|
+
el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
|
|
888
|
+
} else {
|
|
889
|
+
el.scrollLeft = 0
|
|
890
|
+
el.scrollTop = 0
|
|
891
|
+
}
|
|
699
892
|
}
|
|
700
|
-
|
|
893
|
+
// Allow save effects for this canvas now that positioning is settled.
|
|
894
|
+
viewportInitName.current = canvasId
|
|
895
|
+
}, [canvasId, loading])
|
|
701
896
|
|
|
702
897
|
// Center on a specific widget if `?widget=<id>` is in the URL
|
|
703
898
|
useEffect(() => {
|
|
@@ -746,55 +941,76 @@ export default function CanvasPage({ name }) {
|
|
|
746
941
|
window.history.replaceState({}, '', url.toString())
|
|
747
942
|
}, [loading, localWidgets, componentEntries])
|
|
748
943
|
|
|
749
|
-
// Persist viewport state (zoom
|
|
944
|
+
// Persist viewport state (zoom only) to localStorage on zoom changes.
|
|
945
|
+
// Scroll position is persisted separately by the debounced scroll handler,
|
|
946
|
+
// cleanup handler, and beforeunload — never here, because imperative zoom
|
|
947
|
+
// operations (applyZoom, zoom-to-fit) adjust scroll AFTER setZoom, so the
|
|
948
|
+
// scroll values would be stale at this point.
|
|
750
949
|
useEffect(() => {
|
|
950
|
+
if (viewportInitName.current !== canvasId) return
|
|
751
951
|
const el = scrollRef.current
|
|
752
|
-
|
|
952
|
+
// Read current scroll so the zoom entry doesn't zero-out position,
|
|
953
|
+
// but the authoritative scroll save comes from the scroll handler.
|
|
954
|
+
saveViewportState(canvasId, {
|
|
753
955
|
zoom,
|
|
754
956
|
scrollLeft: el?.scrollLeft ?? 0,
|
|
755
957
|
scrollTop: el?.scrollTop ?? 0,
|
|
756
958
|
})
|
|
757
|
-
}, [
|
|
959
|
+
}, [canvasId, zoom])
|
|
758
960
|
|
|
759
961
|
useEffect(() => {
|
|
760
962
|
const el = scrollRef.current
|
|
761
963
|
if (!el) return
|
|
762
|
-
|
|
763
|
-
|
|
964
|
+
const saveNow = () => {
|
|
965
|
+
if (viewportInitName.current !== canvasId) return
|
|
966
|
+
saveViewportState(canvasId, {
|
|
764
967
|
zoom: zoomRef.current,
|
|
765
968
|
scrollLeft: el.scrollLeft,
|
|
766
969
|
scrollTop: el.scrollTop,
|
|
767
970
|
})
|
|
768
971
|
}
|
|
972
|
+
const debouncedScrollSave = debounce(saveNow, 150)
|
|
973
|
+
function handleScroll() {
|
|
974
|
+
if (viewportInitName.current !== canvasId) return
|
|
975
|
+
debouncedScrollSave()
|
|
976
|
+
}
|
|
769
977
|
el.addEventListener('scroll', handleScroll, { passive: true })
|
|
770
978
|
|
|
771
979
|
// Flush viewport state on page unload so a refresh never misses it
|
|
772
980
|
function handleBeforeUnload() {
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
scrollLeft: el.scrollLeft,
|
|
776
|
-
scrollTop: el.scrollTop,
|
|
777
|
-
})
|
|
981
|
+
debouncedScrollSave.cancel()
|
|
982
|
+
saveNow()
|
|
778
983
|
}
|
|
779
984
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
780
985
|
|
|
781
986
|
return () => {
|
|
987
|
+
debouncedScrollSave.cancel()
|
|
782
988
|
el.removeEventListener('scroll', handleScroll)
|
|
783
989
|
window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
990
|
+
// Save final state on cleanup (covers SPA navigation where
|
|
991
|
+
// beforeunload doesn't fire).
|
|
992
|
+
saveNow()
|
|
784
993
|
}
|
|
785
|
-
}, [
|
|
994
|
+
}, [canvasId, loading])
|
|
786
995
|
|
|
787
996
|
/**
|
|
788
997
|
* Zoom to a new level, anchoring on an optional client-space point.
|
|
789
998
|
* When a cursor position is provided (e.g. from a wheel event), the
|
|
790
999
|
* canvas point under the cursor stays fixed. Otherwise falls back to
|
|
791
1000
|
* the viewport center.
|
|
1001
|
+
*
|
|
1002
|
+
* Performs an imperative DOM mutation instead of a React state update
|
|
1003
|
+
* to avoid triggering a full re-render of the widget tree on every
|
|
1004
|
+
* zoom tick. React state is committed after a debounce for toolbar
|
|
1005
|
+
* display updates.
|
|
792
1006
|
*/
|
|
793
1007
|
function applyZoom(newZoom, clientX, clientY) {
|
|
794
1008
|
const el = scrollRef.current
|
|
1009
|
+
const zoomEl = zoomElRef.current
|
|
795
1010
|
const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
|
|
796
1011
|
|
|
797
|
-
if (!el) {
|
|
1012
|
+
if (!el || !zoomEl) {
|
|
1013
|
+
zoomRef.current = clampedZoom
|
|
798
1014
|
setZoom(clampedZoom)
|
|
799
1015
|
return
|
|
800
1016
|
}
|
|
@@ -812,24 +1028,48 @@ export default function CanvasPage({ name }) {
|
|
|
812
1028
|
const canvasX = (el.scrollLeft + anchorX) / oldScale
|
|
813
1029
|
const canvasY = (el.scrollTop + anchorY) / oldScale
|
|
814
1030
|
|
|
815
|
-
//
|
|
1031
|
+
// Imperative DOM update — no React re-render
|
|
816
1032
|
zoomRef.current = clampedZoom
|
|
817
|
-
|
|
1033
|
+
zoomEl.style.transform = `scale(${newScale})`
|
|
1034
|
+
zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
|
|
1035
|
+
zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
|
|
1036
|
+
|
|
1037
|
+
// Hint GPU compositing during active zoom
|
|
1038
|
+
zoomEl.dataset.zooming = ''
|
|
818
1039
|
|
|
819
1040
|
// Scroll so the same canvas point stays under the anchor
|
|
820
1041
|
el.scrollLeft = canvasX * newScale - anchorX
|
|
821
1042
|
el.scrollTop = canvasY * newScale - anchorY
|
|
1043
|
+
|
|
1044
|
+
// Debounced commit: update React state for toolbar display + persistence
|
|
1045
|
+
clearTimeout(zoomCommitTimer.current)
|
|
1046
|
+
zoomCommitTimer.current = setTimeout(() => {
|
|
1047
|
+
// Remove GPU compositing hint
|
|
1048
|
+
delete zoomEl.dataset.zooming
|
|
1049
|
+
setZoom(clampedZoom)
|
|
1050
|
+
}, 150)
|
|
1051
|
+
|
|
1052
|
+
// Throttled zoom-changed event for external consumers (toolbar)
|
|
1053
|
+
if (!zoomEventTimer.current) {
|
|
1054
|
+
zoomEventTimer.current = setTimeout(() => {
|
|
1055
|
+
zoomEventTimer.current = null
|
|
1056
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
|
|
1057
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
1058
|
+
detail: { zoom: zoomRef.current }
|
|
1059
|
+
}))
|
|
1060
|
+
}, 100)
|
|
1061
|
+
}
|
|
822
1062
|
}
|
|
823
1063
|
|
|
824
1064
|
// Signal canvas mount/unmount to CoreUIBar
|
|
825
1065
|
useEffect(() => {
|
|
826
|
-
window[CANVAS_BRIDGE_STATE_KEY] = { active: true,
|
|
1066
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
|
|
827
1067
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
|
|
828
|
-
detail: {
|
|
1068
|
+
detail: { canvasId, zoom: zoomRef.current }
|
|
829
1069
|
}))
|
|
830
1070
|
|
|
831
1071
|
function handleStatusRequest() {
|
|
832
|
-
const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true,
|
|
1072
|
+
const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true, canvasId, zoom: zoomRef.current }
|
|
833
1073
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:status', { detail: state }))
|
|
834
1074
|
}
|
|
835
1075
|
|
|
@@ -837,10 +1077,10 @@ export default function CanvasPage({ name }) {
|
|
|
837
1077
|
|
|
838
1078
|
return () => {
|
|
839
1079
|
document.removeEventListener('storyboard:canvas:status-request', handleStatusRequest)
|
|
840
|
-
window[CANVAS_BRIDGE_STATE_KEY] = { active: false,
|
|
1080
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: false, canvasId: '', zoom: 100 }
|
|
841
1081
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:unmounted'))
|
|
842
1082
|
}
|
|
843
|
-
}, [
|
|
1083
|
+
}, [canvasId])
|
|
844
1084
|
|
|
845
1085
|
// Tell the Vite dev server to suppress full-reloads while this canvas is active.
|
|
846
1086
|
// The ?canvas-hmr URL param opts out of the guard for canvas UI development.
|
|
@@ -860,7 +1100,70 @@ export default function CanvasPage({ name }) {
|
|
|
860
1100
|
clearInterval(interval)
|
|
861
1101
|
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
|
|
862
1102
|
}
|
|
863
|
-
}, [
|
|
1103
|
+
}, [canvasId])
|
|
1104
|
+
|
|
1105
|
+
// --- Selected widgets bridge ---
|
|
1106
|
+
// Writes .selectedwidgets.json so Copilot knows which canvas/widgets are active.
|
|
1107
|
+
// Uses a stable tabId to survive WebSocket reconnects.
|
|
1108
|
+
const selectionTabIdRef = useRef(Math.random().toString(36).slice(2, 10))
|
|
1109
|
+
|
|
1110
|
+
// Gather selected widget data from refs (safe for callbacks/timeouts)
|
|
1111
|
+
const getSelectedWidgetData = useCallback(() => {
|
|
1112
|
+
const ids = [...selectedIdsRef.current]
|
|
1113
|
+
const widgets = (stateRef.current.widgets || [])
|
|
1114
|
+
.filter(w => ids.includes(w.id))
|
|
1115
|
+
.map(w => ({ id: w.id, type: w.type, props: w.props }))
|
|
1116
|
+
|
|
1117
|
+
// Include jsx-* component selections
|
|
1118
|
+
for (const id of ids) {
|
|
1119
|
+
if (id.startsWith('jsx-') && !widgets.some(w => w.id === id)) {
|
|
1120
|
+
widgets.push({ id, type: 'component', props: { exportName: id.slice(4) } })
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
return { widgetIds: ids, widgets }
|
|
1125
|
+
}, [])
|
|
1126
|
+
|
|
1127
|
+
// Send focus event on mount, tab focus, and visibility change
|
|
1128
|
+
useEffect(() => {
|
|
1129
|
+
if (!import.meta.hot) return
|
|
1130
|
+
|
|
1131
|
+
const tabId = selectionTabIdRef.current
|
|
1132
|
+
|
|
1133
|
+
function sendFocus() {
|
|
1134
|
+
const { widgetIds, widgets } = getSelectedWidgetData()
|
|
1135
|
+
import.meta.hot.send('storyboard:canvas-focused', { tabId, canvasId, widgetIds, widgets })
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
sendFocus()
|
|
1139
|
+
|
|
1140
|
+
function handleVisibility() {
|
|
1141
|
+
if (!document.hidden) sendFocus()
|
|
1142
|
+
}
|
|
1143
|
+
function handleFocus() { sendFocus() }
|
|
1144
|
+
|
|
1145
|
+
document.addEventListener('visibilitychange', handleVisibility)
|
|
1146
|
+
window.addEventListener('focus', handleFocus)
|
|
1147
|
+
|
|
1148
|
+
return () => {
|
|
1149
|
+
document.removeEventListener('visibilitychange', handleVisibility)
|
|
1150
|
+
window.removeEventListener('focus', handleFocus)
|
|
1151
|
+
import.meta.hot.send('storyboard:canvas-unfocused', { tabId })
|
|
1152
|
+
}
|
|
1153
|
+
}, [canvasId, getSelectedWidgetData])
|
|
1154
|
+
|
|
1155
|
+
// Debounced selection change (500ms) — reads from refs at fire time
|
|
1156
|
+
useEffect(() => {
|
|
1157
|
+
if (!import.meta.hot) return
|
|
1158
|
+
|
|
1159
|
+
const tabId = selectionTabIdRef.current
|
|
1160
|
+
const timer = setTimeout(() => {
|
|
1161
|
+
const { widgetIds, widgets } = getSelectedWidgetData()
|
|
1162
|
+
import.meta.hot.send('storyboard:selection-changed', { tabId, canvasId, widgetIds: widgetIds, widgets })
|
|
1163
|
+
}, 500)
|
|
1164
|
+
|
|
1165
|
+
return () => clearTimeout(timer)
|
|
1166
|
+
}, [selectedWidgetIds, canvasId, getSelectedWidgetData])
|
|
864
1167
|
|
|
865
1168
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
866
1169
|
const addWidget = useCallback(async (type) => {
|
|
@@ -868,7 +1171,7 @@ export default function CanvasPage({ name }) {
|
|
|
868
1171
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
869
1172
|
const pos = centerPositionForWidget(center, type, defaultProps)
|
|
870
1173
|
try {
|
|
871
|
-
const result = await addWidgetApi(
|
|
1174
|
+
const result = await addWidgetApi(canvasId, {
|
|
872
1175
|
type,
|
|
873
1176
|
props: defaultProps,
|
|
874
1177
|
position: pos,
|
|
@@ -880,16 +1183,43 @@ export default function CanvasPage({ name }) {
|
|
|
880
1183
|
} catch (err) {
|
|
881
1184
|
console.error('[canvas] Failed to add widget:', err)
|
|
882
1185
|
}
|
|
883
|
-
}, [
|
|
1186
|
+
}, [canvasId, undoRedo])
|
|
1187
|
+
|
|
1188
|
+
// Add a story widget by storyId — used by CanvasControls story picker
|
|
1189
|
+
const addStoryWidget = useCallback(async (storyId) => {
|
|
1190
|
+
const storyProps = { storyId, exportName: '', width: 600, height: 400 }
|
|
1191
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1192
|
+
const pos = centerPositionForWidget(center, 'story', storyProps)
|
|
1193
|
+
try {
|
|
1194
|
+
const result = await addWidgetApi(canvasId, {
|
|
1195
|
+
type: 'story',
|
|
1196
|
+
props: storyProps,
|
|
1197
|
+
position: pos,
|
|
1198
|
+
})
|
|
1199
|
+
if (result.success && result.widget) {
|
|
1200
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1201
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1202
|
+
}
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
console.error('[canvas] Failed to add story widget:', err)
|
|
1205
|
+
}
|
|
1206
|
+
}, [canvasId, undoRedo])
|
|
884
1207
|
|
|
885
1208
|
// Listen for CoreUIBar add-widget events
|
|
886
1209
|
useEffect(() => {
|
|
887
1210
|
function handleAddWidget(e) {
|
|
888
1211
|
addWidget(e.detail.type)
|
|
889
1212
|
}
|
|
1213
|
+
function handleAddStoryWidget(e) {
|
|
1214
|
+
addStoryWidget(e.detail.storyId)
|
|
1215
|
+
}
|
|
890
1216
|
document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
891
|
-
|
|
892
|
-
|
|
1217
|
+
document.addEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
|
|
1218
|
+
return () => {
|
|
1219
|
+
document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
1220
|
+
document.removeEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
|
|
1221
|
+
}
|
|
1222
|
+
}, [addWidget, addStoryWidget])
|
|
893
1223
|
|
|
894
1224
|
// Listen for zoom changes from CoreUIBar
|
|
895
1225
|
useEffect(() => {
|
|
@@ -908,7 +1238,7 @@ export default function CanvasPage({ name }) {
|
|
|
908
1238
|
function handleSnapToggle() {
|
|
909
1239
|
setSnapEnabled((prev) => {
|
|
910
1240
|
const next = !prev
|
|
911
|
-
updateCanvas(
|
|
1241
|
+
updateCanvas(canvasId, { settings: { snapToGrid: next } }).catch((err) =>
|
|
912
1242
|
console.error('[canvas] Failed to persist snap setting:', err)
|
|
913
1243
|
)
|
|
914
1244
|
return next
|
|
@@ -916,15 +1246,27 @@ export default function CanvasPage({ name }) {
|
|
|
916
1246
|
}
|
|
917
1247
|
document.addEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
918
1248
|
return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
919
|
-
}, [
|
|
1249
|
+
}, [canvasId])
|
|
920
1250
|
|
|
921
1251
|
// Broadcast snap state to Svelte toolbar
|
|
922
1252
|
useEffect(() => {
|
|
923
1253
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
|
|
924
1254
|
detail: { snapEnabled }
|
|
925
1255
|
}))
|
|
1256
|
+
snapEnabledRef.current = snapEnabled
|
|
926
1257
|
}, [snapEnabled])
|
|
927
1258
|
|
|
1259
|
+
// Respond to snap-state requests from Svelte toolbar (handles mount-order race)
|
|
1260
|
+
useEffect(() => {
|
|
1261
|
+
function handleRequest() {
|
|
1262
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
|
|
1263
|
+
detail: { snapEnabled: snapEnabledRef.current }
|
|
1264
|
+
}))
|
|
1265
|
+
}
|
|
1266
|
+
document.addEventListener('storyboard:canvas:snap-state-request', handleRequest)
|
|
1267
|
+
return () => document.removeEventListener('storyboard:canvas:snap-state-request', handleRequest)
|
|
1268
|
+
}, [])
|
|
1269
|
+
|
|
928
1270
|
// Listen for gridSize from Svelte toolbar config
|
|
929
1271
|
useEffect(() => {
|
|
930
1272
|
function handleGridSize(e) {
|
|
@@ -935,6 +1277,11 @@ export default function CanvasPage({ name }) {
|
|
|
935
1277
|
return () => document.removeEventListener('storyboard:canvas:grid-size', handleGridSize)
|
|
936
1278
|
}, [])
|
|
937
1279
|
|
|
1280
|
+
// Keep snapGridSize ref in sync for drop handler
|
|
1281
|
+
useEffect(() => {
|
|
1282
|
+
snapGridSizeRef.current = snapGridSize
|
|
1283
|
+
}, [snapGridSize])
|
|
1284
|
+
|
|
938
1285
|
// Listen for zoom-to-fit from CoreUIBar
|
|
939
1286
|
useEffect(() => {
|
|
940
1287
|
function handleZoomToFit() {
|
|
@@ -955,13 +1302,28 @@ export default function CanvasPage({ name }) {
|
|
|
955
1302
|
const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
|
|
956
1303
|
const newScale = fitZoom / 100
|
|
957
1304
|
|
|
958
|
-
//
|
|
1305
|
+
// Imperative DOM update — same path as applyZoom
|
|
959
1306
|
zoomRef.current = fitZoom
|
|
960
|
-
|
|
1307
|
+
const zoomEl = zoomElRef.current
|
|
1308
|
+
if (zoomEl) {
|
|
1309
|
+
zoomEl.style.transform = `scale(${newScale})`
|
|
1310
|
+
zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
|
|
1311
|
+
zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
|
|
1312
|
+
}
|
|
1313
|
+
setZoom(fitZoom)
|
|
961
1314
|
|
|
962
1315
|
// Scroll so the bounding box top-left (with padding) is at viewport top-left
|
|
963
1316
|
el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
|
|
964
1317
|
el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
|
|
1318
|
+
|
|
1319
|
+
// Persist after both zoom and scroll are settled
|
|
1320
|
+
if (viewportInitName.current === canvasId) {
|
|
1321
|
+
saveViewportState(canvasId, {
|
|
1322
|
+
zoom: fitZoom,
|
|
1323
|
+
scrollLeft: el.scrollLeft,
|
|
1324
|
+
scrollTop: el.scrollTop,
|
|
1325
|
+
})
|
|
1326
|
+
}
|
|
965
1327
|
}
|
|
966
1328
|
document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
967
1329
|
return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
@@ -980,11 +1342,11 @@ export default function CanvasPage({ name }) {
|
|
|
980
1342
|
|
|
981
1343
|
// Broadcast zoom level to CoreUIBar whenever it changes
|
|
982
1344
|
useEffect(() => {
|
|
983
|
-
window[CANVAS_BRIDGE_STATE_KEY] = { active: true,
|
|
1345
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom }
|
|
984
1346
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
985
1347
|
detail: { zoom }
|
|
986
1348
|
}))
|
|
987
|
-
}, [
|
|
1349
|
+
}, [canvasId, zoom])
|
|
988
1350
|
|
|
989
1351
|
// Delete selected widget on Delete/Backspace key
|
|
990
1352
|
useEffect(() => {
|
|
@@ -1006,32 +1368,15 @@ export default function CanvasPage({ name }) {
|
|
|
1006
1368
|
e.preventDefault()
|
|
1007
1369
|
setSelectedWidgetIds(new Set())
|
|
1008
1370
|
}
|
|
1009
|
-
// Copy
|
|
1010
|
-
//
|
|
1011
|
-
// - Shift+C (no cmd) → copy widget ID (or file path for images)
|
|
1371
|
+
// Copy shortcut (one or more widgets selected):
|
|
1372
|
+
// cmd+c → copy canvasId::id1,id2,... (for cross-canvas paste-duplicate)
|
|
1012
1373
|
const mod = e.metaKey || e.ctrlKey
|
|
1013
|
-
if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size
|
|
1014
|
-
|
|
1015
|
-
const
|
|
1016
|
-
if (
|
|
1374
|
+
if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size >= 1) {
|
|
1375
|
+
// Filter out non-duplicable widgets (jsx- component widgets are code)
|
|
1376
|
+
const copyableIds = [...selectedWidgetIds].filter(id => !id.startsWith('jsx-'))
|
|
1377
|
+
if (copyableIds.length > 0) {
|
|
1017
1378
|
e.preventDefault()
|
|
1018
|
-
|
|
1019
|
-
if (url) {
|
|
1020
|
-
navigator.clipboard.writeText(url).catch(() => {})
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
// Shift+C (uppercase C, no cmd) → copy ID or file path
|
|
1025
|
-
if (e.key === 'C' && e.shiftKey && !mod && selectedWidgetIds.size === 1) {
|
|
1026
|
-
const widgetId = [...selectedWidgetIds][0]
|
|
1027
|
-
const widget = localWidgets?.find(w => w.id === widgetId)
|
|
1028
|
-
if (widget) {
|
|
1029
|
-
e.preventDefault()
|
|
1030
|
-
if (widget.type === 'image' && widget.props?.src) {
|
|
1031
|
-
navigator.clipboard.writeText(`src/canvas/images/${widget.props.src}`).catch(() => {})
|
|
1032
|
-
} else {
|
|
1033
|
-
navigator.clipboard.writeText(widgetId).catch(() => {})
|
|
1034
|
-
}
|
|
1379
|
+
navigator.clipboard.writeText(`${canvasId}::${copyableIds.join(',')}`).catch(() => {})
|
|
1035
1380
|
}
|
|
1036
1381
|
}
|
|
1037
1382
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
@@ -1044,7 +1389,7 @@ export default function CanvasPage({ name }) {
|
|
|
1044
1389
|
if (!prev) return prev
|
|
1045
1390
|
const next = prev.filter(w => !selectedWidgetIds.has(w.id))
|
|
1046
1391
|
queueWrite(() =>
|
|
1047
|
-
updateCanvas(
|
|
1392
|
+
updateCanvas(canvasId, { widgets: next }).catch(err =>
|
|
1048
1393
|
console.error('[canvas] Failed to save multi-delete:', err)
|
|
1049
1394
|
)
|
|
1050
1395
|
)
|
|
@@ -1059,50 +1404,17 @@ export default function CanvasPage({ name }) {
|
|
|
1059
1404
|
}
|
|
1060
1405
|
document.addEventListener('keydown', handleKeyDown)
|
|
1061
1406
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
1062
|
-
}, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo,
|
|
1407
|
+
}, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, debouncedSave])
|
|
1063
1408
|
|
|
1064
|
-
//
|
|
1409
|
+
// Ref to store processImageFile for use by drop effect
|
|
1410
|
+
const processImageFileRef = useRef(null)
|
|
1411
|
+
|
|
1412
|
+
// Paste and drop handler — images become image widgets, same-origin URLs become prototypes,
|
|
1065
1413
|
// other URLs become link previews, text becomes markdown
|
|
1066
1414
|
useEffect(() => {
|
|
1067
1415
|
const origin = window.location.origin
|
|
1068
1416
|
const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1071
|
-
// Check if a URL is same-origin, accounting for branch-deploy prefixes.
|
|
1072
|
-
// e.g. https://site.com/branch--my-feature/Proto and https://site.com/storyboard/Proto
|
|
1073
|
-
// are both same-origin prototype URLs.
|
|
1074
|
-
function isSameOriginPrototype(url) {
|
|
1075
|
-
if (!url.startsWith(origin)) return false
|
|
1076
|
-
if (url.startsWith(baseUrl)) return true
|
|
1077
|
-
// Match branch deploy URLs: origin + /branch--*/...
|
|
1078
|
-
const pathAfterOrigin = url.slice(origin.length)
|
|
1079
|
-
return BRANCH_PREFIX_RE.test(pathAfterOrigin)
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
// Strip the base path (or any branch prefix) from a pathname to get a portable src.
|
|
1083
|
-
function extractPrototypeSrc(pathname) {
|
|
1084
|
-
// Strip current base path
|
|
1085
|
-
if (basePath && pathname.startsWith(basePath)) {
|
|
1086
|
-
return pathname.slice(basePath.length) || '/'
|
|
1087
|
-
}
|
|
1088
|
-
// Strip branch prefix: /branch--name/rest → /rest
|
|
1089
|
-
const branchMatch = pathname.match(BRANCH_PREFIX_RE)
|
|
1090
|
-
if (branchMatch) {
|
|
1091
|
-
return pathname.slice(branchMatch[0].length) || '/'
|
|
1092
|
-
}
|
|
1093
|
-
return pathname
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
/** Parse text as a web URL (http/https only). Returns URL object or null. */
|
|
1097
|
-
function looksLikeWebUrl(text) {
|
|
1098
|
-
try {
|
|
1099
|
-
const url = new URL(text)
|
|
1100
|
-
if (url.protocol === 'http:' || url.protocol === 'https:') return url
|
|
1101
|
-
return null
|
|
1102
|
-
} catch {
|
|
1103
|
-
return null
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1417
|
+
const pasteCtx = createPasteContext(origin, basePath)
|
|
1106
1418
|
|
|
1107
1419
|
function blobToDataUrl(blob) {
|
|
1108
1420
|
return new Promise((resolve, reject) => {
|
|
@@ -1122,6 +1434,59 @@ export default function CanvasPage({ name }) {
|
|
|
1122
1434
|
})
|
|
1123
1435
|
}
|
|
1124
1436
|
|
|
1437
|
+
/**
|
|
1438
|
+
* Process an image file (from paste or drop) and add it as a widget.
|
|
1439
|
+
* @param {File|Blob} file - Image file to process
|
|
1440
|
+
* @param {{ x: number, y: number }|null} position - Drop position, or null to use viewport center
|
|
1441
|
+
*/
|
|
1442
|
+
async function processImageFile(file, position = null) {
|
|
1443
|
+
try {
|
|
1444
|
+
const dataUrl = await blobToDataUrl(file)
|
|
1445
|
+
const { width: natW, height: natH } = await getImageDimensions(dataUrl)
|
|
1446
|
+
|
|
1447
|
+
// Display at 2x retina: halve natural dimensions, then cap at 600px
|
|
1448
|
+
const maxWidth = 600
|
|
1449
|
+
let displayW = Math.round(natW / 2)
|
|
1450
|
+
let displayH = Math.round(natH / 2)
|
|
1451
|
+
if (displayW > maxWidth) {
|
|
1452
|
+
displayH = Math.round(displayH * (maxWidth / displayW))
|
|
1453
|
+
displayW = maxWidth
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const uploadResult = await uploadImage(dataUrl, canvasId)
|
|
1457
|
+
if (!uploadResult.success) {
|
|
1458
|
+
console.error('[canvas] Image upload failed:', uploadResult.error)
|
|
1459
|
+
return false
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// Use provided position or fall back to viewport center
|
|
1463
|
+
let pos
|
|
1464
|
+
if (position) {
|
|
1465
|
+
pos = { x: position.x, y: position.y }
|
|
1466
|
+
} else {
|
|
1467
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1468
|
+
pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
const result = await addWidgetApi(canvasId, {
|
|
1472
|
+
type: 'image',
|
|
1473
|
+
props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
|
|
1474
|
+
position: pos,
|
|
1475
|
+
})
|
|
1476
|
+
if (result.success && result.widget) {
|
|
1477
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1478
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1479
|
+
}
|
|
1480
|
+
return true
|
|
1481
|
+
} catch (err) {
|
|
1482
|
+
console.error('[canvas] Failed to process image:', err)
|
|
1483
|
+
return false
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// Store in ref for use by drag/drop effect
|
|
1488
|
+
processImageFileRef.current = processImageFile
|
|
1489
|
+
|
|
1125
1490
|
async function handleImagePaste(e) {
|
|
1126
1491
|
const items = e.clipboardData?.items
|
|
1127
1492
|
if (!items) return false
|
|
@@ -1133,40 +1498,7 @@ export default function CanvasPage({ name }) {
|
|
|
1133
1498
|
if (!blob) continue
|
|
1134
1499
|
|
|
1135
1500
|
e.preventDefault()
|
|
1136
|
-
|
|
1137
|
-
try {
|
|
1138
|
-
const dataUrl = await blobToDataUrl(blob)
|
|
1139
|
-
const { width: natW, height: natH } = await getImageDimensions(dataUrl)
|
|
1140
|
-
|
|
1141
|
-
// Display at 2x retina: halve natural dimensions, then cap at 600px
|
|
1142
|
-
const maxWidth = 600
|
|
1143
|
-
let displayW = Math.round(natW / 2)
|
|
1144
|
-
let displayH = Math.round(natH / 2)
|
|
1145
|
-
if (displayW > maxWidth) {
|
|
1146
|
-
displayH = Math.round(displayH * (maxWidth / displayW))
|
|
1147
|
-
displayW = maxWidth
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
const uploadResult = await uploadImage(dataUrl, name)
|
|
1151
|
-
if (!uploadResult.success) {
|
|
1152
|
-
console.error('[canvas] Image upload failed:', uploadResult.error)
|
|
1153
|
-
return true
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1157
|
-
const pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
|
|
1158
|
-
const result = await addWidgetApi(name, {
|
|
1159
|
-
type: 'image',
|
|
1160
|
-
props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
|
|
1161
|
-
position: pos,
|
|
1162
|
-
})
|
|
1163
|
-
if (result.success && result.widget) {
|
|
1164
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
1165
|
-
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1166
|
-
}
|
|
1167
|
-
} catch (err) {
|
|
1168
|
-
console.error('[canvas] Failed to paste image:', err)
|
|
1169
|
-
}
|
|
1501
|
+
await processImageFile(blob, null)
|
|
1170
1502
|
return true
|
|
1171
1503
|
}
|
|
1172
1504
|
return false
|
|
@@ -1183,32 +1515,94 @@ export default function CanvasPage({ name }) {
|
|
|
1183
1515
|
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
1184
1516
|
if (!text) return
|
|
1185
1517
|
|
|
1186
|
-
|
|
1518
|
+
// Detect canvasId::widgetId or canvasId::id1,id2,id3 format for widget duplication
|
|
1519
|
+
// Also supports legacy canvasId/widgetId for basenames without slashes,
|
|
1520
|
+
// but only when the second segment looks like a widget ID (type-hash).
|
|
1521
|
+
const widgetRefMatch = text.match(/^(.+)::([^:]+)$/) || (text.indexOf('::') === -1 && text.match(/^([^/]+)\/((?:sticky-note|markdown|prototype|link-preview|figma-embed|component|image)-[a-z0-9]+)$/))
|
|
1522
|
+
if (widgetRefMatch) {
|
|
1523
|
+
e.preventDefault()
|
|
1524
|
+
const [, sourceCanvas, sourceWidgetRef] = widgetRefMatch
|
|
1525
|
+
const sourceWidgetIds = sourceWidgetRef.split(',').filter(id => !id.startsWith('jsx-'))
|
|
1526
|
+
if (sourceWidgetIds.length === 0) return
|
|
1187
1527
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1528
|
+
try {
|
|
1529
|
+
// Resolve source widgets in canvas order
|
|
1530
|
+
let sourceList
|
|
1531
|
+
if (sourceCanvas === canvasId) {
|
|
1532
|
+
sourceList = localWidgets ?? []
|
|
1533
|
+
} else {
|
|
1534
|
+
const canvasData = await getCanvasApi(sourceCanvas)
|
|
1535
|
+
sourceList = canvasData?.widgets ?? []
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
const sourceWidgets = sourceList.filter(w => sourceWidgetIds.includes(w.id))
|
|
1539
|
+
if (sourceWidgets.length === 0) return
|
|
1540
|
+
|
|
1541
|
+
// Compute bounding box of source widgets for relative positioning
|
|
1542
|
+
const fallback = { width: 200, height: 150 }
|
|
1543
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
|
1544
|
+
for (const w of sourceWidgets) {
|
|
1545
|
+
const wx = w.position?.x ?? 0
|
|
1546
|
+
const wy = w.position?.y ?? 0
|
|
1547
|
+
const ww = w.props?.width ?? WIDGET_FALLBACK_SIZES[w.type]?.width ?? fallback.width
|
|
1548
|
+
const wh = w.props?.height ?? WIDGET_FALLBACK_SIZES[w.type]?.height ?? fallback.height
|
|
1549
|
+
if (wx < minX) minX = wx
|
|
1550
|
+
if (wy < minY) minY = wy
|
|
1551
|
+
if (wx + ww > maxX) maxX = wx + ww
|
|
1552
|
+
if (wy + wh > maxY) maxY = wy + wh
|
|
1553
|
+
}
|
|
1554
|
+
const groupW = maxX - minX
|
|
1555
|
+
const groupH = maxY - minY
|
|
1556
|
+
|
|
1557
|
+
// Center the group in the viewport
|
|
1558
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1559
|
+
const baseX = Math.round(center.x - groupW / 2)
|
|
1560
|
+
const baseY = Math.round(center.y - groupH / 2)
|
|
1561
|
+
|
|
1562
|
+
// Single undo snapshot for the entire paste
|
|
1563
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1564
|
+
|
|
1565
|
+
// Paste all widgets, collecting new IDs for selection
|
|
1566
|
+
const newWidgets = []
|
|
1567
|
+
for (const w of sourceWidgets) {
|
|
1568
|
+
const relX = (w.position?.x ?? 0) - minX
|
|
1569
|
+
const relY = (w.position?.y ?? 0) - minY
|
|
1570
|
+
const result = await addWidgetApi(canvasId, {
|
|
1571
|
+
type: w.type,
|
|
1572
|
+
props: { ...w.props },
|
|
1573
|
+
position: { x: baseX + relX, y: baseY + relY },
|
|
1574
|
+
})
|
|
1575
|
+
if (result.success && result.widget) {
|
|
1576
|
+
newWidgets.push(result.widget)
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
if (newWidgets.length > 0) {
|
|
1581
|
+
setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
|
|
1582
|
+
setSelectedWidgetIds(new Set(newWidgets.map(w => w.id)))
|
|
1583
|
+
}
|
|
1584
|
+
} catch (err) {
|
|
1585
|
+
console.error('[canvas] Failed to paste widget reference:', err)
|
|
1202
1586
|
}
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1587
|
+
// Always consume the ref — never fall through to markdown creation
|
|
1588
|
+
return
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
e.preventDefault()
|
|
1592
|
+
const resolved = resolvePaste(text, pasteCtx, getPasteRules())
|
|
1593
|
+
if (!resolved) return
|
|
1594
|
+
const { type } = resolved
|
|
1595
|
+
let props = resolved.props
|
|
1596
|
+
|
|
1597
|
+
if (type === 'link-preview' && isGitHubEmbedUrl(props?.url || text)) {
|
|
1598
|
+
const githubUpdates = await buildGitHubPreviewUpdates(props?.url || text)
|
|
1599
|
+
if (githubUpdates) props = { ...props, ...githubUpdates }
|
|
1206
1600
|
}
|
|
1207
1601
|
|
|
1208
1602
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1209
1603
|
const pos = centerPositionForWidget(center, type, props)
|
|
1210
1604
|
try {
|
|
1211
|
-
const result = await addWidgetApi(
|
|
1605
|
+
const result = await addWidgetApi(canvasId, {
|
|
1212
1606
|
type,
|
|
1213
1607
|
props,
|
|
1214
1608
|
position: pos,
|
|
@@ -1216,14 +1610,81 @@ export default function CanvasPage({ name }) {
|
|
|
1216
1610
|
if (result.success && result.widget) {
|
|
1217
1611
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1218
1612
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1613
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1219
1614
|
}
|
|
1220
1615
|
} catch (err) {
|
|
1221
1616
|
console.error('[canvas] Failed to add widget from paste:', err)
|
|
1222
1617
|
}
|
|
1223
1618
|
}
|
|
1619
|
+
|
|
1224
1620
|
document.addEventListener('paste', handlePaste)
|
|
1225
1621
|
return () => document.removeEventListener('paste', handlePaste)
|
|
1226
|
-
|
|
1622
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1623
|
+
}, [canvasId, undoRedo, localWidgets])
|
|
1624
|
+
|
|
1625
|
+
// --- Drag and drop handlers for images from Finder/file manager ---
|
|
1626
|
+
// Separate effect to ensure listeners attach after scroll container mounts (loading=false)
|
|
1627
|
+
useEffect(() => {
|
|
1628
|
+
if (loading) return // Don't attach until canvas is loaded and scroll container exists
|
|
1629
|
+
|
|
1630
|
+
const scrollEl = scrollRef.current
|
|
1631
|
+
if (!scrollEl) return
|
|
1632
|
+
|
|
1633
|
+
function handleDragOver(e) {
|
|
1634
|
+
// Only handle if dragging files (not internal widget drag)
|
|
1635
|
+
if (!e.dataTransfer?.types?.includes('Files')) return
|
|
1636
|
+
e.preventDefault()
|
|
1637
|
+
e.dataTransfer.dropEffect = 'copy'
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
async function handleDrop(e) {
|
|
1641
|
+
// Only handle file drops, not internal widget drags
|
|
1642
|
+
if (!e.dataTransfer?.types?.includes('Files')) return
|
|
1643
|
+
|
|
1644
|
+
// Prevent browser default (opening file) immediately for any file drop
|
|
1645
|
+
e.preventDefault()
|
|
1646
|
+
e.stopPropagation()
|
|
1647
|
+
|
|
1648
|
+
const files = e.dataTransfer.files
|
|
1649
|
+
if (!files || files.length === 0) return
|
|
1650
|
+
|
|
1651
|
+
// Filter to image files only — non-images are silently ignored (default already prevented)
|
|
1652
|
+
const imageFiles = Array.from(files).filter((f) => f.type.startsWith('image/'))
|
|
1653
|
+
if (imageFiles.length === 0) return
|
|
1654
|
+
|
|
1655
|
+
// Convert drop coordinates to canvas coordinates
|
|
1656
|
+
const rect = scrollEl.getBoundingClientRect()
|
|
1657
|
+
const scale = zoomRef.current / 100
|
|
1658
|
+
|
|
1659
|
+
// Mouse position relative to scroll container
|
|
1660
|
+
const mouseX = e.clientX - rect.left
|
|
1661
|
+
const mouseY = e.clientY - rect.top
|
|
1662
|
+
|
|
1663
|
+
// Convert to canvas coordinates (account for scroll and zoom)
|
|
1664
|
+
const canvasX = (scrollEl.scrollLeft + mouseX) / scale
|
|
1665
|
+
const canvasY = (scrollEl.scrollTop + mouseY) / scale
|
|
1666
|
+
|
|
1667
|
+
// Snap to grid if enabled, using current grid size
|
|
1668
|
+
const gridSize = snapGridSizeRef.current
|
|
1669
|
+
const shouldSnap = snapEnabledRef.current
|
|
1670
|
+
const snappedX = shouldSnap ? Math.round(canvasX / gridSize) * gridSize : Math.round(canvasX)
|
|
1671
|
+
const snappedY = shouldSnap ? Math.round(canvasY / gridSize) * gridSize : Math.round(canvasY)
|
|
1672
|
+
|
|
1673
|
+
// Process each image file, offsetting subsequent images
|
|
1674
|
+
for (let i = 0; i < imageFiles.length; i++) {
|
|
1675
|
+
const offset = shouldSnap ? i * gridSize : i * 24
|
|
1676
|
+
await processImageFileRef.current?.(imageFiles[i], { x: snappedX + offset, y: snappedY + offset })
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
scrollEl.addEventListener('dragover', handleDragOver)
|
|
1681
|
+
scrollEl.addEventListener('drop', handleDrop)
|
|
1682
|
+
|
|
1683
|
+
return () => {
|
|
1684
|
+
scrollEl.removeEventListener('dragover', handleDragOver)
|
|
1685
|
+
scrollEl.removeEventListener('drop', handleDrop)
|
|
1686
|
+
}
|
|
1687
|
+
}, [loading])
|
|
1227
1688
|
|
|
1228
1689
|
// --- Undo / Redo ---
|
|
1229
1690
|
const handleUndo = useCallback(() => {
|
|
@@ -1234,11 +1695,11 @@ export default function CanvasPage({ name }) {
|
|
|
1234
1695
|
setLocalWidgets(previous.widgets)
|
|
1235
1696
|
setLocalSources(previous.sources)
|
|
1236
1697
|
queueWrite(() =>
|
|
1237
|
-
updateCanvas(
|
|
1698
|
+
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
|
|
1238
1699
|
console.error('[canvas] Failed to persist undo:', err)
|
|
1239
1700
|
)
|
|
1240
1701
|
)
|
|
1241
|
-
}, [
|
|
1702
|
+
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
1242
1703
|
|
|
1243
1704
|
const handleRedo = useCallback(() => {
|
|
1244
1705
|
const next = undoRedo.redo(stateRef.current)
|
|
@@ -1248,11 +1709,11 @@ export default function CanvasPage({ name }) {
|
|
|
1248
1709
|
setLocalWidgets(next.widgets)
|
|
1249
1710
|
setLocalSources(next.sources)
|
|
1250
1711
|
queueWrite(() =>
|
|
1251
|
-
updateCanvas(
|
|
1712
|
+
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
|
|
1252
1713
|
console.error('[canvas] Failed to persist redo:', err)
|
|
1253
1714
|
)
|
|
1254
1715
|
)
|
|
1255
|
-
}, [
|
|
1716
|
+
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
1256
1717
|
|
|
1257
1718
|
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
|
|
1258
1719
|
useEffect(() => {
|
|
@@ -1311,6 +1772,69 @@ export default function CanvasPage({ name }) {
|
|
|
1311
1772
|
return () => document.removeEventListener('wheel', handleWheel)
|
|
1312
1773
|
}, [])
|
|
1313
1774
|
|
|
1775
|
+
// Receive cmd+wheel events forwarded from prototype/story iframes
|
|
1776
|
+
useEffect(() => {
|
|
1777
|
+
function handleMessage(e) {
|
|
1778
|
+
if (e.data?.type !== 'storyboard:embed:wheel') return
|
|
1779
|
+
zoomAccum.current += -e.data.deltaY
|
|
1780
|
+
const step = Math.trunc(zoomAccum.current)
|
|
1781
|
+
if (step === 0) return
|
|
1782
|
+
zoomAccum.current -= step
|
|
1783
|
+
applyZoom(zoomRef.current + step)
|
|
1784
|
+
}
|
|
1785
|
+
window.addEventListener('message', handleMessage)
|
|
1786
|
+
return () => window.removeEventListener('message', handleMessage)
|
|
1787
|
+
}, [])
|
|
1788
|
+
|
|
1789
|
+
// Touch pinch-to-zoom for mobile — two-finger pinch zooms the canvas
|
|
1790
|
+
const pinchState = useRef({ active: false, startDist: 0, startZoom: 0, centerX: 0, centerY: 0 })
|
|
1791
|
+
useEffect(() => {
|
|
1792
|
+
const el = scrollRef.current
|
|
1793
|
+
if (!el) return
|
|
1794
|
+
|
|
1795
|
+
function getTouchDist(t1, t2) {
|
|
1796
|
+
const dx = t1.clientX - t2.clientX
|
|
1797
|
+
const dy = t1.clientY - t2.clientY
|
|
1798
|
+
return Math.sqrt(dx * dx + dy * dy)
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function handleTouchStart(e) {
|
|
1802
|
+
if (e.touches.length !== 2) return
|
|
1803
|
+
const dist = getTouchDist(e.touches[0], e.touches[1])
|
|
1804
|
+
pinchState.current = {
|
|
1805
|
+
active: true,
|
|
1806
|
+
startDist: dist,
|
|
1807
|
+
startZoom: zoomRef.current,
|
|
1808
|
+
centerX: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
|
1809
|
+
centerY: (e.touches[0].clientY + e.touches[1].clientY) / 2,
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function handleTouchMove(e) {
|
|
1814
|
+
if (!pinchState.current.active || e.touches.length !== 2) return
|
|
1815
|
+
e.preventDefault()
|
|
1816
|
+
const dist = getTouchDist(e.touches[0], e.touches[1])
|
|
1817
|
+
const ratio = dist / pinchState.current.startDist
|
|
1818
|
+
const newZoom = Math.round(pinchState.current.startZoom * ratio)
|
|
1819
|
+
applyZoom(newZoom, pinchState.current.centerX, pinchState.current.centerY)
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function handleTouchEnd() {
|
|
1823
|
+
pinchState.current.active = false
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
el.addEventListener('touchstart', handleTouchStart, { passive: true })
|
|
1827
|
+
el.addEventListener('touchmove', handleTouchMove, { passive: false })
|
|
1828
|
+
el.addEventListener('touchend', handleTouchEnd)
|
|
1829
|
+
el.addEventListener('touchcancel', handleTouchEnd)
|
|
1830
|
+
return () => {
|
|
1831
|
+
el.removeEventListener('touchstart', handleTouchStart)
|
|
1832
|
+
el.removeEventListener('touchmove', handleTouchMove)
|
|
1833
|
+
el.removeEventListener('touchend', handleTouchEnd)
|
|
1834
|
+
el.removeEventListener('touchcancel', handleTouchEnd)
|
|
1835
|
+
}
|
|
1836
|
+
}, [])
|
|
1837
|
+
|
|
1314
1838
|
// Space + drag to pan the canvas
|
|
1315
1839
|
const [spaceHeld, setSpaceHeld] = useState(false)
|
|
1316
1840
|
const isPanning = useRef(false)
|
|
@@ -1370,10 +1894,31 @@ export default function CanvasPage({ name }) {
|
|
|
1370
1894
|
document.addEventListener('mouseup', handlePanEnd)
|
|
1371
1895
|
}, [spaceHeld])
|
|
1372
1896
|
|
|
1897
|
+
// Stable callback for deselecting all widgets
|
|
1898
|
+
const handleDeselectAll = useCallback(() => setSelectedWidgetIds(new Set()), [])
|
|
1899
|
+
|
|
1900
|
+
// Marquee (lasso) multi-select on canvas background drag
|
|
1901
|
+
const { marqueeScreenRect, handleMarqueeMouseDown } = useMarqueeSelect({
|
|
1902
|
+
scrollRef,
|
|
1903
|
+
zoomRef: zoomRef,
|
|
1904
|
+
setSelectedWidgetIds,
|
|
1905
|
+
widgets: localWidgets,
|
|
1906
|
+
componentEntries,
|
|
1907
|
+
fallbackSizes: WIDGET_FALLBACK_SIZES,
|
|
1908
|
+
spaceHeld,
|
|
1909
|
+
isLocalDev,
|
|
1910
|
+
})
|
|
1911
|
+
|
|
1912
|
+
// Stable callback for widget removal + deselect
|
|
1913
|
+
const handleWidgetRemoveAndDeselect = useCallback((id) => {
|
|
1914
|
+
handleWidgetRemove(id)
|
|
1915
|
+
setSelectedWidgetIds(new Set())
|
|
1916
|
+
}, [handleWidgetRemove])
|
|
1917
|
+
|
|
1373
1918
|
if (!canvas) {
|
|
1374
1919
|
return (
|
|
1375
1920
|
<div className={styles.empty}>
|
|
1376
|
-
<p>Canvas “{
|
|
1921
|
+
<p>Canvas “{canvasId}” not found</p>
|
|
1377
1922
|
</div>
|
|
1378
1923
|
)
|
|
1379
1924
|
}
|
|
@@ -1401,11 +1946,11 @@ export default function CanvasPage({ name }) {
|
|
|
1401
1946
|
const canvasThemeVars = getCanvasThemeVars(canvasTheme)
|
|
1402
1947
|
const canvasPrimerAttrs = getCanvasPrimerAttrs(canvasTheme)
|
|
1403
1948
|
|
|
1404
|
-
// Merge JSX-sourced widgets
|
|
1949
|
+
// Merge JSX-sourced widgets and JSON widgets
|
|
1405
1950
|
const allChildren = []
|
|
1406
1951
|
|
|
1407
1952
|
// 1. Component widgets (from jsxExports or sources fallback)
|
|
1408
|
-
const componentFeatures = getFeatures('component')
|
|
1953
|
+
const componentFeatures = getFeatures('component', { isLocalDev })
|
|
1409
1954
|
for (const entry of componentEntries) {
|
|
1410
1955
|
const { exportName, Component, sourceData } = entry
|
|
1411
1956
|
const sourcePosition = sourceData.position || { x: 0, y: 0 }
|
|
@@ -1431,7 +1976,7 @@ export default function CanvasPage({ name }) {
|
|
|
1431
1976
|
selected={selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1432
1977
|
multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1433
1978
|
onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
|
|
1434
|
-
onDeselect={
|
|
1979
|
+
onDeselect={handleDeselectAll}
|
|
1435
1980
|
readOnly={!isLocalDev}
|
|
1436
1981
|
>
|
|
1437
1982
|
<ComponentWidget
|
|
@@ -1473,13 +2018,12 @@ export default function CanvasPage({ name }) {
|
|
|
1473
2018
|
selected={selectedWidgetIds.has(widget.id)}
|
|
1474
2019
|
multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
|
|
1475
2020
|
onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
|
|
1476
|
-
onDeselect={
|
|
2021
|
+
onDeselect={handleDeselectAll}
|
|
1477
2022
|
onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
|
|
1478
2023
|
onCopy={isLocalDev ? handleWidgetCopy : undefined}
|
|
1479
|
-
onRemove={isLocalDev ?
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
} : undefined}
|
|
2024
|
+
onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
|
|
2025
|
+
onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
|
|
2026
|
+
canRefreshGitHub={isLocalDev}
|
|
1483
2027
|
readOnly={!isLocalDev}
|
|
1484
2028
|
/>
|
|
1485
2029
|
</div>
|
|
@@ -1491,24 +2035,13 @@ export default function CanvasPage({ name }) {
|
|
|
1491
2035
|
return (
|
|
1492
2036
|
<>
|
|
1493
2037
|
<div className={styles.canvasTitle}>
|
|
1494
|
-
<
|
|
1495
|
-
<
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
size={1}
|
|
1502
|
-
onChange={handleTitleChange}
|
|
1503
|
-
onKeyDown={handleTitleKeyDown}
|
|
1504
|
-
onMouseDown={(e) => e.stopPropagation()}
|
|
1505
|
-
spellCheck={false}
|
|
1506
|
-
aria-label="Canvas title"
|
|
1507
|
-
/>
|
|
1508
|
-
) : (
|
|
1509
|
-
<h1 className={styles.canvasTitleStatic}>{canvasTitle}</h1>
|
|
1510
|
-
)}
|
|
1511
|
-
</div>
|
|
2038
|
+
<a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
|
|
2039
|
+
<Icon name="iconoir/key-command" size={16} color="#fff" />
|
|
2040
|
+
</a>
|
|
2041
|
+
{siblingPages.length > 1 && (
|
|
2042
|
+
<h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || canvasId.split('/').pop()}</h1>
|
|
2043
|
+
)}
|
|
2044
|
+
<PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
|
|
1512
2045
|
{isLocalDev && (
|
|
1513
2046
|
<span className={styles.localEditingLabel}>Local editing</span>
|
|
1514
2047
|
)}
|
|
@@ -1523,10 +2056,11 @@ export default function CanvasPage({ name }) {
|
|
|
1523
2056
|
...canvasThemeVars,
|
|
1524
2057
|
...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
|
|
1525
2058
|
}}
|
|
1526
|
-
|
|
1527
|
-
onMouseDown={handlePanStart}
|
|
2059
|
+
onMouseDown={(e) => { handlePanStart(e); handleMarqueeMouseDown(e); }}
|
|
1528
2060
|
>
|
|
2061
|
+
<MarqueeOverlay rect={marqueeScreenRect} />
|
|
1529
2062
|
<div
|
|
2063
|
+
ref={zoomElRef}
|
|
1530
2064
|
data-storyboard-canvas-zoom
|
|
1531
2065
|
data-sb-canvas-theme={canvasTheme}
|
|
1532
2066
|
className={styles.canvasZoom}
|
|
@@ -1543,6 +2077,28 @@ export default function CanvasPage({ name }) {
|
|
|
1543
2077
|
</Canvas>
|
|
1544
2078
|
</div>
|
|
1545
2079
|
</div>
|
|
2080
|
+
{showGhInstallBanner && (
|
|
2081
|
+
<aside className={styles.ghInstallBanner} role="status" aria-live="polite">
|
|
2082
|
+
<span className={styles.ghInstallBannerText}>
|
|
2083
|
+
GitHub embeds require local <code>gh</code> CLI access.
|
|
2084
|
+
</span>
|
|
2085
|
+
<a
|
|
2086
|
+
href={GH_INSTALL_URL}
|
|
2087
|
+
target="_blank"
|
|
2088
|
+
rel="noopener noreferrer"
|
|
2089
|
+
className={styles.ghInstallBannerLink}
|
|
2090
|
+
>
|
|
2091
|
+
Install GitHub CLI
|
|
2092
|
+
</a>
|
|
2093
|
+
<button
|
|
2094
|
+
type="button"
|
|
2095
|
+
className={styles.ghInstallBannerDismiss}
|
|
2096
|
+
onClick={() => setShowGhInstallBanner(false)}
|
|
2097
|
+
>
|
|
2098
|
+
Dismiss
|
|
2099
|
+
</button>
|
|
2100
|
+
</aside>
|
|
2101
|
+
)}
|
|
1546
2102
|
</>
|
|
1547
2103
|
)
|
|
1548
2104
|
}
|