@dfosco/storyboard-react 4.0.0-beta.3 → 4.0.0-beta.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -4
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +790 -302
- package/src/canvas/CanvasPage.module.css +70 -47
- 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 +102 -0
- package/src/canvas/PageSelector.module.css +93 -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 +247 -18
- package/src/canvas/widgets/LinkPreview.module.css +349 -8
- package/src/canvas/widgets/LinkPreview.test.jsx +71 -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 +319 -70
- 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 +512 -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/refreshQueue.js +108 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
- package/src/canvas/widgets/useSnapshotCapture.js +157 -0
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +164 -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 +458 -71
- 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,14 @@ export default function CanvasPage({ name }) {
|
|
|
958
1299
|
e.preventDefault()
|
|
959
1300
|
setSelectedWidgetIds(new Set())
|
|
960
1301
|
}
|
|
1302
|
+
// Copy shortcut (single widget selected):
|
|
1303
|
+
// cmd+c → copy canvasId::widgetId (for cross-canvas paste-duplicate)
|
|
1304
|
+
const mod = e.metaKey || e.ctrlKey
|
|
1305
|
+
if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size === 1) {
|
|
1306
|
+
const widgetId = [...selectedWidgetIds][0]
|
|
1307
|
+
e.preventDefault()
|
|
1308
|
+
navigator.clipboard.writeText(`${canvasId}::${widgetId}`).catch(() => {})
|
|
1309
|
+
}
|
|
961
1310
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
962
1311
|
e.preventDefault()
|
|
963
1312
|
if (selectedWidgetIds.size > 1) {
|
|
@@ -968,7 +1317,7 @@ export default function CanvasPage({ name }) {
|
|
|
968
1317
|
if (!prev) return prev
|
|
969
1318
|
const next = prev.filter(w => !selectedWidgetIds.has(w.id))
|
|
970
1319
|
queueWrite(() =>
|
|
971
|
-
updateCanvas(
|
|
1320
|
+
updateCanvas(canvasId, { widgets: next }).catch(err =>
|
|
972
1321
|
console.error('[canvas] Failed to save multi-delete:', err)
|
|
973
1322
|
)
|
|
974
1323
|
)
|
|
@@ -983,50 +1332,17 @@ export default function CanvasPage({ name }) {
|
|
|
983
1332
|
}
|
|
984
1333
|
document.addEventListener('keydown', handleKeyDown)
|
|
985
1334
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
986
|
-
}, [selectedWidgetIds, handleWidgetRemove, undoRedo,
|
|
1335
|
+
}, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, debouncedSave])
|
|
1336
|
+
|
|
1337
|
+
// Ref to store processImageFile for use by drop effect
|
|
1338
|
+
const processImageFileRef = useRef(null)
|
|
987
1339
|
|
|
988
|
-
// Paste handler — images become image widgets, same-origin URLs become prototypes,
|
|
1340
|
+
// Paste and drop handler — images become image widgets, same-origin URLs become prototypes,
|
|
989
1341
|
// other URLs become link previews, text becomes markdown
|
|
990
1342
|
useEffect(() => {
|
|
991
1343
|
const origin = window.location.origin
|
|
992
1344
|
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
|
-
}
|
|
1345
|
+
const pasteCtx = createPasteContext(origin, basePath)
|
|
1030
1346
|
|
|
1031
1347
|
function blobToDataUrl(blob) {
|
|
1032
1348
|
return new Promise((resolve, reject) => {
|
|
@@ -1046,6 +1362,59 @@ export default function CanvasPage({ name }) {
|
|
|
1046
1362
|
})
|
|
1047
1363
|
}
|
|
1048
1364
|
|
|
1365
|
+
/**
|
|
1366
|
+
* Process an image file (from paste or drop) and add it as a widget.
|
|
1367
|
+
* @param {File|Blob} file - Image file to process
|
|
1368
|
+
* @param {{ x: number, y: number }|null} position - Drop position, or null to use viewport center
|
|
1369
|
+
*/
|
|
1370
|
+
async function processImageFile(file, position = null) {
|
|
1371
|
+
try {
|
|
1372
|
+
const dataUrl = await blobToDataUrl(file)
|
|
1373
|
+
const { width: natW, height: natH } = await getImageDimensions(dataUrl)
|
|
1374
|
+
|
|
1375
|
+
// Display at 2x retina: halve natural dimensions, then cap at 600px
|
|
1376
|
+
const maxWidth = 600
|
|
1377
|
+
let displayW = Math.round(natW / 2)
|
|
1378
|
+
let displayH = Math.round(natH / 2)
|
|
1379
|
+
if (displayW > maxWidth) {
|
|
1380
|
+
displayH = Math.round(displayH * (maxWidth / displayW))
|
|
1381
|
+
displayW = maxWidth
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const uploadResult = await uploadImage(dataUrl, canvasId)
|
|
1385
|
+
if (!uploadResult.success) {
|
|
1386
|
+
console.error('[canvas] Image upload failed:', uploadResult.error)
|
|
1387
|
+
return false
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// Use provided position or fall back to viewport center
|
|
1391
|
+
let pos
|
|
1392
|
+
if (position) {
|
|
1393
|
+
pos = { x: position.x, y: position.y }
|
|
1394
|
+
} else {
|
|
1395
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1396
|
+
pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
const result = await addWidgetApi(canvasId, {
|
|
1400
|
+
type: 'image',
|
|
1401
|
+
props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
|
|
1402
|
+
position: pos,
|
|
1403
|
+
})
|
|
1404
|
+
if (result.success && result.widget) {
|
|
1405
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1406
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1407
|
+
}
|
|
1408
|
+
return true
|
|
1409
|
+
} catch (err) {
|
|
1410
|
+
console.error('[canvas] Failed to process image:', err)
|
|
1411
|
+
return false
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// Store in ref for use by drag/drop effect
|
|
1416
|
+
processImageFileRef.current = processImageFile
|
|
1417
|
+
|
|
1049
1418
|
async function handleImagePaste(e) {
|
|
1050
1419
|
const items = e.clipboardData?.items
|
|
1051
1420
|
if (!items) return false
|
|
@@ -1057,40 +1426,7 @@ export default function CanvasPage({ name }) {
|
|
|
1057
1426
|
if (!blob) continue
|
|
1058
1427
|
|
|
1059
1428
|
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
|
-
}
|
|
1429
|
+
await processImageFile(blob, null)
|
|
1094
1430
|
return true
|
|
1095
1431
|
}
|
|
1096
1432
|
return false
|
|
@@ -1107,32 +1443,58 @@ export default function CanvasPage({ name }) {
|
|
|
1107
1443
|
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
1108
1444
|
if (!text) return
|
|
1109
1445
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
const
|
|
1114
|
-
if (
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1446
|
+
// Detect canvasId::widgetId format for widget duplication (cross-canvas copy-paste)
|
|
1447
|
+
// Also supports legacy canvasId/widgetId for basenames without slashes,
|
|
1448
|
+
// but only when the second segment looks like a widget ID (type-hash).
|
|
1449
|
+
const widgetRefMatch = text.match(/^(.+)::([^:]+)$/) || (text.indexOf('::') === -1 && text.match(/^([^/]+)\/((?:sticky-note|markdown|prototype|link-preview|figma-embed|component|image)-[a-z0-9]+)$/))
|
|
1450
|
+
if (widgetRefMatch) {
|
|
1451
|
+
e.preventDefault()
|
|
1452
|
+
const [, sourceCanvas, sourceWidgetId] = widgetRefMatch
|
|
1453
|
+
// Component widgets are code, not duplicable data — silently consume the ref
|
|
1454
|
+
if (sourceWidgetId.startsWith('jsx-')) return
|
|
1455
|
+
try {
|
|
1456
|
+
let sourceWidget = null
|
|
1457
|
+
if (sourceCanvas === canvasId) {
|
|
1458
|
+
sourceWidget = (localWidgets ?? []).find(w => w.id === sourceWidgetId)
|
|
1459
|
+
} else {
|
|
1460
|
+
const canvasData = await getCanvasApi(sourceCanvas)
|
|
1461
|
+
sourceWidget = (canvasData?.widgets ?? []).find(w => w.id === sourceWidgetId)
|
|
1462
|
+
}
|
|
1463
|
+
if (sourceWidget) {
|
|
1464
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1465
|
+
const pos = centerPositionForWidget(center, sourceWidget.type, sourceWidget.props)
|
|
1466
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1467
|
+
const result = await addWidgetApi(canvasId, {
|
|
1468
|
+
type: sourceWidget.type,
|
|
1469
|
+
props: { ...sourceWidget.props },
|
|
1470
|
+
position: pos,
|
|
1471
|
+
})
|
|
1472
|
+
if (result.success && result.widget) {
|
|
1473
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
} catch (err) {
|
|
1477
|
+
console.error('[canvas] Failed to paste widget reference:', err)
|
|
1126
1478
|
}
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1479
|
+
// Always consume the ref — never fall through to markdown creation
|
|
1480
|
+
return
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
e.preventDefault()
|
|
1484
|
+
const resolved = resolvePaste(text, pasteCtx, getPasteRules())
|
|
1485
|
+
if (!resolved) return
|
|
1486
|
+
const { type } = resolved
|
|
1487
|
+
let props = resolved.props
|
|
1488
|
+
|
|
1489
|
+
if (type === 'link-preview' && isGitHubEmbedUrl(props?.url || text)) {
|
|
1490
|
+
const githubUpdates = await buildGitHubPreviewUpdates(props?.url || text)
|
|
1491
|
+
if (githubUpdates) props = { ...props, ...githubUpdates }
|
|
1130
1492
|
}
|
|
1131
1493
|
|
|
1132
1494
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1133
1495
|
const pos = centerPositionForWidget(center, type, props)
|
|
1134
1496
|
try {
|
|
1135
|
-
const result = await addWidgetApi(
|
|
1497
|
+
const result = await addWidgetApi(canvasId, {
|
|
1136
1498
|
type,
|
|
1137
1499
|
props,
|
|
1138
1500
|
position: pos,
|
|
@@ -1145,9 +1507,75 @@ export default function CanvasPage({ name }) {
|
|
|
1145
1507
|
console.error('[canvas] Failed to add widget from paste:', err)
|
|
1146
1508
|
}
|
|
1147
1509
|
}
|
|
1510
|
+
|
|
1148
1511
|
document.addEventListener('paste', handlePaste)
|
|
1149
1512
|
return () => document.removeEventListener('paste', handlePaste)
|
|
1150
|
-
|
|
1513
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1514
|
+
}, [canvasId, undoRedo, localWidgets])
|
|
1515
|
+
|
|
1516
|
+
// --- Drag and drop handlers for images from Finder/file manager ---
|
|
1517
|
+
// Separate effect to ensure listeners attach after scroll container mounts (loading=false)
|
|
1518
|
+
useEffect(() => {
|
|
1519
|
+
if (loading) return // Don't attach until canvas is loaded and scroll container exists
|
|
1520
|
+
|
|
1521
|
+
const scrollEl = scrollRef.current
|
|
1522
|
+
if (!scrollEl) return
|
|
1523
|
+
|
|
1524
|
+
function handleDragOver(e) {
|
|
1525
|
+
// Only handle if dragging files (not internal widget drag)
|
|
1526
|
+
if (!e.dataTransfer?.types?.includes('Files')) return
|
|
1527
|
+
e.preventDefault()
|
|
1528
|
+
e.dataTransfer.dropEffect = 'copy'
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
async function handleDrop(e) {
|
|
1532
|
+
// Only handle file drops, not internal widget drags
|
|
1533
|
+
if (!e.dataTransfer?.types?.includes('Files')) return
|
|
1534
|
+
|
|
1535
|
+
// Prevent browser default (opening file) immediately for any file drop
|
|
1536
|
+
e.preventDefault()
|
|
1537
|
+
e.stopPropagation()
|
|
1538
|
+
|
|
1539
|
+
const files = e.dataTransfer.files
|
|
1540
|
+
if (!files || files.length === 0) return
|
|
1541
|
+
|
|
1542
|
+
// Filter to image files only — non-images are silently ignored (default already prevented)
|
|
1543
|
+
const imageFiles = Array.from(files).filter((f) => f.type.startsWith('image/'))
|
|
1544
|
+
if (imageFiles.length === 0) return
|
|
1545
|
+
|
|
1546
|
+
// Convert drop coordinates to canvas coordinates
|
|
1547
|
+
const rect = scrollEl.getBoundingClientRect()
|
|
1548
|
+
const scale = zoomRef.current / 100
|
|
1549
|
+
|
|
1550
|
+
// Mouse position relative to scroll container
|
|
1551
|
+
const mouseX = e.clientX - rect.left
|
|
1552
|
+
const mouseY = e.clientY - rect.top
|
|
1553
|
+
|
|
1554
|
+
// Convert to canvas coordinates (account for scroll and zoom)
|
|
1555
|
+
const canvasX = (scrollEl.scrollLeft + mouseX) / scale
|
|
1556
|
+
const canvasY = (scrollEl.scrollTop + mouseY) / scale
|
|
1557
|
+
|
|
1558
|
+
// Snap to grid if enabled, using current grid size
|
|
1559
|
+
const gridSize = snapGridSizeRef.current
|
|
1560
|
+
const shouldSnap = snapEnabledRef.current
|
|
1561
|
+
const snappedX = shouldSnap ? Math.round(canvasX / gridSize) * gridSize : Math.round(canvasX)
|
|
1562
|
+
const snappedY = shouldSnap ? Math.round(canvasY / gridSize) * gridSize : Math.round(canvasY)
|
|
1563
|
+
|
|
1564
|
+
// Process each image file, offsetting subsequent images
|
|
1565
|
+
for (let i = 0; i < imageFiles.length; i++) {
|
|
1566
|
+
const offset = shouldSnap ? i * gridSize : i * 24
|
|
1567
|
+
await processImageFileRef.current?.(imageFiles[i], { x: snappedX + offset, y: snappedY + offset })
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
scrollEl.addEventListener('dragover', handleDragOver)
|
|
1572
|
+
scrollEl.addEventListener('drop', handleDrop)
|
|
1573
|
+
|
|
1574
|
+
return () => {
|
|
1575
|
+
scrollEl.removeEventListener('dragover', handleDragOver)
|
|
1576
|
+
scrollEl.removeEventListener('drop', handleDrop)
|
|
1577
|
+
}
|
|
1578
|
+
}, [loading])
|
|
1151
1579
|
|
|
1152
1580
|
// --- Undo / Redo ---
|
|
1153
1581
|
const handleUndo = useCallback(() => {
|
|
@@ -1158,11 +1586,11 @@ export default function CanvasPage({ name }) {
|
|
|
1158
1586
|
setLocalWidgets(previous.widgets)
|
|
1159
1587
|
setLocalSources(previous.sources)
|
|
1160
1588
|
queueWrite(() =>
|
|
1161
|
-
updateCanvas(
|
|
1589
|
+
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
|
|
1162
1590
|
console.error('[canvas] Failed to persist undo:', err)
|
|
1163
1591
|
)
|
|
1164
1592
|
)
|
|
1165
|
-
}, [
|
|
1593
|
+
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
1166
1594
|
|
|
1167
1595
|
const handleRedo = useCallback(() => {
|
|
1168
1596
|
const next = undoRedo.redo(stateRef.current)
|
|
@@ -1172,11 +1600,11 @@ export default function CanvasPage({ name }) {
|
|
|
1172
1600
|
setLocalWidgets(next.widgets)
|
|
1173
1601
|
setLocalSources(next.sources)
|
|
1174
1602
|
queueWrite(() =>
|
|
1175
|
-
updateCanvas(
|
|
1603
|
+
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
|
|
1176
1604
|
console.error('[canvas] Failed to persist redo:', err)
|
|
1177
1605
|
)
|
|
1178
1606
|
)
|
|
1179
|
-
}, [
|
|
1607
|
+
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
1180
1608
|
|
|
1181
1609
|
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
|
|
1182
1610
|
useEffect(() => {
|
|
@@ -1235,6 +1663,55 @@ export default function CanvasPage({ name }) {
|
|
|
1235
1663
|
return () => document.removeEventListener('wheel', handleWheel)
|
|
1236
1664
|
}, [])
|
|
1237
1665
|
|
|
1666
|
+
// Touch pinch-to-zoom for mobile — two-finger pinch zooms the canvas
|
|
1667
|
+
const pinchState = useRef({ active: false, startDist: 0, startZoom: 0, centerX: 0, centerY: 0 })
|
|
1668
|
+
useEffect(() => {
|
|
1669
|
+
const el = scrollRef.current
|
|
1670
|
+
if (!el) return
|
|
1671
|
+
|
|
1672
|
+
function getTouchDist(t1, t2) {
|
|
1673
|
+
const dx = t1.clientX - t2.clientX
|
|
1674
|
+
const dy = t1.clientY - t2.clientY
|
|
1675
|
+
return Math.sqrt(dx * dx + dy * dy)
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function handleTouchStart(e) {
|
|
1679
|
+
if (e.touches.length !== 2) return
|
|
1680
|
+
const dist = getTouchDist(e.touches[0], e.touches[1])
|
|
1681
|
+
pinchState.current = {
|
|
1682
|
+
active: true,
|
|
1683
|
+
startDist: dist,
|
|
1684
|
+
startZoom: zoomRef.current,
|
|
1685
|
+
centerX: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
|
1686
|
+
centerY: (e.touches[0].clientY + e.touches[1].clientY) / 2,
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function handleTouchMove(e) {
|
|
1691
|
+
if (!pinchState.current.active || e.touches.length !== 2) return
|
|
1692
|
+
e.preventDefault()
|
|
1693
|
+
const dist = getTouchDist(e.touches[0], e.touches[1])
|
|
1694
|
+
const ratio = dist / pinchState.current.startDist
|
|
1695
|
+
const newZoom = Math.round(pinchState.current.startZoom * ratio)
|
|
1696
|
+
applyZoom(newZoom, pinchState.current.centerX, pinchState.current.centerY)
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function handleTouchEnd() {
|
|
1700
|
+
pinchState.current.active = false
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
el.addEventListener('touchstart', handleTouchStart, { passive: true })
|
|
1704
|
+
el.addEventListener('touchmove', handleTouchMove, { passive: false })
|
|
1705
|
+
el.addEventListener('touchend', handleTouchEnd)
|
|
1706
|
+
el.addEventListener('touchcancel', handleTouchEnd)
|
|
1707
|
+
return () => {
|
|
1708
|
+
el.removeEventListener('touchstart', handleTouchStart)
|
|
1709
|
+
el.removeEventListener('touchmove', handleTouchMove)
|
|
1710
|
+
el.removeEventListener('touchend', handleTouchEnd)
|
|
1711
|
+
el.removeEventListener('touchcancel', handleTouchEnd)
|
|
1712
|
+
}
|
|
1713
|
+
}, [])
|
|
1714
|
+
|
|
1238
1715
|
// Space + drag to pan the canvas
|
|
1239
1716
|
const [spaceHeld, setSpaceHeld] = useState(false)
|
|
1240
1717
|
const isPanning = useRef(false)
|
|
@@ -1294,10 +1771,19 @@ export default function CanvasPage({ name }) {
|
|
|
1294
1771
|
document.addEventListener('mouseup', handlePanEnd)
|
|
1295
1772
|
}, [spaceHeld])
|
|
1296
1773
|
|
|
1774
|
+
// Stable callback for deselecting all widgets
|
|
1775
|
+
const handleDeselectAll = useCallback(() => setSelectedWidgetIds(new Set()), [])
|
|
1776
|
+
|
|
1777
|
+
// Stable callback for widget removal + deselect
|
|
1778
|
+
const handleWidgetRemoveAndDeselect = useCallback((id) => {
|
|
1779
|
+
handleWidgetRemove(id)
|
|
1780
|
+
setSelectedWidgetIds(new Set())
|
|
1781
|
+
}, [handleWidgetRemove])
|
|
1782
|
+
|
|
1297
1783
|
if (!canvas) {
|
|
1298
1784
|
return (
|
|
1299
1785
|
<div className={styles.empty}>
|
|
1300
|
-
<p>Canvas “{
|
|
1786
|
+
<p>Canvas “{canvasId}” not found</p>
|
|
1301
1787
|
</div>
|
|
1302
1788
|
)
|
|
1303
1789
|
}
|
|
@@ -1328,54 +1814,50 @@ export default function CanvasPage({ name }) {
|
|
|
1328
1814
|
// Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
|
|
1329
1815
|
const allChildren = []
|
|
1330
1816
|
|
|
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
|
-
|
|
1817
|
+
// 1. Component widgets (from jsxExports or sources fallback)
|
|
1818
|
+
const componentFeatures = getFeatures('component', { isLocalDev })
|
|
1819
|
+
for (const entry of componentEntries) {
|
|
1820
|
+
const { exportName, Component, sourceData } = entry
|
|
1821
|
+
const sourcePosition = sourceData.position || { x: 0, y: 0 }
|
|
1822
|
+
allChildren.push(
|
|
1823
|
+
<div
|
|
1824
|
+
key={`jsx-${exportName}`}
|
|
1825
|
+
id={`jsx-${exportName}`}
|
|
1826
|
+
data-tc-x={sourcePosition.x}
|
|
1827
|
+
data-tc-y={sourcePosition.y}
|
|
1828
|
+
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
1829
|
+
{...canvasPrimerAttrs}
|
|
1830
|
+
style={canvasThemeVars}
|
|
1831
|
+
onClick={isLocalDev ? (e) => {
|
|
1832
|
+
e.stopPropagation()
|
|
1833
|
+
if (!e.target.closest('.tc-drag-handle')) {
|
|
1834
|
+
handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
|
|
1835
|
+
}
|
|
1836
|
+
} : undefined}
|
|
1837
|
+
>
|
|
1838
|
+
<WidgetChrome
|
|
1839
|
+
widgetId={`jsx-${exportName}`}
|
|
1840
|
+
features={componentFeatures}
|
|
1841
|
+
selected={selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1842
|
+
multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
|
|
1843
|
+
onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
|
|
1844
|
+
onDeselect={handleDeselectAll}
|
|
1845
|
+
readOnly={!isLocalDev}
|
|
1358
1846
|
>
|
|
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
|
-
}
|
|
1847
|
+
<ComponentWidget
|
|
1848
|
+
component={Component}
|
|
1849
|
+
jsxModule={canvas?._jsxModule}
|
|
1850
|
+
exportName={exportName}
|
|
1851
|
+
canvasTheme={canvasTheme}
|
|
1852
|
+
isLocalDev={isLocalDev}
|
|
1853
|
+
width={sourceData.width}
|
|
1854
|
+
height={sourceData.height}
|
|
1855
|
+
onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
|
|
1856
|
+
resizable={isResizable('component') && isLocalDev}
|
|
1857
|
+
/>
|
|
1858
|
+
</WidgetChrome>
|
|
1859
|
+
</div>
|
|
1860
|
+
)
|
|
1379
1861
|
}
|
|
1380
1862
|
|
|
1381
1863
|
// 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
|
|
@@ -1401,13 +1883,12 @@ export default function CanvasPage({ name }) {
|
|
|
1401
1883
|
selected={selectedWidgetIds.has(widget.id)}
|
|
1402
1884
|
multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
|
|
1403
1885
|
onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
|
|
1404
|
-
onDeselect={
|
|
1886
|
+
onDeselect={handleDeselectAll}
|
|
1405
1887
|
onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
|
|
1406
1888
|
onCopy={isLocalDev ? handleWidgetCopy : undefined}
|
|
1407
|
-
onRemove={isLocalDev ?
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
} : undefined}
|
|
1889
|
+
onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
|
|
1890
|
+
onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
|
|
1891
|
+
canRefreshGitHub={isLocalDev}
|
|
1411
1892
|
readOnly={!isLocalDev}
|
|
1412
1893
|
/>
|
|
1413
1894
|
</div>
|
|
@@ -1419,24 +1900,8 @@ export default function CanvasPage({ name }) {
|
|
|
1419
1900
|
return (
|
|
1420
1901
|
<>
|
|
1421
1902
|
<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>
|
|
1903
|
+
<h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || canvasId.split('/').pop()}</h1>
|
|
1904
|
+
<PageSelector currentName={canvasId} pages={siblingPages} />
|
|
1440
1905
|
{isLocalDev && (
|
|
1441
1906
|
<span className={styles.localEditingLabel}>Local editing</span>
|
|
1442
1907
|
)}
|
|
@@ -1451,10 +1916,11 @@ export default function CanvasPage({ name }) {
|
|
|
1451
1916
|
...canvasThemeVars,
|
|
1452
1917
|
...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
|
|
1453
1918
|
}}
|
|
1454
|
-
onClick={
|
|
1919
|
+
onClick={handleDeselectAll}
|
|
1455
1920
|
onMouseDown={handlePanStart}
|
|
1456
1921
|
>
|
|
1457
1922
|
<div
|
|
1923
|
+
ref={zoomElRef}
|
|
1458
1924
|
data-storyboard-canvas-zoom
|
|
1459
1925
|
data-sb-canvas-theme={canvasTheme}
|
|
1460
1926
|
className={styles.canvasZoom}
|
|
@@ -1471,6 +1937,28 @@ export default function CanvasPage({ name }) {
|
|
|
1471
1937
|
</Canvas>
|
|
1472
1938
|
</div>
|
|
1473
1939
|
</div>
|
|
1940
|
+
{showGhInstallBanner && (
|
|
1941
|
+
<aside className={styles.ghInstallBanner} role="status" aria-live="polite">
|
|
1942
|
+
<span className={styles.ghInstallBannerText}>
|
|
1943
|
+
GitHub embeds require local <code>gh</code> CLI access.
|
|
1944
|
+
</span>
|
|
1945
|
+
<a
|
|
1946
|
+
href={GH_INSTALL_URL}
|
|
1947
|
+
target="_blank"
|
|
1948
|
+
rel="noopener noreferrer"
|
|
1949
|
+
className={styles.ghInstallBannerLink}
|
|
1950
|
+
>
|
|
1951
|
+
Install GitHub CLI
|
|
1952
|
+
</a>
|
|
1953
|
+
<button
|
|
1954
|
+
type="button"
|
|
1955
|
+
className={styles.ghInstallBannerDismiss}
|
|
1956
|
+
onClick={() => setShowGhInstallBanner(false)}
|
|
1957
|
+
>
|
|
1958
|
+
Dismiss
|
|
1959
|
+
</button>
|
|
1960
|
+
</aside>
|
|
1961
|
+
)}
|
|
1474
1962
|
</>
|
|
1475
1963
|
)
|
|
1476
1964
|
}
|