@dfosco/storyboard-react 4.0.0-beta.4 → 4.0.0-beta.41
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 +9 -4
- package/src/Icon.jsx +179 -0
- package/src/Viewfinder.jsx +1030 -57
- package/src/Viewfinder.module.css +1524 -155
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +843 -301
- package/src/canvas/CanvasPage.module.css +73 -50
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/canvas/PageSelector.jsx +198 -0
- package/src/canvas/PageSelector.module.css +158 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- 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 +15 -10
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +82 -9
- package/src/canvas/widgets/ComponentWidget.module.css +14 -6
- 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 +95 -21
- package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
- package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +95 -144
- 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 +276 -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 +4 -7
- 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 +56 -0
- 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 +141 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +375 -57
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { createElement, useCallback, useEffect, 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,52 @@ 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 { isGitHubEmbedUrl } from './widgets/githubUrl.js'
|
|
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 {
|
|
16
|
+
import {
|
|
17
|
+
addWidget as addWidgetApi,
|
|
18
|
+
checkGitHubCliAvailable,
|
|
19
|
+
fetchGitHubEmbed,
|
|
20
|
+
getCanvas as getCanvasApi,
|
|
21
|
+
removeWidget as removeWidgetApi,
|
|
22
|
+
updateCanvas,
|
|
23
|
+
uploadImage,
|
|
24
|
+
} from './canvasApi.js'
|
|
25
|
+
import PageSelector from './PageSelector.jsx'
|
|
26
|
+
import { stories as storyIndex } from 'virtual:storyboard-data-index'
|
|
16
27
|
import styles from './CanvasPage.module.css'
|
|
17
28
|
|
|
18
29
|
const ZOOM_MIN = 25
|
|
19
30
|
const ZOOM_MAX = 200
|
|
20
31
|
|
|
32
|
+
/** Saved viewport state older than this is considered stale — zoom-to-fit instead. */
|
|
33
|
+
const VIEWPORT_TTL_MS = 15 * 60 * 1000
|
|
34
|
+
|
|
21
35
|
const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
|
|
36
|
+
const GH_INSTALL_URL = 'https://github.com/cli/cli'
|
|
22
37
|
|
|
23
38
|
/** Matches branch-deploy base path prefixes like /branch--my-feature/ */
|
|
24
39
|
const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
|
|
25
40
|
|
|
41
|
+
// Build a reverse map from story route paths → { storyId, route }
|
|
42
|
+
const storyRouteIndex = new Map()
|
|
43
|
+
for (const [storyId, data] of Object.entries(storyIndex || {})) {
|
|
44
|
+
if (data?._route) {
|
|
45
|
+
storyRouteIndex.set(data._route.replace(/\/+$/, ''), storyId)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
function getToolbarColorMode(theme) {
|
|
27
50
|
return String(theme || 'light').startsWith('dark') ? 'dark' : 'light'
|
|
28
51
|
}
|
|
29
52
|
|
|
30
53
|
function resolveCanvasThemeFromStorage() {
|
|
31
54
|
if (typeof localStorage === 'undefined') return 'light'
|
|
32
|
-
let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas:
|
|
55
|
+
let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: true }
|
|
33
56
|
try {
|
|
34
57
|
const rawSync = localStorage.getItem('sb-theme-sync')
|
|
35
58
|
if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
|
|
@@ -47,6 +70,36 @@ function resolveCanvasThemeFromStorage() {
|
|
|
47
70
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
48
71
|
}
|
|
49
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Get the copyable URL for a widget based on its type.
|
|
75
|
+
* Returns the most relevant URL/path for the widget content.
|
|
76
|
+
*/
|
|
77
|
+
// eslint-disable-next-line no-unused-vars
|
|
78
|
+
function getWidgetCopyableUrl(widget) {
|
|
79
|
+
const { type, props = {} } = widget
|
|
80
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
81
|
+
switch (type) {
|
|
82
|
+
case 'prototype':
|
|
83
|
+
// Prototype src is a path like "/MyPrototype" - make it a full URL
|
|
84
|
+
return props.src ? `${window.location.origin}${base.replace(/\/$/, '')}${props.src}` : ''
|
|
85
|
+
case 'figma-embed':
|
|
86
|
+
return props.url || ''
|
|
87
|
+
case 'link-preview':
|
|
88
|
+
return props.url || ''
|
|
89
|
+
case 'image':
|
|
90
|
+
// Return the served image URL
|
|
91
|
+
return props.src ? `${window.location.origin}${base.replace(/\/$/, '')}/_storyboard/canvas/images/${props.src}` : ''
|
|
92
|
+
case 'sticky-note':
|
|
93
|
+
// Sticky notes have text content, not a URL
|
|
94
|
+
return props.text || ''
|
|
95
|
+
case 'markdown':
|
|
96
|
+
// Markdown has content, not a URL
|
|
97
|
+
return props.content || ''
|
|
98
|
+
default:
|
|
99
|
+
return ''
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
50
103
|
/**
|
|
51
104
|
* Debounce helper — returns a function that delays invocation.
|
|
52
105
|
* Exposes `.cancel()` to abort pending calls (used by undo/redo).
|
|
@@ -62,15 +115,20 @@ function debounce(fn, ms) {
|
|
|
62
115
|
}
|
|
63
116
|
|
|
64
117
|
/** Per-canvas viewport state persistence (zoom + scroll position). */
|
|
65
|
-
function getViewportStorageKey(
|
|
66
|
-
return `sb-canvas-viewport:${
|
|
118
|
+
function getViewportStorageKey(canvasId) {
|
|
119
|
+
return `sb-canvas-viewport:${canvasId}`
|
|
67
120
|
}
|
|
68
121
|
|
|
69
|
-
function loadViewportState(
|
|
122
|
+
function loadViewportState(canvasId) {
|
|
70
123
|
try {
|
|
71
|
-
const raw = localStorage.getItem(getViewportStorageKey(
|
|
124
|
+
const raw = localStorage.getItem(getViewportStorageKey(canvasId))
|
|
72
125
|
if (!raw) return null
|
|
73
126
|
const state = JSON.parse(raw)
|
|
127
|
+
const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
|
|
128
|
+
if (Date.now() - timestamp > VIEWPORT_TTL_MS) {
|
|
129
|
+
localStorage.removeItem(getViewportStorageKey(canvasId))
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
74
132
|
return {
|
|
75
133
|
zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
|
|
76
134
|
scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
|
|
@@ -79,9 +137,12 @@ function loadViewportState(canvasName) {
|
|
|
79
137
|
} catch { return null }
|
|
80
138
|
}
|
|
81
139
|
|
|
82
|
-
function saveViewportState(
|
|
140
|
+
function saveViewportState(canvasId, state) {
|
|
83
141
|
try {
|
|
84
|
-
localStorage.setItem(getViewportStorageKey(
|
|
142
|
+
localStorage.setItem(getViewportStorageKey(canvasId), JSON.stringify({
|
|
143
|
+
...state,
|
|
144
|
+
timestamp: Date.now(),
|
|
145
|
+
}))
|
|
85
146
|
} catch { /* quota exceeded — non-critical */ }
|
|
86
147
|
}
|
|
87
148
|
|
|
@@ -158,7 +219,7 @@ const FIT_PADDING = 48
|
|
|
158
219
|
* Compute the axis-aligned bounding box that contains every widget and source.
|
|
159
220
|
* Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
|
|
160
221
|
*/
|
|
161
|
-
function computeCanvasBounds(widgets,
|
|
222
|
+
function computeCanvasBounds(widgets, componentEntries) {
|
|
162
223
|
let minX = Infinity
|
|
163
224
|
let minY = Infinity
|
|
164
225
|
let maxX = -Infinity
|
|
@@ -179,31 +240,25 @@ function computeCanvasBounds(widgets, sources, jsxExports) {
|
|
|
179
240
|
hasItems = true
|
|
180
241
|
}
|
|
181
242
|
|
|
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
|
-
}
|
|
243
|
+
// Component widgets (from jsxExports or sources fallback)
|
|
244
|
+
for (const entry of componentEntries) {
|
|
245
|
+
const x = entry.sourceData?.position?.x ?? 0
|
|
246
|
+
const y = entry.sourceData?.position?.y ?? 0
|
|
247
|
+
const fallback = WIDGET_FALLBACK_SIZES['component']
|
|
248
|
+
const width = entry.sourceData?.width ?? fallback.width
|
|
249
|
+
const height = entry.sourceData?.height ?? fallback.height
|
|
250
|
+
minX = Math.min(minX, x)
|
|
251
|
+
minY = Math.min(minY, y)
|
|
252
|
+
maxX = Math.max(maxX, x + width)
|
|
253
|
+
maxY = Math.max(maxY, y + height)
|
|
254
|
+
hasItems = true
|
|
200
255
|
}
|
|
201
256
|
|
|
202
257
|
return hasItems ? { minX, minY, maxX, maxY } : null
|
|
203
258
|
}
|
|
204
259
|
|
|
205
260
|
/** Renders a single JSON-defined widget by type lookup. */
|
|
206
|
-
function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
261
|
+
function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub }) {
|
|
207
262
|
const Component = getWidgetComponent(widget.type)
|
|
208
263
|
if (!Component) {
|
|
209
264
|
console.warn(`[canvas] Unknown widget type: ${widget.type}`)
|
|
@@ -211,7 +266,14 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
|
211
266
|
}
|
|
212
267
|
const resizable = isResizable(widget.type) && !!onUpdate
|
|
213
268
|
// Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
|
|
214
|
-
const elementProps = {
|
|
269
|
+
const elementProps = {
|
|
270
|
+
id: widget.id,
|
|
271
|
+
props: widget.props,
|
|
272
|
+
onUpdate,
|
|
273
|
+
resizable,
|
|
274
|
+
onRefreshGitHub,
|
|
275
|
+
canRefreshGitHub,
|
|
276
|
+
}
|
|
215
277
|
if (Component.$$typeof === Symbol.for('react.forward_ref')) {
|
|
216
278
|
elementProps.ref = widgetRef
|
|
217
279
|
}
|
|
@@ -221,8 +283,10 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
|
221
283
|
/**
|
|
222
284
|
* Wrapper for each JSON widget that holds its own ref for imperative actions.
|
|
223
285
|
* This allows WidgetChrome to dispatch actions to the widget via ref.
|
|
286
|
+
*
|
|
287
|
+
* Memoized to prevent re-renders during zoom and unrelated state changes.
|
|
224
288
|
*/
|
|
225
|
-
function ChromeWrappedWidget({
|
|
289
|
+
const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
226
290
|
widget,
|
|
227
291
|
selected,
|
|
228
292
|
multiSelected,
|
|
@@ -231,18 +295,64 @@ function ChromeWrappedWidget({
|
|
|
231
295
|
onUpdate,
|
|
232
296
|
onRemove,
|
|
233
297
|
onCopy,
|
|
298
|
+
onRefreshGitHub,
|
|
299
|
+
canRefreshGitHub,
|
|
234
300
|
readOnly,
|
|
235
301
|
}) {
|
|
236
302
|
const widgetRef = useRef(null)
|
|
237
|
-
const
|
|
303
|
+
const rawFeatures = getFeatures(widget.type, { isLocalDev: !readOnly })
|
|
304
|
+
|
|
305
|
+
// Dynamically adjust features based on widget state
|
|
306
|
+
const features = useMemo(() => {
|
|
307
|
+
const isGitHub = !!widget.props?.github
|
|
308
|
+
return rawFeatures.map((f) => {
|
|
309
|
+
// Toggle collapse label and hide when content is short (no github = no collapse)
|
|
310
|
+
if (f.action === 'toggle-collapse') {
|
|
311
|
+
if (!isGitHub) return null
|
|
312
|
+
return {
|
|
313
|
+
...f,
|
|
314
|
+
label: widget.props?.collapsed ? 'Expand height' : 'Collapse height',
|
|
315
|
+
icon: widget.props?.collapsed ? 'unfold' : 'fold',
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Hide refresh-github for non-GitHub link previews
|
|
319
|
+
if (f.action === 'refresh-github' && !isGitHub) return null
|
|
320
|
+
return f
|
|
321
|
+
}).filter(Boolean)
|
|
322
|
+
}, [rawFeatures, widget.props?.github, widget.props?.collapsed])
|
|
238
323
|
|
|
239
324
|
const handleAction = useCallback((actionId) => {
|
|
240
325
|
if (actionId === 'delete') {
|
|
241
326
|
onRemove?.(widget.id)
|
|
242
327
|
} else if (actionId === 'copy') {
|
|
243
328
|
onCopy?.(widget)
|
|
329
|
+
} else if (actionId === 'copy-text') {
|
|
330
|
+
const title = widget.props?.title || ''
|
|
331
|
+
const body = widget.props?.text || widget.props?.content || widget.props?.github?.body || ''
|
|
332
|
+
const text = title && body ? `# ${title}\n\n${body}` : title || body
|
|
333
|
+
navigator.clipboard?.writeText(text).catch(() => {})
|
|
334
|
+
} else if (actionId === 'open-external') {
|
|
335
|
+
const url = widget.props?.url || widget.props?.src
|
|
336
|
+
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
337
|
+
} else if (actionId === 'refresh-github') {
|
|
338
|
+
const url = widget.props?.url
|
|
339
|
+
if (url && onRefreshGitHub) onRefreshGitHub(widget.id, url)
|
|
340
|
+
} else if (actionId === 'toggle-collapse') {
|
|
341
|
+
const wasCollapsed = !!widget.props?.collapsed
|
|
342
|
+
onUpdate?.(widget.id, { collapsed: !wasCollapsed })
|
|
343
|
+
// When collapsing, pan viewport to center the widget
|
|
344
|
+
if (!wasCollapsed) {
|
|
345
|
+
requestAnimationFrame(() => {
|
|
346
|
+
const el = document.getElementById(widget.id)
|
|
347
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
348
|
+
})
|
|
349
|
+
}
|
|
244
350
|
}
|
|
245
|
-
}, [widget, onRemove, onCopy])
|
|
351
|
+
}, [widget, onRemove, onCopy, onRefreshGitHub])
|
|
352
|
+
|
|
353
|
+
const handleWidgetFieldUpdate = useCallback((updates) => {
|
|
354
|
+
onUpdate?.(widget.id, updates)
|
|
355
|
+
}, [onUpdate, widget.id])
|
|
246
356
|
|
|
247
357
|
return (
|
|
248
358
|
<WidgetChrome
|
|
@@ -256,43 +366,96 @@ function ChromeWrappedWidget({
|
|
|
256
366
|
onSelect={onSelect}
|
|
257
367
|
onDeselect={onDeselect}
|
|
258
368
|
onAction={handleAction}
|
|
259
|
-
onUpdate={onUpdate ?
|
|
369
|
+
onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
|
|
260
370
|
readOnly={readOnly}
|
|
261
371
|
>
|
|
262
372
|
<WidgetRenderer
|
|
263
373
|
widget={widget}
|
|
264
|
-
onUpdate={onUpdate ?
|
|
374
|
+
onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
|
|
265
375
|
widgetRef={widgetRef}
|
|
376
|
+
onRefreshGitHub={onRefreshGitHub}
|
|
377
|
+
canRefreshGitHub={canRefreshGitHub}
|
|
266
378
|
/>
|
|
267
379
|
</WidgetChrome>
|
|
268
380
|
)
|
|
269
|
-
}
|
|
381
|
+
}, function chromeWidgetAreEqual(prev, next) {
|
|
382
|
+
return (
|
|
383
|
+
prev.widget === next.widget &&
|
|
384
|
+
prev.selected === next.selected &&
|
|
385
|
+
prev.multiSelected === next.multiSelected &&
|
|
386
|
+
prev.readOnly === next.readOnly &&
|
|
387
|
+
prev.onSelect === next.onSelect &&
|
|
388
|
+
prev.onDeselect === next.onDeselect &&
|
|
389
|
+
prev.onUpdate === next.onUpdate &&
|
|
390
|
+
prev.onRemove === next.onRemove &&
|
|
391
|
+
prev.onCopy === next.onCopy
|
|
392
|
+
)
|
|
393
|
+
})
|
|
270
394
|
|
|
271
395
|
/**
|
|
272
396
|
* Generic canvas page component.
|
|
273
397
|
* Reads canvas data from the index and renders all widgets on a draggable surface.
|
|
274
398
|
*
|
|
275
|
-
* @param {{
|
|
399
|
+
* @param {{ canvasId: string }} props - Canvas name as indexed by the data plugin
|
|
276
400
|
*/
|
|
277
|
-
export default function CanvasPage({ name }) {
|
|
278
|
-
const
|
|
401
|
+
export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages = [], canvasMeta = null }) {
|
|
402
|
+
const canvasId = canvasIdProp || name || ''
|
|
403
|
+
const { canvas, jsxExports, jsxError, loading } = useCanvas(canvasId)
|
|
279
404
|
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
|
|
280
405
|
|
|
281
406
|
// Local mutable copy of widgets for instant UI updates
|
|
282
407
|
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
283
408
|
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
284
409
|
const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
|
|
285
|
-
const initialViewport = loadViewportState(
|
|
410
|
+
const initialViewport = loadViewportState(canvasId)
|
|
286
411
|
const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
|
|
287
412
|
const zoomRef = useRef(initialViewport?.zoom ?? 100)
|
|
288
413
|
const scrollRef = useRef(null)
|
|
414
|
+
const zoomElRef = useRef(null)
|
|
415
|
+
const zoomCommitTimer = useRef(null)
|
|
416
|
+
const zoomEventTimer = useRef(null)
|
|
289
417
|
const pendingScrollRestore = useRef(initialViewport)
|
|
290
|
-
|
|
291
|
-
|
|
418
|
+
// Gate viewport persistence until initial positioning is complete.
|
|
419
|
+
// Tracks which canvasId was last initialized — save effects only
|
|
420
|
+
// write when this matches `canvasId`, preventing cross-canvas corruption.
|
|
421
|
+
const viewportInitName = useRef(null)
|
|
292
422
|
const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
|
|
293
423
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
294
424
|
const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
|
|
295
425
|
const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
|
|
426
|
+
const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
|
|
427
|
+
|
|
428
|
+
// Refs for snap settings (used by drop handler inside effect closure)
|
|
429
|
+
const snapEnabledRef = useRef(snapEnabled)
|
|
430
|
+
const snapGridSizeRef = useRef(snapGridSize)
|
|
431
|
+
|
|
432
|
+
// Centralized list of component export names.
|
|
433
|
+
// When jsxExports is available, use it (discovers new exports not yet in sources).
|
|
434
|
+
// When jsxExports is null (module import failed), fall back to sources so iframes
|
|
435
|
+
// still render — the error is contained inside each iframe.
|
|
436
|
+
const componentEntries = useMemo(() => {
|
|
437
|
+
const sourceMap = Object.fromEntries(
|
|
438
|
+
(localSources || []).filter((s) => s?.export).map((s) => [s.export, s]),
|
|
439
|
+
)
|
|
440
|
+
if (jsxExports) {
|
|
441
|
+
return Object.keys(jsxExports).map((exportName) => ({
|
|
442
|
+
exportName,
|
|
443
|
+
Component: jsxExports[exportName],
|
|
444
|
+
sourceData: sourceMap[exportName] || {},
|
|
445
|
+
}))
|
|
446
|
+
}
|
|
447
|
+
// Fallback: use sources when module import failed (iframe isolation still works)
|
|
448
|
+
if (jsxError && canvas?._jsxModule) {
|
|
449
|
+
return (localSources || [])
|
|
450
|
+
.filter((s) => s?.export)
|
|
451
|
+
.map((s) => ({
|
|
452
|
+
exportName: s.export,
|
|
453
|
+
Component: null,
|
|
454
|
+
sourceData: s,
|
|
455
|
+
}))
|
|
456
|
+
}
|
|
457
|
+
return []
|
|
458
|
+
}, [jsxExports, jsxError, localSources, canvas?._jsxModule])
|
|
296
459
|
|
|
297
460
|
// Undo/redo history — tracks both widgets and sources as a combined snapshot
|
|
298
461
|
const undoRedo = useUndoRedo()
|
|
@@ -349,13 +512,13 @@ export default function CanvasPage({ name }) {
|
|
|
349
512
|
// Flag to suppress the click-based selection reset that fires after a drag
|
|
350
513
|
const justDraggedRef = useRef(false)
|
|
351
514
|
|
|
352
|
-
const handleItemDragStart = useCallback((dragId
|
|
515
|
+
const handleItemDragStart = useCallback((dragId) => {
|
|
353
516
|
const ids = selectedIdsRef.current
|
|
354
517
|
peerArticlesRef.current.clear()
|
|
355
518
|
if (ids.size <= 1 || !ids.has(dragId)) return
|
|
356
519
|
|
|
357
520
|
// Suppress selection changes for the duration of the drag
|
|
358
|
-
justDraggedRef.current = true
|
|
521
|
+
justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
|
|
359
522
|
|
|
360
523
|
// Collect peer article elements for transition on drag end
|
|
361
524
|
for (const id of ids) {
|
|
@@ -394,40 +557,28 @@ export default function CanvasPage({ name }) {
|
|
|
394
557
|
setTrackedCanvas(canvas)
|
|
395
558
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
396
559
|
setLocalSources(canvas?.sources ?? [])
|
|
397
|
-
|
|
560
|
+
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
561
|
+
setSnapGridSize(canvas?.gridSize || 40)
|
|
398
562
|
undoRedo.reset()
|
|
563
|
+
// Block saves until the new canvas's viewport is fully restored.
|
|
564
|
+
viewportInitName.current = null
|
|
565
|
+
const newViewport = loadViewportState(canvasId)
|
|
566
|
+
pendingScrollRestore.current = newViewport
|
|
567
|
+
// Restore zoom from the new canvas's saved state
|
|
568
|
+
const newZoom = newViewport?.zoom ?? 100
|
|
569
|
+
zoomRef.current = newZoom
|
|
570
|
+
setZoom(newZoom)
|
|
399
571
|
}
|
|
400
572
|
|
|
401
573
|
// Debounced save to server
|
|
402
574
|
const debouncedSave = useRef(
|
|
403
|
-
debounce((
|
|
404
|
-
updateCanvas(
|
|
575
|
+
debounce((canvasId, widgets) => {
|
|
576
|
+
updateCanvas(canvasId, { widgets }).catch((err) =>
|
|
405
577
|
console.error('[canvas] Failed to save:', err)
|
|
406
578
|
)
|
|
407
579
|
}, 2000)
|
|
408
580
|
).current
|
|
409
581
|
|
|
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
582
|
const handleWidgetUpdate = useCallback((widgetId, updates) => {
|
|
432
583
|
undoRedo.snapshot(stateRef.current, 'edit', widgetId)
|
|
433
584
|
// Snap width/height to grid when snap is enabled
|
|
@@ -441,20 +592,20 @@ export default function CanvasPage({ name }) {
|
|
|
441
592
|
const next = prev.map((w) =>
|
|
442
593
|
w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
|
|
443
594
|
)
|
|
444
|
-
debouncedSave(
|
|
595
|
+
debouncedSave(canvasId, next)
|
|
445
596
|
return next
|
|
446
597
|
})
|
|
447
|
-
}, [
|
|
598
|
+
}, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
|
|
448
599
|
|
|
449
600
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
450
601
|
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
451
602
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
452
603
|
queueWrite(() =>
|
|
453
|
-
removeWidgetApi(
|
|
604
|
+
removeWidgetApi(canvasId, widgetId).catch((err) =>
|
|
454
605
|
console.error('[canvas] Failed to remove widget:', err)
|
|
455
606
|
)
|
|
456
607
|
)
|
|
457
|
-
}, [
|
|
608
|
+
}, [canvasId, undoRedo])
|
|
458
609
|
|
|
459
610
|
const handleWidgetCopy = useCallback(async (widget) => {
|
|
460
611
|
// Find the next free offset — check how many copies already exist at +n*40
|
|
@@ -470,7 +621,7 @@ export default function CanvasPage({ name }) {
|
|
|
470
621
|
const position = { x: baseX + n * 40, y: baseY + n * 40 }
|
|
471
622
|
try {
|
|
472
623
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
473
|
-
const result = await addWidgetApi(
|
|
624
|
+
const result = await addWidgetApi(canvasId, {
|
|
474
625
|
type: widget.type,
|
|
475
626
|
props: { ...widget.props },
|
|
476
627
|
position,
|
|
@@ -481,11 +632,63 @@ export default function CanvasPage({ name }) {
|
|
|
481
632
|
} catch (err) {
|
|
482
633
|
console.error('[canvas] Failed to copy widget:', err)
|
|
483
634
|
}
|
|
484
|
-
}, [
|
|
635
|
+
}, [canvasId, localWidgets, undoRedo])
|
|
636
|
+
|
|
637
|
+
const showMissingGhBanner = useCallback(() => {
|
|
638
|
+
setShowGhInstallBanner(true)
|
|
639
|
+
}, [])
|
|
640
|
+
|
|
641
|
+
const buildGitHubPreviewUpdates = useCallback(async (url) => {
|
|
642
|
+
try {
|
|
643
|
+
const availability = await checkGitHubCliAvailable()
|
|
644
|
+
if (!availability?.available) {
|
|
645
|
+
showMissingGhBanner()
|
|
646
|
+
return null
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const result = await fetchGitHubEmbed(url)
|
|
650
|
+
if (result?.code === 'gh_unavailable') {
|
|
651
|
+
showMissingGhBanner()
|
|
652
|
+
return null
|
|
653
|
+
}
|
|
654
|
+
if (!result?.success || !result?.snapshot) return null
|
|
655
|
+
|
|
656
|
+
const snapshot = result.snapshot
|
|
657
|
+
return {
|
|
658
|
+
title: snapshot.title || '',
|
|
659
|
+
width: 580,
|
|
660
|
+
height: 400,
|
|
661
|
+
github: {
|
|
662
|
+
kind: snapshot.kind || 'issue',
|
|
663
|
+
parentKind: snapshot.parentKind || snapshot.kind || 'issue',
|
|
664
|
+
context: snapshot.context || '',
|
|
665
|
+
body: snapshot.body || '',
|
|
666
|
+
bodyHtml: snapshot.bodyHtml || '',
|
|
667
|
+
authors: Array.isArray(snapshot.authors)
|
|
668
|
+
? snapshot.authors.filter((author) => typeof author === 'string' && author.trim())
|
|
669
|
+
: [],
|
|
670
|
+
createdAt: snapshot.createdAt ?? null,
|
|
671
|
+
updatedAt: snapshot.updatedAt ?? null,
|
|
672
|
+
fetchedAt: new Date().toISOString(),
|
|
673
|
+
},
|
|
674
|
+
}
|
|
675
|
+
} catch (err) {
|
|
676
|
+
console.error('[canvas] Failed to fetch GitHub embed metadata:', err)
|
|
677
|
+
return null
|
|
678
|
+
}
|
|
679
|
+
}, [showMissingGhBanner])
|
|
680
|
+
|
|
681
|
+
const handleRefreshGitHubWidget = useCallback(async (widgetId, url) => {
|
|
682
|
+
if (!widgetId || !url) return { updated: false }
|
|
683
|
+
const updates = await buildGitHubPreviewUpdates(url)
|
|
684
|
+
if (!updates) return { updated: false }
|
|
685
|
+
handleWidgetUpdate(widgetId, updates)
|
|
686
|
+
return { updated: true }
|
|
687
|
+
}, [buildGitHubPreviewUpdates, handleWidgetUpdate])
|
|
485
688
|
|
|
486
689
|
const debouncedSourceSave = useRef(
|
|
487
|
-
debounce((
|
|
488
|
-
updateCanvas(
|
|
690
|
+
debounce((canvasId, sources) => {
|
|
691
|
+
updateCanvas(canvasId, { sources }).catch((err) =>
|
|
489
692
|
console.error('[canvas] Failed to save sources:', err)
|
|
490
693
|
)
|
|
491
694
|
}, 2000)
|
|
@@ -503,10 +706,10 @@ export default function CanvasPage({ name }) {
|
|
|
503
706
|
const next = current.some((s) => s?.export === exportName)
|
|
504
707
|
? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
|
|
505
708
|
: [...current, { export: exportName, ...snapped }]
|
|
506
|
-
debouncedSourceSave(
|
|
709
|
+
debouncedSourceSave(canvasId, next)
|
|
507
710
|
return next
|
|
508
711
|
})
|
|
509
|
-
}, [
|
|
712
|
+
}, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
|
|
510
713
|
|
|
511
714
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
512
715
|
if (!dragId || !position) {
|
|
@@ -521,7 +724,7 @@ export default function CanvasPage({ name }) {
|
|
|
521
724
|
if (ids.size > 1 && ids.has(dragId)) {
|
|
522
725
|
transitionPeers()
|
|
523
726
|
// Suppress the click-based selection reset that fires after pointerup
|
|
524
|
-
justDraggedRef.current = true
|
|
727
|
+
justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
|
|
525
728
|
requestAnimationFrame(() => { justDraggedRef.current = false })
|
|
526
729
|
undoRedo.snapshot(stateRef.current, 'multi-move')
|
|
527
730
|
|
|
@@ -558,7 +761,7 @@ export default function CanvasPage({ name }) {
|
|
|
558
761
|
return w
|
|
559
762
|
})
|
|
560
763
|
queueWrite(() =>
|
|
561
|
-
updateCanvas(
|
|
764
|
+
updateCanvas(canvasId, { widgets: next }).catch((err) =>
|
|
562
765
|
console.error('[canvas] Failed to save multi-move:', err)
|
|
563
766
|
)
|
|
564
767
|
)
|
|
@@ -590,7 +793,7 @@ export default function CanvasPage({ name }) {
|
|
|
590
793
|
})
|
|
591
794
|
if (changed) {
|
|
592
795
|
queueWrite(() =>
|
|
593
|
-
updateCanvas(
|
|
796
|
+
updateCanvas(canvasId, { sources: next }).catch((err) =>
|
|
594
797
|
console.error('[canvas] Failed to save multi-move sources:', err)
|
|
595
798
|
)
|
|
596
799
|
)
|
|
@@ -609,7 +812,7 @@ export default function CanvasPage({ name }) {
|
|
|
609
812
|
? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
|
|
610
813
|
: [...current, { export: sourceExport, position: rounded }]
|
|
611
814
|
queueWrite(() =>
|
|
612
|
-
updateCanvas(
|
|
815
|
+
updateCanvas(canvasId, { sources: next }).catch((err) =>
|
|
613
816
|
console.error('[canvas] Failed to save source position:', err)
|
|
614
817
|
)
|
|
615
818
|
)
|
|
@@ -625,28 +828,65 @@ export default function CanvasPage({ name }) {
|
|
|
625
828
|
w.id === dragId ? { ...w, position: rounded } : w
|
|
626
829
|
)
|
|
627
830
|
queueWrite(() =>
|
|
628
|
-
updateCanvas(
|
|
831
|
+
updateCanvas(canvasId, { widgets: next }).catch((err) =>
|
|
629
832
|
console.error('[canvas] Failed to save widget position:', err)
|
|
630
833
|
)
|
|
631
834
|
)
|
|
632
835
|
return next
|
|
633
836
|
})
|
|
634
|
-
}, [
|
|
837
|
+
}, [canvasId, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
|
|
635
838
|
|
|
839
|
+
// Keep zoomRef in sync when React state is set (e.g. by toolbar or zoom-to-fit)
|
|
636
840
|
useEffect(() => {
|
|
637
841
|
zoomRef.current = zoom
|
|
638
842
|
}, [zoom])
|
|
639
843
|
|
|
640
|
-
//
|
|
844
|
+
// Cleanup zoom timers on unmount
|
|
845
|
+
useEffect(() => () => {
|
|
846
|
+
clearTimeout(zoomCommitTimer.current)
|
|
847
|
+
clearTimeout(zoomEventTimer.current)
|
|
848
|
+
}, [])
|
|
849
|
+
|
|
850
|
+
// Restore scroll position from localStorage after first render.
|
|
851
|
+
// When saved state is fresh (< 15 min), restore it. Otherwise zoom-to-fit
|
|
852
|
+
// all objects so the user sees a useful overview instead of stale coordinates.
|
|
641
853
|
useEffect(() => {
|
|
642
854
|
const el = scrollRef.current
|
|
855
|
+
if (!el || loading) return
|
|
643
856
|
const saved = pendingScrollRestore.current
|
|
644
|
-
if (
|
|
857
|
+
if (saved) {
|
|
858
|
+
// Fresh saved viewport — restore exactly
|
|
645
859
|
if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
|
|
646
860
|
if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
|
|
647
861
|
pendingScrollRestore.current = null
|
|
862
|
+
} else {
|
|
863
|
+
// No saved state or stale — zoom-to-fit all objects
|
|
864
|
+
const bounds = computeCanvasBounds(localWidgets, componentEntries)
|
|
865
|
+
if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
|
|
866
|
+
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
867
|
+
const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
|
|
868
|
+
const fitScale = Math.min(el.clientWidth / boxW, el.clientHeight / boxH)
|
|
869
|
+
const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
|
|
870
|
+
const newScale = fitZoom / 100
|
|
871
|
+
zoomRef.current = fitZoom
|
|
872
|
+
// Imperative DOM update for initial zoom-to-fit — same path as applyZoom
|
|
873
|
+
const zoomEl = zoomElRef.current
|
|
874
|
+
if (zoomEl) {
|
|
875
|
+
zoomEl.style.transform = `scale(${newScale})`
|
|
876
|
+
zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
|
|
877
|
+
zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
|
|
878
|
+
}
|
|
879
|
+
setZoom(fitZoom)
|
|
880
|
+
el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
|
|
881
|
+
el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
|
|
882
|
+
} else {
|
|
883
|
+
el.scrollLeft = 0
|
|
884
|
+
el.scrollTop = 0
|
|
885
|
+
}
|
|
648
886
|
}
|
|
649
|
-
|
|
887
|
+
// Allow save effects for this canvas now that positioning is settled.
|
|
888
|
+
viewportInitName.current = canvasId
|
|
889
|
+
}, [canvasId, loading])
|
|
650
890
|
|
|
651
891
|
// Center on a specific widget if `?widget=<id>` is in the URL
|
|
652
892
|
useEffect(() => {
|
|
@@ -673,16 +913,13 @@ export default function CanvasPage({ name }) {
|
|
|
673
913
|
// Check JSX sources (jsx-ExportName)
|
|
674
914
|
if (!widget && targetId.startsWith('jsx-')) {
|
|
675
915
|
const exportName = targetId.slice(4)
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
)
|
|
679
|
-
const sourceData = sourceMap[exportName]
|
|
680
|
-
if (sourceData || (jsxExports && exportName in jsxExports)) {
|
|
916
|
+
const entry = componentEntries.find((e) => e.exportName === exportName)
|
|
917
|
+
if (entry) {
|
|
681
918
|
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
|
|
919
|
+
x = entry.sourceData?.position?.x ?? 0
|
|
920
|
+
y = entry.sourceData?.position?.y ?? 0
|
|
921
|
+
w = entry.sourceData?.width ?? fallback.width
|
|
922
|
+
h = entry.sourceData?.height ?? fallback.height
|
|
686
923
|
}
|
|
687
924
|
}
|
|
688
925
|
|
|
@@ -696,57 +933,78 @@ export default function CanvasPage({ name }) {
|
|
|
696
933
|
const url = new URL(window.location.href)
|
|
697
934
|
url.searchParams.delete('widget')
|
|
698
935
|
window.history.replaceState({}, '', url.toString())
|
|
699
|
-
}, [loading, localWidgets,
|
|
936
|
+
}, [loading, localWidgets, componentEntries])
|
|
700
937
|
|
|
701
|
-
// Persist viewport state (zoom
|
|
938
|
+
// Persist viewport state (zoom only) to localStorage on zoom changes.
|
|
939
|
+
// Scroll position is persisted separately by the debounced scroll handler,
|
|
940
|
+
// cleanup handler, and beforeunload — never here, because imperative zoom
|
|
941
|
+
// operations (applyZoom, zoom-to-fit) adjust scroll AFTER setZoom, so the
|
|
942
|
+
// scroll values would be stale at this point.
|
|
702
943
|
useEffect(() => {
|
|
944
|
+
if (viewportInitName.current !== canvasId) return
|
|
703
945
|
const el = scrollRef.current
|
|
704
|
-
|
|
946
|
+
// Read current scroll so the zoom entry doesn't zero-out position,
|
|
947
|
+
// but the authoritative scroll save comes from the scroll handler.
|
|
948
|
+
saveViewportState(canvasId, {
|
|
705
949
|
zoom,
|
|
706
950
|
scrollLeft: el?.scrollLeft ?? 0,
|
|
707
951
|
scrollTop: el?.scrollTop ?? 0,
|
|
708
952
|
})
|
|
709
|
-
}, [
|
|
953
|
+
}, [canvasId, zoom])
|
|
710
954
|
|
|
711
955
|
useEffect(() => {
|
|
712
956
|
const el = scrollRef.current
|
|
713
957
|
if (!el) return
|
|
714
|
-
|
|
715
|
-
|
|
958
|
+
const saveNow = () => {
|
|
959
|
+
if (viewportInitName.current !== canvasId) return
|
|
960
|
+
saveViewportState(canvasId, {
|
|
716
961
|
zoom: zoomRef.current,
|
|
717
962
|
scrollLeft: el.scrollLeft,
|
|
718
963
|
scrollTop: el.scrollTop,
|
|
719
964
|
})
|
|
720
965
|
}
|
|
966
|
+
const debouncedScrollSave = debounce(saveNow, 150)
|
|
967
|
+
function handleScroll() {
|
|
968
|
+
if (viewportInitName.current !== canvasId) return
|
|
969
|
+
debouncedScrollSave()
|
|
970
|
+
}
|
|
721
971
|
el.addEventListener('scroll', handleScroll, { passive: true })
|
|
722
972
|
|
|
723
973
|
// Flush viewport state on page unload so a refresh never misses it
|
|
724
974
|
function handleBeforeUnload() {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
scrollLeft: el.scrollLeft,
|
|
728
|
-
scrollTop: el.scrollTop,
|
|
729
|
-
})
|
|
975
|
+
debouncedScrollSave.cancel()
|
|
976
|
+
saveNow()
|
|
730
977
|
}
|
|
731
978
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
732
979
|
|
|
733
980
|
return () => {
|
|
981
|
+
debouncedScrollSave.cancel()
|
|
734
982
|
el.removeEventListener('scroll', handleScroll)
|
|
735
983
|
window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
984
|
+
// Save final state on cleanup (covers SPA navigation where
|
|
985
|
+
// beforeunload doesn't fire).
|
|
986
|
+
saveNow()
|
|
736
987
|
}
|
|
737
|
-
}, [
|
|
988
|
+
}, [canvasId, loading])
|
|
738
989
|
|
|
739
990
|
/**
|
|
740
991
|
* Zoom to a new level, anchoring on an optional client-space point.
|
|
741
992
|
* When a cursor position is provided (e.g. from a wheel event), the
|
|
742
993
|
* canvas point under the cursor stays fixed. Otherwise falls back to
|
|
743
994
|
* the viewport center.
|
|
995
|
+
*
|
|
996
|
+
* Performs an imperative DOM mutation instead of a React state update
|
|
997
|
+
* to avoid triggering a full re-render of the widget tree on every
|
|
998
|
+
* zoom tick. React state is committed after a debounce for toolbar
|
|
999
|
+
* display updates.
|
|
744
1000
|
*/
|
|
745
1001
|
function applyZoom(newZoom, clientX, clientY) {
|
|
746
1002
|
const el = scrollRef.current
|
|
1003
|
+
const zoomEl = zoomElRef.current
|
|
747
1004
|
const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
|
|
748
1005
|
|
|
749
|
-
if (!el) {
|
|
1006
|
+
if (!el || !zoomEl) {
|
|
1007
|
+
zoomRef.current = clampedZoom
|
|
750
1008
|
setZoom(clampedZoom)
|
|
751
1009
|
return
|
|
752
1010
|
}
|
|
@@ -764,24 +1022,48 @@ export default function CanvasPage({ name }) {
|
|
|
764
1022
|
const canvasX = (el.scrollLeft + anchorX) / oldScale
|
|
765
1023
|
const canvasY = (el.scrollTop + anchorY) / oldScale
|
|
766
1024
|
|
|
767
|
-
//
|
|
1025
|
+
// Imperative DOM update — no React re-render
|
|
768
1026
|
zoomRef.current = clampedZoom
|
|
769
|
-
|
|
1027
|
+
zoomEl.style.transform = `scale(${newScale})`
|
|
1028
|
+
zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
|
|
1029
|
+
zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
|
|
1030
|
+
|
|
1031
|
+
// Hint GPU compositing during active zoom
|
|
1032
|
+
zoomEl.dataset.zooming = ''
|
|
770
1033
|
|
|
771
1034
|
// Scroll so the same canvas point stays under the anchor
|
|
772
1035
|
el.scrollLeft = canvasX * newScale - anchorX
|
|
773
1036
|
el.scrollTop = canvasY * newScale - anchorY
|
|
1037
|
+
|
|
1038
|
+
// Debounced commit: update React state for toolbar display + persistence
|
|
1039
|
+
clearTimeout(zoomCommitTimer.current)
|
|
1040
|
+
zoomCommitTimer.current = setTimeout(() => {
|
|
1041
|
+
// Remove GPU compositing hint
|
|
1042
|
+
delete zoomEl.dataset.zooming
|
|
1043
|
+
setZoom(clampedZoom)
|
|
1044
|
+
}, 150)
|
|
1045
|
+
|
|
1046
|
+
// Throttled zoom-changed event for external consumers (toolbar)
|
|
1047
|
+
if (!zoomEventTimer.current) {
|
|
1048
|
+
zoomEventTimer.current = setTimeout(() => {
|
|
1049
|
+
zoomEventTimer.current = null
|
|
1050
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
|
|
1051
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
1052
|
+
detail: { zoom: zoomRef.current }
|
|
1053
|
+
}))
|
|
1054
|
+
}, 100)
|
|
1055
|
+
}
|
|
774
1056
|
}
|
|
775
1057
|
|
|
776
1058
|
// Signal canvas mount/unmount to CoreUIBar
|
|
777
1059
|
useEffect(() => {
|
|
778
|
-
window[CANVAS_BRIDGE_STATE_KEY] = { active: true,
|
|
1060
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
|
|
779
1061
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
|
|
780
|
-
detail: {
|
|
1062
|
+
detail: { canvasId, zoom: zoomRef.current }
|
|
781
1063
|
}))
|
|
782
1064
|
|
|
783
1065
|
function handleStatusRequest() {
|
|
784
|
-
const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true,
|
|
1066
|
+
const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true, canvasId, zoom: zoomRef.current }
|
|
785
1067
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:status', { detail: state }))
|
|
786
1068
|
}
|
|
787
1069
|
|
|
@@ -789,10 +1071,10 @@ export default function CanvasPage({ name }) {
|
|
|
789
1071
|
|
|
790
1072
|
return () => {
|
|
791
1073
|
document.removeEventListener('storyboard:canvas:status-request', handleStatusRequest)
|
|
792
|
-
window[CANVAS_BRIDGE_STATE_KEY] = { active: false,
|
|
1074
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: false, canvasId: '', zoom: 100 }
|
|
793
1075
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:unmounted'))
|
|
794
1076
|
}
|
|
795
|
-
}, [
|
|
1077
|
+
}, [canvasId])
|
|
796
1078
|
|
|
797
1079
|
// Tell the Vite dev server to suppress full-reloads while this canvas is active.
|
|
798
1080
|
// The ?canvas-hmr URL param opts out of the guard for canvas UI development.
|
|
@@ -812,7 +1094,7 @@ export default function CanvasPage({ name }) {
|
|
|
812
1094
|
clearInterval(interval)
|
|
813
1095
|
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
|
|
814
1096
|
}
|
|
815
|
-
}, [
|
|
1097
|
+
}, [canvasId])
|
|
816
1098
|
|
|
817
1099
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
818
1100
|
const addWidget = useCallback(async (type) => {
|
|
@@ -820,7 +1102,7 @@ export default function CanvasPage({ name }) {
|
|
|
820
1102
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
821
1103
|
const pos = centerPositionForWidget(center, type, defaultProps)
|
|
822
1104
|
try {
|
|
823
|
-
const result = await addWidgetApi(
|
|
1105
|
+
const result = await addWidgetApi(canvasId, {
|
|
824
1106
|
type,
|
|
825
1107
|
props: defaultProps,
|
|
826
1108
|
position: pos,
|
|
@@ -832,16 +1114,43 @@ export default function CanvasPage({ name }) {
|
|
|
832
1114
|
} catch (err) {
|
|
833
1115
|
console.error('[canvas] Failed to add widget:', err)
|
|
834
1116
|
}
|
|
835
|
-
}, [
|
|
1117
|
+
}, [canvasId, undoRedo])
|
|
1118
|
+
|
|
1119
|
+
// Add a story widget by storyId — used by CanvasControls story picker
|
|
1120
|
+
const addStoryWidget = useCallback(async (storyId) => {
|
|
1121
|
+
const storyProps = { storyId, exportName: '', width: 600, height: 400 }
|
|
1122
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1123
|
+
const pos = centerPositionForWidget(center, 'story', storyProps)
|
|
1124
|
+
try {
|
|
1125
|
+
const result = await addWidgetApi(canvasId, {
|
|
1126
|
+
type: 'story',
|
|
1127
|
+
props: storyProps,
|
|
1128
|
+
position: pos,
|
|
1129
|
+
})
|
|
1130
|
+
if (result.success && result.widget) {
|
|
1131
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1132
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1133
|
+
}
|
|
1134
|
+
} catch (err) {
|
|
1135
|
+
console.error('[canvas] Failed to add story widget:', err)
|
|
1136
|
+
}
|
|
1137
|
+
}, [canvasId, undoRedo])
|
|
836
1138
|
|
|
837
1139
|
// Listen for CoreUIBar add-widget events
|
|
838
1140
|
useEffect(() => {
|
|
839
1141
|
function handleAddWidget(e) {
|
|
840
1142
|
addWidget(e.detail.type)
|
|
841
1143
|
}
|
|
1144
|
+
function handleAddStoryWidget(e) {
|
|
1145
|
+
addStoryWidget(e.detail.storyId)
|
|
1146
|
+
}
|
|
842
1147
|
document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
843
|
-
|
|
844
|
-
|
|
1148
|
+
document.addEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
|
|
1149
|
+
return () => {
|
|
1150
|
+
document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
1151
|
+
document.removeEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
|
|
1152
|
+
}
|
|
1153
|
+
}, [addWidget, addStoryWidget])
|
|
845
1154
|
|
|
846
1155
|
// Listen for zoom changes from CoreUIBar
|
|
847
1156
|
useEffect(() => {
|
|
@@ -860,7 +1169,7 @@ export default function CanvasPage({ name }) {
|
|
|
860
1169
|
function handleSnapToggle() {
|
|
861
1170
|
setSnapEnabled((prev) => {
|
|
862
1171
|
const next = !prev
|
|
863
|
-
updateCanvas(
|
|
1172
|
+
updateCanvas(canvasId, { settings: { snapToGrid: next } }).catch((err) =>
|
|
864
1173
|
console.error('[canvas] Failed to persist snap setting:', err)
|
|
865
1174
|
)
|
|
866
1175
|
return next
|
|
@@ -868,15 +1177,27 @@ export default function CanvasPage({ name }) {
|
|
|
868
1177
|
}
|
|
869
1178
|
document.addEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
870
1179
|
return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
871
|
-
}, [
|
|
1180
|
+
}, [canvasId])
|
|
872
1181
|
|
|
873
1182
|
// Broadcast snap state to Svelte toolbar
|
|
874
1183
|
useEffect(() => {
|
|
875
1184
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
|
|
876
1185
|
detail: { snapEnabled }
|
|
877
1186
|
}))
|
|
1187
|
+
snapEnabledRef.current = snapEnabled
|
|
878
1188
|
}, [snapEnabled])
|
|
879
1189
|
|
|
1190
|
+
// Respond to snap-state requests from Svelte toolbar (handles mount-order race)
|
|
1191
|
+
useEffect(() => {
|
|
1192
|
+
function handleRequest() {
|
|
1193
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
|
|
1194
|
+
detail: { snapEnabled: snapEnabledRef.current }
|
|
1195
|
+
}))
|
|
1196
|
+
}
|
|
1197
|
+
document.addEventListener('storyboard:canvas:snap-state-request', handleRequest)
|
|
1198
|
+
return () => document.removeEventListener('storyboard:canvas:snap-state-request', handleRequest)
|
|
1199
|
+
}, [])
|
|
1200
|
+
|
|
880
1201
|
// Listen for gridSize from Svelte toolbar config
|
|
881
1202
|
useEffect(() => {
|
|
882
1203
|
function handleGridSize(e) {
|
|
@@ -887,13 +1208,18 @@ export default function CanvasPage({ name }) {
|
|
|
887
1208
|
return () => document.removeEventListener('storyboard:canvas:grid-size', handleGridSize)
|
|
888
1209
|
}, [])
|
|
889
1210
|
|
|
1211
|
+
// Keep snapGridSize ref in sync for drop handler
|
|
1212
|
+
useEffect(() => {
|
|
1213
|
+
snapGridSizeRef.current = snapGridSize
|
|
1214
|
+
}, [snapGridSize])
|
|
1215
|
+
|
|
890
1216
|
// Listen for zoom-to-fit from CoreUIBar
|
|
891
1217
|
useEffect(() => {
|
|
892
1218
|
function handleZoomToFit() {
|
|
893
1219
|
const el = scrollRef.current
|
|
894
1220
|
if (!el) return
|
|
895
1221
|
|
|
896
|
-
const bounds = computeCanvasBounds(localWidgets,
|
|
1222
|
+
const bounds = computeCanvasBounds(localWidgets, componentEntries)
|
|
897
1223
|
if (!bounds) return
|
|
898
1224
|
|
|
899
1225
|
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
@@ -907,17 +1233,32 @@ export default function CanvasPage({ name }) {
|
|
|
907
1233
|
const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
|
|
908
1234
|
const newScale = fitZoom / 100
|
|
909
1235
|
|
|
910
|
-
//
|
|
1236
|
+
// Imperative DOM update — same path as applyZoom
|
|
911
1237
|
zoomRef.current = fitZoom
|
|
912
|
-
|
|
1238
|
+
const zoomEl = zoomElRef.current
|
|
1239
|
+
if (zoomEl) {
|
|
1240
|
+
zoomEl.style.transform = `scale(${newScale})`
|
|
1241
|
+
zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
|
|
1242
|
+
zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
|
|
1243
|
+
}
|
|
1244
|
+
setZoom(fitZoom)
|
|
913
1245
|
|
|
914
1246
|
// Scroll so the bounding box top-left (with padding) is at viewport top-left
|
|
915
1247
|
el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
|
|
916
1248
|
el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
|
|
1249
|
+
|
|
1250
|
+
// Persist after both zoom and scroll are settled
|
|
1251
|
+
if (viewportInitName.current === canvasId) {
|
|
1252
|
+
saveViewportState(canvasId, {
|
|
1253
|
+
zoom: fitZoom,
|
|
1254
|
+
scrollLeft: el.scrollLeft,
|
|
1255
|
+
scrollTop: el.scrollTop,
|
|
1256
|
+
})
|
|
1257
|
+
}
|
|
917
1258
|
}
|
|
918
1259
|
document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
919
1260
|
return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
920
|
-
}, [localWidgets,
|
|
1261
|
+
}, [localWidgets, componentEntries])
|
|
921
1262
|
|
|
922
1263
|
// Canvas background should follow toolbar theme target.
|
|
923
1264
|
useEffect(() => {
|
|
@@ -932,11 +1273,11 @@ export default function CanvasPage({ name }) {
|
|
|
932
1273
|
|
|
933
1274
|
// Broadcast zoom level to CoreUIBar whenever it changes
|
|
934
1275
|
useEffect(() => {
|
|
935
|
-
window[CANVAS_BRIDGE_STATE_KEY] = { active: true,
|
|
1276
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom }
|
|
936
1277
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
937
1278
|
detail: { zoom }
|
|
938
1279
|
}))
|
|
939
|
-
}, [
|
|
1280
|
+
}, [canvasId, zoom])
|
|
940
1281
|
|
|
941
1282
|
// Delete selected widget on Delete/Backspace key
|
|
942
1283
|
useEffect(() => {
|
|
@@ -958,6 +1299,17 @@ export default function CanvasPage({ name }) {
|
|
|
958
1299
|
e.preventDefault()
|
|
959
1300
|
setSelectedWidgetIds(new Set())
|
|
960
1301
|
}
|
|
1302
|
+
// Copy shortcut (one or more widgets selected):
|
|
1303
|
+
// cmd+c → copy canvasId::id1,id2,... (for cross-canvas paste-duplicate)
|
|
1304
|
+
const mod = e.metaKey || e.ctrlKey
|
|
1305
|
+
if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size >= 1) {
|
|
1306
|
+
// Filter out non-duplicable widgets (jsx- component widgets are code)
|
|
1307
|
+
const copyableIds = [...selectedWidgetIds].filter(id => !id.startsWith('jsx-'))
|
|
1308
|
+
if (copyableIds.length > 0) {
|
|
1309
|
+
e.preventDefault()
|
|
1310
|
+
navigator.clipboard.writeText(`${canvasId}::${copyableIds.join(',')}`).catch(() => {})
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
961
1313
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
962
1314
|
e.preventDefault()
|
|
963
1315
|
if (selectedWidgetIds.size > 1) {
|
|
@@ -968,7 +1320,7 @@ export default function CanvasPage({ name }) {
|
|
|
968
1320
|
if (!prev) return prev
|
|
969
1321
|
const next = prev.filter(w => !selectedWidgetIds.has(w.id))
|
|
970
1322
|
queueWrite(() =>
|
|
971
|
-
updateCanvas(
|
|
1323
|
+
updateCanvas(canvasId, { widgets: next }).catch(err =>
|
|
972
1324
|
console.error('[canvas] Failed to save multi-delete:', err)
|
|
973
1325
|
)
|
|
974
1326
|
)
|
|
@@ -983,50 +1335,17 @@ export default function CanvasPage({ name }) {
|
|
|
983
1335
|
}
|
|
984
1336
|
document.addEventListener('keydown', handleKeyDown)
|
|
985
1337
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
986
|
-
}, [selectedWidgetIds, handleWidgetRemove, undoRedo,
|
|
1338
|
+
}, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, debouncedSave])
|
|
987
1339
|
|
|
988
|
-
//
|
|
1340
|
+
// Ref to store processImageFile for use by drop effect
|
|
1341
|
+
const processImageFileRef = useRef(null)
|
|
1342
|
+
|
|
1343
|
+
// Paste and drop handler — images become image widgets, same-origin URLs become prototypes,
|
|
989
1344
|
// other URLs become link previews, text becomes markdown
|
|
990
1345
|
useEffect(() => {
|
|
991
1346
|
const origin = window.location.origin
|
|
992
1347
|
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
|
-
}
|
|
1348
|
+
const pasteCtx = createPasteContext(origin, basePath)
|
|
1030
1349
|
|
|
1031
1350
|
function blobToDataUrl(blob) {
|
|
1032
1351
|
return new Promise((resolve, reject) => {
|
|
@@ -1046,6 +1365,59 @@ export default function CanvasPage({ name }) {
|
|
|
1046
1365
|
})
|
|
1047
1366
|
}
|
|
1048
1367
|
|
|
1368
|
+
/**
|
|
1369
|
+
* Process an image file (from paste or drop) and add it as a widget.
|
|
1370
|
+
* @param {File|Blob} file - Image file to process
|
|
1371
|
+
* @param {{ x: number, y: number }|null} position - Drop position, or null to use viewport center
|
|
1372
|
+
*/
|
|
1373
|
+
async function processImageFile(file, position = null) {
|
|
1374
|
+
try {
|
|
1375
|
+
const dataUrl = await blobToDataUrl(file)
|
|
1376
|
+
const { width: natW, height: natH } = await getImageDimensions(dataUrl)
|
|
1377
|
+
|
|
1378
|
+
// Display at 2x retina: halve natural dimensions, then cap at 600px
|
|
1379
|
+
const maxWidth = 600
|
|
1380
|
+
let displayW = Math.round(natW / 2)
|
|
1381
|
+
let displayH = Math.round(natH / 2)
|
|
1382
|
+
if (displayW > maxWidth) {
|
|
1383
|
+
displayH = Math.round(displayH * (maxWidth / displayW))
|
|
1384
|
+
displayW = maxWidth
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const uploadResult = await uploadImage(dataUrl, canvasId)
|
|
1388
|
+
if (!uploadResult.success) {
|
|
1389
|
+
console.error('[canvas] Image upload failed:', uploadResult.error)
|
|
1390
|
+
return false
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// Use provided position or fall back to viewport center
|
|
1394
|
+
let pos
|
|
1395
|
+
if (position) {
|
|
1396
|
+
pos = { x: position.x, y: position.y }
|
|
1397
|
+
} else {
|
|
1398
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1399
|
+
pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const result = await addWidgetApi(canvasId, {
|
|
1403
|
+
type: 'image',
|
|
1404
|
+
props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
|
|
1405
|
+
position: pos,
|
|
1406
|
+
})
|
|
1407
|
+
if (result.success && result.widget) {
|
|
1408
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1409
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1410
|
+
}
|
|
1411
|
+
return true
|
|
1412
|
+
} catch (err) {
|
|
1413
|
+
console.error('[canvas] Failed to process image:', err)
|
|
1414
|
+
return false
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Store in ref for use by drag/drop effect
|
|
1419
|
+
processImageFileRef.current = processImageFile
|
|
1420
|
+
|
|
1049
1421
|
async function handleImagePaste(e) {
|
|
1050
1422
|
const items = e.clipboardData?.items
|
|
1051
1423
|
if (!items) return false
|
|
@@ -1057,40 +1429,7 @@ export default function CanvasPage({ name }) {
|
|
|
1057
1429
|
if (!blob) continue
|
|
1058
1430
|
|
|
1059
1431
|
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
|
-
}
|
|
1432
|
+
await processImageFile(blob, null)
|
|
1094
1433
|
return true
|
|
1095
1434
|
}
|
|
1096
1435
|
return false
|
|
@@ -1107,32 +1446,94 @@ export default function CanvasPage({ name }) {
|
|
|
1107
1446
|
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
1108
1447
|
if (!text) return
|
|
1109
1448
|
|
|
1110
|
-
|
|
1449
|
+
// Detect canvasId::widgetId or canvasId::id1,id2,id3 format for widget duplication
|
|
1450
|
+
// Also supports legacy canvasId/widgetId for basenames without slashes,
|
|
1451
|
+
// but only when the second segment looks like a widget ID (type-hash).
|
|
1452
|
+
const widgetRefMatch = text.match(/^(.+)::([^:]+)$/) || (text.indexOf('::') === -1 && text.match(/^([^/]+)\/((?:sticky-note|markdown|prototype|link-preview|figma-embed|component|image)-[a-z0-9]+)$/))
|
|
1453
|
+
if (widgetRefMatch) {
|
|
1454
|
+
e.preventDefault()
|
|
1455
|
+
const [, sourceCanvas, sourceWidgetRef] = widgetRefMatch
|
|
1456
|
+
const sourceWidgetIds = sourceWidgetRef.split(',').filter(id => !id.startsWith('jsx-'))
|
|
1457
|
+
if (sourceWidgetIds.length === 0) return
|
|
1111
1458
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1459
|
+
try {
|
|
1460
|
+
// Resolve source widgets in canvas order
|
|
1461
|
+
let sourceList
|
|
1462
|
+
if (sourceCanvas === canvasId) {
|
|
1463
|
+
sourceList = localWidgets ?? []
|
|
1464
|
+
} else {
|
|
1465
|
+
const canvasData = await getCanvasApi(sourceCanvas)
|
|
1466
|
+
sourceList = canvasData?.widgets ?? []
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const sourceWidgets = sourceList.filter(w => sourceWidgetIds.includes(w.id))
|
|
1470
|
+
if (sourceWidgets.length === 0) return
|
|
1471
|
+
|
|
1472
|
+
// Compute bounding box of source widgets for relative positioning
|
|
1473
|
+
const fallback = { width: 200, height: 150 }
|
|
1474
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
|
1475
|
+
for (const w of sourceWidgets) {
|
|
1476
|
+
const wx = w.position?.x ?? 0
|
|
1477
|
+
const wy = w.position?.y ?? 0
|
|
1478
|
+
const ww = w.props?.width ?? WIDGET_FALLBACK_SIZES[w.type]?.width ?? fallback.width
|
|
1479
|
+
const wh = w.props?.height ?? WIDGET_FALLBACK_SIZES[w.type]?.height ?? fallback.height
|
|
1480
|
+
if (wx < minX) minX = wx
|
|
1481
|
+
if (wy < minY) minY = wy
|
|
1482
|
+
if (wx + ww > maxX) maxX = wx + ww
|
|
1483
|
+
if (wy + wh > maxY) maxY = wy + wh
|
|
1484
|
+
}
|
|
1485
|
+
const groupW = maxX - minX
|
|
1486
|
+
const groupH = maxY - minY
|
|
1487
|
+
|
|
1488
|
+
// Center the group in the viewport
|
|
1489
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1490
|
+
const baseX = Math.round(center.x - groupW / 2)
|
|
1491
|
+
const baseY = Math.round(center.y - groupH / 2)
|
|
1492
|
+
|
|
1493
|
+
// Single undo snapshot for the entire paste
|
|
1494
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1495
|
+
|
|
1496
|
+
// Paste all widgets, collecting new IDs for selection
|
|
1497
|
+
const newWidgets = []
|
|
1498
|
+
for (const w of sourceWidgets) {
|
|
1499
|
+
const relX = (w.position?.x ?? 0) - minX
|
|
1500
|
+
const relY = (w.position?.y ?? 0) - minY
|
|
1501
|
+
const result = await addWidgetApi(canvasId, {
|
|
1502
|
+
type: w.type,
|
|
1503
|
+
props: { ...w.props },
|
|
1504
|
+
position: { x: baseX + relX, y: baseY + relY },
|
|
1505
|
+
})
|
|
1506
|
+
if (result.success && result.widget) {
|
|
1507
|
+
newWidgets.push(result.widget)
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
if (newWidgets.length > 0) {
|
|
1512
|
+
setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
|
|
1513
|
+
setSelectedWidgetIds(new Set(newWidgets.map(w => w.id)))
|
|
1514
|
+
}
|
|
1515
|
+
} catch (err) {
|
|
1516
|
+
console.error('[canvas] Failed to paste widget reference:', err)
|
|
1126
1517
|
}
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1518
|
+
// Always consume the ref — never fall through to markdown creation
|
|
1519
|
+
return
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
e.preventDefault()
|
|
1523
|
+
const resolved = resolvePaste(text, pasteCtx, getPasteRules())
|
|
1524
|
+
if (!resolved) return
|
|
1525
|
+
const { type } = resolved
|
|
1526
|
+
let props = resolved.props
|
|
1527
|
+
|
|
1528
|
+
if (type === 'link-preview' && isGitHubEmbedUrl(props?.url || text)) {
|
|
1529
|
+
const githubUpdates = await buildGitHubPreviewUpdates(props?.url || text)
|
|
1530
|
+
if (githubUpdates) props = { ...props, ...githubUpdates }
|
|
1130
1531
|
}
|
|
1131
1532
|
|
|
1132
1533
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1133
1534
|
const pos = centerPositionForWidget(center, type, props)
|
|
1134
1535
|
try {
|
|
1135
|
-
const result = await addWidgetApi(
|
|
1536
|
+
const result = await addWidgetApi(canvasId, {
|
|
1136
1537
|
type,
|
|
1137
1538
|
props,
|
|
1138
1539
|
position: pos,
|
|
@@ -1140,14 +1541,81 @@ export default function CanvasPage({ name }) {
|
|
|
1140
1541
|
if (result.success && result.widget) {
|
|
1141
1542
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1142
1543
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1544
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1143
1545
|
}
|
|
1144
1546
|
} catch (err) {
|
|
1145
1547
|
console.error('[canvas] Failed to add widget from paste:', err)
|
|
1146
1548
|
}
|
|
1147
1549
|
}
|
|
1550
|
+
|
|
1148
1551
|
document.addEventListener('paste', handlePaste)
|
|
1149
1552
|
return () => document.removeEventListener('paste', handlePaste)
|
|
1150
|
-
|
|
1553
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1554
|
+
}, [canvasId, undoRedo, localWidgets])
|
|
1555
|
+
|
|
1556
|
+
// --- Drag and drop handlers for images from Finder/file manager ---
|
|
1557
|
+
// Separate effect to ensure listeners attach after scroll container mounts (loading=false)
|
|
1558
|
+
useEffect(() => {
|
|
1559
|
+
if (loading) return // Don't attach until canvas is loaded and scroll container exists
|
|
1560
|
+
|
|
1561
|
+
const scrollEl = scrollRef.current
|
|
1562
|
+
if (!scrollEl) return
|
|
1563
|
+
|
|
1564
|
+
function handleDragOver(e) {
|
|
1565
|
+
// Only handle if dragging files (not internal widget drag)
|
|
1566
|
+
if (!e.dataTransfer?.types?.includes('Files')) return
|
|
1567
|
+
e.preventDefault()
|
|
1568
|
+
e.dataTransfer.dropEffect = 'copy'
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
async function handleDrop(e) {
|
|
1572
|
+
// Only handle file drops, not internal widget drags
|
|
1573
|
+
if (!e.dataTransfer?.types?.includes('Files')) return
|
|
1574
|
+
|
|
1575
|
+
// Prevent browser default (opening file) immediately for any file drop
|
|
1576
|
+
e.preventDefault()
|
|
1577
|
+
e.stopPropagation()
|
|
1578
|
+
|
|
1579
|
+
const files = e.dataTransfer.files
|
|
1580
|
+
if (!files || files.length === 0) return
|
|
1581
|
+
|
|
1582
|
+
// Filter to image files only — non-images are silently ignored (default already prevented)
|
|
1583
|
+
const imageFiles = Array.from(files).filter((f) => f.type.startsWith('image/'))
|
|
1584
|
+
if (imageFiles.length === 0) return
|
|
1585
|
+
|
|
1586
|
+
// Convert drop coordinates to canvas coordinates
|
|
1587
|
+
const rect = scrollEl.getBoundingClientRect()
|
|
1588
|
+
const scale = zoomRef.current / 100
|
|
1589
|
+
|
|
1590
|
+
// Mouse position relative to scroll container
|
|
1591
|
+
const mouseX = e.clientX - rect.left
|
|
1592
|
+
const mouseY = e.clientY - rect.top
|
|
1593
|
+
|
|
1594
|
+
// Convert to canvas coordinates (account for scroll and zoom)
|
|
1595
|
+
const canvasX = (scrollEl.scrollLeft + mouseX) / scale
|
|
1596
|
+
const canvasY = (scrollEl.scrollTop + mouseY) / scale
|
|
1597
|
+
|
|
1598
|
+
// Snap to grid if enabled, using current grid size
|
|
1599
|
+
const gridSize = snapGridSizeRef.current
|
|
1600
|
+
const shouldSnap = snapEnabledRef.current
|
|
1601
|
+
const snappedX = shouldSnap ? Math.round(canvasX / gridSize) * gridSize : Math.round(canvasX)
|
|
1602
|
+
const snappedY = shouldSnap ? Math.round(canvasY / gridSize) * gridSize : Math.round(canvasY)
|
|
1603
|
+
|
|
1604
|
+
// Process each image file, offsetting subsequent images
|
|
1605
|
+
for (let i = 0; i < imageFiles.length; i++) {
|
|
1606
|
+
const offset = shouldSnap ? i * gridSize : i * 24
|
|
1607
|
+
await processImageFileRef.current?.(imageFiles[i], { x: snappedX + offset, y: snappedY + offset })
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
scrollEl.addEventListener('dragover', handleDragOver)
|
|
1612
|
+
scrollEl.addEventListener('drop', handleDrop)
|
|
1613
|
+
|
|
1614
|
+
return () => {
|
|
1615
|
+
scrollEl.removeEventListener('dragover', handleDragOver)
|
|
1616
|
+
scrollEl.removeEventListener('drop', handleDrop)
|
|
1617
|
+
}
|
|
1618
|
+
}, [loading])
|
|
1151
1619
|
|
|
1152
1620
|
// --- Undo / Redo ---
|
|
1153
1621
|
const handleUndo = useCallback(() => {
|
|
@@ -1158,11 +1626,11 @@ export default function CanvasPage({ name }) {
|
|
|
1158
1626
|
setLocalWidgets(previous.widgets)
|
|
1159
1627
|
setLocalSources(previous.sources)
|
|
1160
1628
|
queueWrite(() =>
|
|
1161
|
-
updateCanvas(
|
|
1629
|
+
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
|
|
1162
1630
|
console.error('[canvas] Failed to persist undo:', err)
|
|
1163
1631
|
)
|
|
1164
1632
|
)
|
|
1165
|
-
}, [
|
|
1633
|
+
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
1166
1634
|
|
|
1167
1635
|
const handleRedo = useCallback(() => {
|
|
1168
1636
|
const next = undoRedo.redo(stateRef.current)
|
|
@@ -1172,11 +1640,11 @@ export default function CanvasPage({ name }) {
|
|
|
1172
1640
|
setLocalWidgets(next.widgets)
|
|
1173
1641
|
setLocalSources(next.sources)
|
|
1174
1642
|
queueWrite(() =>
|
|
1175
|
-
updateCanvas(
|
|
1643
|
+
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
|
|
1176
1644
|
console.error('[canvas] Failed to persist redo:', err)
|
|
1177
1645
|
)
|
|
1178
1646
|
)
|
|
1179
|
-
}, [
|
|
1647
|
+
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
1180
1648
|
|
|
1181
1649
|
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
|
|
1182
1650
|
useEffect(() => {
|
|
@@ -1235,6 +1703,69 @@ export default function CanvasPage({ name }) {
|
|
|
1235
1703
|
return () => document.removeEventListener('wheel', handleWheel)
|
|
1236
1704
|
}, [])
|
|
1237
1705
|
|
|
1706
|
+
// Receive cmd+wheel events forwarded from prototype/story iframes
|
|
1707
|
+
useEffect(() => {
|
|
1708
|
+
function handleMessage(e) {
|
|
1709
|
+
if (e.data?.type !== 'storyboard:embed:wheel') return
|
|
1710
|
+
zoomAccum.current += -e.data.deltaY
|
|
1711
|
+
const step = Math.trunc(zoomAccum.current)
|
|
1712
|
+
if (step === 0) return
|
|
1713
|
+
zoomAccum.current -= step
|
|
1714
|
+
applyZoom(zoomRef.current + step)
|
|
1715
|
+
}
|
|
1716
|
+
window.addEventListener('message', handleMessage)
|
|
1717
|
+
return () => window.removeEventListener('message', handleMessage)
|
|
1718
|
+
}, [])
|
|
1719
|
+
|
|
1720
|
+
// Touch pinch-to-zoom for mobile — two-finger pinch zooms the canvas
|
|
1721
|
+
const pinchState = useRef({ active: false, startDist: 0, startZoom: 0, centerX: 0, centerY: 0 })
|
|
1722
|
+
useEffect(() => {
|
|
1723
|
+
const el = scrollRef.current
|
|
1724
|
+
if (!el) return
|
|
1725
|
+
|
|
1726
|
+
function getTouchDist(t1, t2) {
|
|
1727
|
+
const dx = t1.clientX - t2.clientX
|
|
1728
|
+
const dy = t1.clientY - t2.clientY
|
|
1729
|
+
return Math.sqrt(dx * dx + dy * dy)
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function handleTouchStart(e) {
|
|
1733
|
+
if (e.touches.length !== 2) return
|
|
1734
|
+
const dist = getTouchDist(e.touches[0], e.touches[1])
|
|
1735
|
+
pinchState.current = {
|
|
1736
|
+
active: true,
|
|
1737
|
+
startDist: dist,
|
|
1738
|
+
startZoom: zoomRef.current,
|
|
1739
|
+
centerX: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
|
1740
|
+
centerY: (e.touches[0].clientY + e.touches[1].clientY) / 2,
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
function handleTouchMove(e) {
|
|
1745
|
+
if (!pinchState.current.active || e.touches.length !== 2) return
|
|
1746
|
+
e.preventDefault()
|
|
1747
|
+
const dist = getTouchDist(e.touches[0], e.touches[1])
|
|
1748
|
+
const ratio = dist / pinchState.current.startDist
|
|
1749
|
+
const newZoom = Math.round(pinchState.current.startZoom * ratio)
|
|
1750
|
+
applyZoom(newZoom, pinchState.current.centerX, pinchState.current.centerY)
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
function handleTouchEnd() {
|
|
1754
|
+
pinchState.current.active = false
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
el.addEventListener('touchstart', handleTouchStart, { passive: true })
|
|
1758
|
+
el.addEventListener('touchmove', handleTouchMove, { passive: false })
|
|
1759
|
+
el.addEventListener('touchend', handleTouchEnd)
|
|
1760
|
+
el.addEventListener('touchcancel', handleTouchEnd)
|
|
1761
|
+
return () => {
|
|
1762
|
+
el.removeEventListener('touchstart', handleTouchStart)
|
|
1763
|
+
el.removeEventListener('touchmove', handleTouchMove)
|
|
1764
|
+
el.removeEventListener('touchend', handleTouchEnd)
|
|
1765
|
+
el.removeEventListener('touchcancel', handleTouchEnd)
|
|
1766
|
+
}
|
|
1767
|
+
}, [])
|
|
1768
|
+
|
|
1238
1769
|
// Space + drag to pan the canvas
|
|
1239
1770
|
const [spaceHeld, setSpaceHeld] = useState(false)
|
|
1240
1771
|
const isPanning = useRef(false)
|
|
@@ -1294,10 +1825,19 @@ export default function CanvasPage({ name }) {
|
|
|
1294
1825
|
document.addEventListener('mouseup', handlePanEnd)
|
|
1295
1826
|
}, [spaceHeld])
|
|
1296
1827
|
|
|
1828
|
+
// Stable callback for deselecting all widgets
|
|
1829
|
+
const handleDeselectAll = useCallback(() => setSelectedWidgetIds(new Set()), [])
|
|
1830
|
+
|
|
1831
|
+
// Stable callback for widget removal + deselect
|
|
1832
|
+
const handleWidgetRemoveAndDeselect = useCallback((id) => {
|
|
1833
|
+
handleWidgetRemove(id)
|
|
1834
|
+
setSelectedWidgetIds(new Set())
|
|
1835
|
+
}, [handleWidgetRemove])
|
|
1836
|
+
|
|
1297
1837
|
if (!canvas) {
|
|
1298
1838
|
return (
|
|
1299
1839
|
<div className={styles.empty}>
|
|
1300
|
-
<p>Canvas “{
|
|
1840
|
+
<p>Canvas “{canvasId}” not found</p>
|
|
1301
1841
|
</div>
|
|
1302
1842
|
)
|
|
1303
1843
|
}
|
|
@@ -1328,54 +1868,50 @@ export default function CanvasPage({ name }) {
|
|
|
1328
1868
|
// Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
|
|
1329
1869
|
const allChildren = []
|
|
1330
1870
|
|
|
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
|
-
|
|
1871
|
+
// 1. Component widgets (from jsxExports or sources fallback)
|
|
1872
|
+
const componentFeatures = getFeatures('component', { isLocalDev })
|
|
1873
|
+
for (const entry of componentEntries) {
|
|
1874
|
+
const { exportName, Component, sourceData } = entry
|
|
1875
|
+
const sourcePosition = sourceData.position || { x: 0, y: 0 }
|
|
1876
|
+
allChildren.push(
|
|
1877
|
+
<div
|
|
1878
|
+
key={`jsx-${exportName}`}
|
|
1879
|
+
id={`jsx-${exportName}`}
|
|
1880
|
+
data-tc-x={sourcePosition.x}
|
|
1881
|
+
data-tc-y={sourcePosition.y}
|
|
1882
|
+
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
1883
|
+
{...canvasPrimerAttrs}
|
|
1884
|
+
style={canvasThemeVars}
|
|
1885
|
+
onClick={isLocalDev ? (e) => {
|
|
1886
|
+
e.stopPropagation()
|
|
1887
|
+
if (!e.target.closest('.tc-drag-handle')) {
|
|
1888
|
+
handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
|
|
1889
|
+
}
|
|
1890
|
+
} : undefined}
|
|
1891
|
+
>
|
|
1892
|
+
<WidgetChrome
|
|
1893
|
+
widgetId={`jsx-${exportName}`}
|
|
1894
|
+
features={componentFeatures}
|
|
1895
|
+
selected={selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1896
|
+
multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1897
|
+
onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
|
|
1898
|
+
onDeselect={handleDeselectAll}
|
|
1899
|
+
readOnly={!isLocalDev}
|
|
1358
1900
|
>
|
|
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
|
-
}
|
|
1901
|
+
<ComponentWidget
|
|
1902
|
+
component={Component}
|
|
1903
|
+
jsxModule={canvas?._jsxModule}
|
|
1904
|
+
exportName={exportName}
|
|
1905
|
+
canvasTheme={canvasTheme}
|
|
1906
|
+
isLocalDev={isLocalDev}
|
|
1907
|
+
width={sourceData.width}
|
|
1908
|
+
height={sourceData.height}
|
|
1909
|
+
onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
|
|
1910
|
+
resizable={isResizable('component') && isLocalDev}
|
|
1911
|
+
/>
|
|
1912
|
+
</WidgetChrome>
|
|
1913
|
+
</div>
|
|
1914
|
+
)
|
|
1379
1915
|
}
|
|
1380
1916
|
|
|
1381
1917
|
// 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
|
|
@@ -1401,13 +1937,12 @@ export default function CanvasPage({ name }) {
|
|
|
1401
1937
|
selected={selectedWidgetIds.has(widget.id)}
|
|
1402
1938
|
multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
|
|
1403
1939
|
onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
|
|
1404
|
-
onDeselect={
|
|
1940
|
+
onDeselect={handleDeselectAll}
|
|
1405
1941
|
onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
|
|
1406
1942
|
onCopy={isLocalDev ? handleWidgetCopy : undefined}
|
|
1407
|
-
onRemove={isLocalDev ?
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
} : undefined}
|
|
1943
|
+
onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
|
|
1944
|
+
onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
|
|
1945
|
+
canRefreshGitHub={isLocalDev}
|
|
1411
1946
|
readOnly={!isLocalDev}
|
|
1412
1947
|
/>
|
|
1413
1948
|
</div>
|
|
@@ -1419,24 +1954,8 @@ export default function CanvasPage({ name }) {
|
|
|
1419
1954
|
return (
|
|
1420
1955
|
<>
|
|
1421
1956
|
<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>
|
|
1957
|
+
<h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || canvasId.split('/').pop()}</h1>
|
|
1958
|
+
<PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
|
|
1440
1959
|
{isLocalDev && (
|
|
1441
1960
|
<span className={styles.localEditingLabel}>Local editing</span>
|
|
1442
1961
|
)}
|
|
@@ -1451,10 +1970,11 @@ export default function CanvasPage({ name }) {
|
|
|
1451
1970
|
...canvasThemeVars,
|
|
1452
1971
|
...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
|
|
1453
1972
|
}}
|
|
1454
|
-
onClick={
|
|
1973
|
+
onClick={handleDeselectAll}
|
|
1455
1974
|
onMouseDown={handlePanStart}
|
|
1456
1975
|
>
|
|
1457
1976
|
<div
|
|
1977
|
+
ref={zoomElRef}
|
|
1458
1978
|
data-storyboard-canvas-zoom
|
|
1459
1979
|
data-sb-canvas-theme={canvasTheme}
|
|
1460
1980
|
className={styles.canvasZoom}
|
|
@@ -1471,6 +1991,28 @@ export default function CanvasPage({ name }) {
|
|
|
1471
1991
|
</Canvas>
|
|
1472
1992
|
</div>
|
|
1473
1993
|
</div>
|
|
1994
|
+
{showGhInstallBanner && (
|
|
1995
|
+
<aside className={styles.ghInstallBanner} role="status" aria-live="polite">
|
|
1996
|
+
<span className={styles.ghInstallBannerText}>
|
|
1997
|
+
GitHub embeds require local <code>gh</code> CLI access.
|
|
1998
|
+
</span>
|
|
1999
|
+
<a
|
|
2000
|
+
href={GH_INSTALL_URL}
|
|
2001
|
+
target="_blank"
|
|
2002
|
+
rel="noopener noreferrer"
|
|
2003
|
+
className={styles.ghInstallBannerLink}
|
|
2004
|
+
>
|
|
2005
|
+
Install GitHub CLI
|
|
2006
|
+
</a>
|
|
2007
|
+
<button
|
|
2008
|
+
type="button"
|
|
2009
|
+
className={styles.ghInstallBannerDismiss}
|
|
2010
|
+
onClick={() => setShowGhInstallBanner(false)}
|
|
2011
|
+
>
|
|
2012
|
+
Dismiss
|
|
2013
|
+
</button>
|
|
2014
|
+
</aside>
|
|
2015
|
+
)}
|
|
1474
2016
|
</>
|
|
1475
2017
|
)
|
|
1476
2018
|
}
|