@dfosco/storyboard-react 4.2.0-beta.17 → 4.2.0-beta.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/BranchBar/BranchBar.jsx +3 -1
- package/src/BranchBar/BranchBar.module.css +2 -2
- package/src/BranchBar/useBranches.js +20 -6
- package/src/BranchBar/useBranches.test.js +68 -0
- package/src/CommandPalette/CommandPalette.jsx +250 -61
- package/src/CommandPalette/command-palette.css +12 -0
- package/src/Icon.jsx +46 -11
- package/src/Viewfinder.jsx +53 -133
- package/src/Viewfinder.module.css +20 -91
- package/src/Workspace.jsx +7 -0
- package/src/canvas/CanvasPage.jsx +601 -62
- package/src/canvas/CanvasPage.module.css +15 -2
- package/src/canvas/CanvasPage.multiselect.test.jsx +7 -0
- package/src/canvas/ConnectorLayer.jsx +120 -152
- package/src/canvas/ConnectorLayer.module.css +69 -0
- package/src/canvas/canvasApi.js +68 -2
- package/src/canvas/connectorGeometry.js +132 -0
- package/src/canvas/hotPoolDevLogs.js +25 -0
- package/src/canvas/useMarqueeSelect.js +30 -4
- package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
- package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
- package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
- package/src/canvas/widgets/ComponentWidget.jsx +1 -0
- package/src/canvas/widgets/CropOverlay.jsx +219 -0
- package/src/canvas/widgets/CropOverlay.module.css +118 -0
- package/src/canvas/widgets/ExpandedPane.jsx +472 -0
- package/src/canvas/widgets/ExpandedPane.module.css +179 -0
- package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +49 -102
- package/src/canvas/widgets/ImageWidget.jsx +129 -8
- package/src/canvas/widgets/ImageWidget.module.css +30 -0
- package/src/canvas/widgets/LinkPreview.jsx +93 -44
- package/src/canvas/widgets/MarkdownBlock.jsx +141 -16
- package/src/canvas/widgets/MarkdownBlock.module.css +25 -0
- package/src/canvas/widgets/PromptWidget.jsx +414 -0
- package/src/canvas/widgets/PromptWidget.module.css +273 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +46 -170
- package/src/canvas/widgets/ResizeHandle.jsx +17 -6
- package/src/canvas/widgets/StoryWidget.jsx +65 -11
- package/src/canvas/widgets/TerminalReadWidget.jsx +11 -5
- package/src/canvas/widgets/TerminalReadWidget.module.css +3 -1
- package/src/canvas/widgets/TerminalWidget.jsx +301 -124
- package/src/canvas/widgets/TerminalWidget.module.css +121 -12
- package/src/canvas/widgets/TilesWidget.jsx +302 -0
- package/src/canvas/widgets/TilesWidget.module.css +133 -0
- package/src/canvas/widgets/WidgetChrome.jsx +67 -152
- package/src/canvas/widgets/WidgetChrome.module.css +20 -1
- package/src/canvas/widgets/expandUtils.js +385 -16
- package/src/canvas/widgets/expandUtils.test.js +155 -0
- package/src/canvas/widgets/index.js +6 -2
- package/src/canvas/widgets/tilePool.js +23 -0
- package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
- package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
- package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
- package/src/canvas/widgets/tiles/leaf.png +0 -0
- package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
- package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
- package/src/canvas/widgets/tiles/solid-a.png +0 -0
- package/src/canvas/widgets/tiles/solid-b.png +0 -0
- package/src/canvas/widgets/widgetConfig.js +37 -4
- package/src/canvas/widgets/widgetIcons.jsx +190 -0
- package/src/canvas/widgets/widgetProps.js +1 -0
- package/src/context.jsx +47 -19
- package/src/hooks/usePrototypeReloadGuard.js +64 -0
- package/src/index.js +4 -2
- package/src/story/ComponentSetPage.jsx +186 -0
- package/src/story/ComponentSetPage.module.css +121 -0
- package/src/story/StoryPage.jsx +32 -2
- package/src/vite/data-plugin.js +79 -35
- package/src/canvas/widgets/ActionWidget.jsx +0 -200
- package/src/canvas/widgets/ActionWidget.module.css +0 -122
- package/src/canvas/widgets/SplitExpandModal.jsx +0 -234
- package/src/canvas/widgets/SplitExpandModal.module.css +0 -335
- package/src/canvas/widgets/SplitScreenTopBar.jsx +0 -30
- package/src/canvas/widgets/SplitScreenTopBar.module.css +0 -58
|
@@ -6,12 +6,16 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
|
|
|
6
6
|
import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
|
|
7
7
|
import { getWidgetComponent } from './widgets/index.js'
|
|
8
8
|
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
9
|
-
import { getFeatures, isResizable, isExpandable, getAnchorState, canAcceptConnection } from './widgets/widgetConfig.js'
|
|
9
|
+
import { getFeatures, isResizable, isExpandable, getAnchorState, canAcceptConnection, isSplitScreenCapable } from './widgets/widgetConfig.js'
|
|
10
10
|
import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
|
|
11
11
|
import { getPasteRules } from '@dfosco/storyboard-core'
|
|
12
|
+
import { isTerminalResizable, getTerminalDimensions } from '@dfosco/storyboard-core'
|
|
13
|
+
import { getFlag } from '@dfosco/storyboard-core'
|
|
14
|
+
import { getCanvasZoom } from '@dfosco/storyboard-core'
|
|
12
15
|
import { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
|
|
16
|
+
import { registerHotPoolDevLogs } from './hotPoolDevLogs.js'
|
|
13
17
|
import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
|
|
14
|
-
|
|
18
|
+
|
|
15
19
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
16
20
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
17
21
|
import useUndoRedo from './useUndoRedo.js'
|
|
@@ -20,6 +24,7 @@ import MarqueeOverlay from './MarqueeOverlay.jsx'
|
|
|
20
24
|
import {
|
|
21
25
|
addWidget as addWidgetApi,
|
|
22
26
|
checkGitHubCliAvailable,
|
|
27
|
+
duplicateImage,
|
|
23
28
|
fetchGitHubEmbed,
|
|
24
29
|
getCanvas as getCanvasApi,
|
|
25
30
|
removeWidget as removeWidgetApi,
|
|
@@ -28,6 +33,8 @@ import {
|
|
|
28
33
|
uploadImage,
|
|
29
34
|
addConnector as addConnectorApi,
|
|
30
35
|
removeConnector as removeConnectorApi,
|
|
36
|
+
updateConnector as updateConnectorApi,
|
|
37
|
+
batchOperations,
|
|
31
38
|
} from './canvasApi.js'
|
|
32
39
|
import PageSelector from './PageSelector.jsx'
|
|
33
40
|
import Icon from '../Icon.jsx'
|
|
@@ -35,8 +42,11 @@ import { stories as storyIndex } from 'virtual:storyboard-data-index'
|
|
|
35
42
|
import styles from './CanvasPage.module.css'
|
|
36
43
|
import ConnectorLayer from './ConnectorLayer.jsx'
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
/** Canvas zoom limits — read from storyboard.config.json via canvasConfig. */
|
|
46
|
+
function zoomLimits() {
|
|
47
|
+
const z = getCanvasZoom()
|
|
48
|
+
return { ZOOM_MIN: z.min, ZOOM_MAX: z.max, ZOOM_STEP: z.step }
|
|
49
|
+
}
|
|
40
50
|
|
|
41
51
|
/** Saved viewport state older than this is considered stale — zoom-to-fit instead. */
|
|
42
52
|
const VIEWPORT_TTL_MS = 15 * 60 * 1000
|
|
@@ -45,6 +55,7 @@ const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
|
|
|
45
55
|
const GH_INSTALL_URL = 'https://github.com/cli/cli'
|
|
46
56
|
|
|
47
57
|
registerSmoothCorners()
|
|
58
|
+
registerHotPoolDevLogs()
|
|
48
59
|
|
|
49
60
|
/** Matches branch-deploy base path prefixes like /branch--my-feature/ */
|
|
50
61
|
const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
|
|
@@ -133,16 +144,17 @@ function getViewportStorageKey(canvasId) {
|
|
|
133
144
|
function loadViewportState(canvasId) {
|
|
134
145
|
try {
|
|
135
146
|
const raw = localStorage.getItem(getViewportStorageKey(canvasId))
|
|
136
|
-
if (!raw) { console.log('[viewport] no saved state for', canvasId); return null }
|
|
147
|
+
if (!raw) { if (getFlag('dev-logs')) console.log('[viewport] no saved state for', canvasId); return null }
|
|
137
148
|
const state = JSON.parse(raw)
|
|
138
149
|
const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
|
|
139
150
|
const age = Date.now() - timestamp
|
|
140
151
|
if (age > VIEWPORT_TTL_MS) {
|
|
141
|
-
console.log('[viewport] stale state for', canvasId, '— age:', Math.round(age / 1000), 's')
|
|
152
|
+
if (getFlag('dev-logs')) console.log('[viewport] stale state for', canvasId, '— age:', Math.round(age / 1000), 's')
|
|
142
153
|
localStorage.removeItem(getViewportStorageKey(canvasId))
|
|
143
154
|
return null
|
|
144
155
|
}
|
|
145
|
-
console.log('[viewport] loaded state for', canvasId, '— age:', Math.round(age / 1000), 's, zoom:', state.zoom, 'scroll:', state.scrollLeft, state.scrollTop)
|
|
156
|
+
if (getFlag('dev-logs')) console.log('[viewport] loaded state for', canvasId, '— age:', Math.round(age / 1000), 's, zoom:', state.zoom, 'scroll:', state.scrollLeft, state.scrollTop)
|
|
157
|
+
const { ZOOM_MIN, ZOOM_MAX } = zoomLimits()
|
|
146
158
|
return {
|
|
147
159
|
zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
|
|
148
160
|
scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
|
|
@@ -272,13 +284,15 @@ function computeCanvasBounds(widgets, componentEntries) {
|
|
|
272
284
|
}
|
|
273
285
|
|
|
274
286
|
/** Renders a single JSON-defined widget by type lookup. */
|
|
275
|
-
function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub }) {
|
|
287
|
+
function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub, multiSelected }) {
|
|
276
288
|
const Component = getWidgetComponent(widget.type)
|
|
277
289
|
if (!Component) {
|
|
278
290
|
console.warn(`[canvas] Unknown widget type: ${widget.type}`)
|
|
279
291
|
return null
|
|
280
292
|
}
|
|
281
|
-
const resizable =
|
|
293
|
+
const resizable = (widget.type === 'terminal' || widget.type === 'agent')
|
|
294
|
+
? isTerminalResizable(widget.props?.agentId) && !!onUpdate
|
|
295
|
+
: isResizable(widget.type) && !!onUpdate
|
|
282
296
|
// Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
|
|
283
297
|
const elementProps = {
|
|
284
298
|
id: widget.id,
|
|
@@ -287,6 +301,7 @@ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefre
|
|
|
287
301
|
resizable,
|
|
288
302
|
onRefreshGitHub,
|
|
289
303
|
canRefreshGitHub,
|
|
304
|
+
multiSelected,
|
|
290
305
|
}
|
|
291
306
|
if (Component.$$typeof === Symbol.for('react.forward_ref')) {
|
|
292
307
|
elementProps.ref = widgetRef
|
|
@@ -305,11 +320,13 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
305
320
|
selected,
|
|
306
321
|
multiSelected,
|
|
307
322
|
connectorCount,
|
|
323
|
+
allWidgets,
|
|
308
324
|
onSelect,
|
|
309
325
|
onDeselect,
|
|
310
326
|
onUpdate,
|
|
311
327
|
onRemove,
|
|
312
328
|
onCopy,
|
|
329
|
+
onCopyWithConnectors,
|
|
313
330
|
onRefreshGitHub,
|
|
314
331
|
canRefreshGitHub,
|
|
315
332
|
onConnectorDragStart,
|
|
@@ -336,9 +353,15 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
336
353
|
return f
|
|
337
354
|
}).filter(Boolean)
|
|
338
355
|
|
|
339
|
-
// Add dynamic "Split Screen" action when a connected split target exists
|
|
356
|
+
// Add dynamic "Split Screen" action when a connected split target exists.
|
|
357
|
+
// Uses connectorCount/allWidgets props (reactive) instead of the global
|
|
358
|
+
// bridge state which may be stale during React render.
|
|
340
359
|
if (isExpandable(widget.type)) {
|
|
341
|
-
const hasConnected =
|
|
360
|
+
const hasConnected = (connectorCount || []).some((c) => {
|
|
361
|
+
const otherId = c.start?.widgetId === widget.id ? c.end?.widgetId : c.start?.widgetId
|
|
362
|
+
const otherWidget = (allWidgets || []).find((w) => w.id === otherId)
|
|
363
|
+
return otherWidget && isSplitScreenCapable(otherWidget.type)
|
|
364
|
+
})
|
|
342
365
|
if (hasConnected) {
|
|
343
366
|
// Insert before the first menu-only feature
|
|
344
367
|
const insertIdx = adjusted.findIndex((f) => f.menu)
|
|
@@ -355,14 +378,52 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
355
378
|
}
|
|
356
379
|
}
|
|
357
380
|
|
|
381
|
+
// Add dynamic "Broadcast" toggle for terminal/agent widgets with connected peers
|
|
382
|
+
if (widget.type === 'terminal' || widget.type === 'agent') {
|
|
383
|
+
const widgetConnectors = connectorCount || []
|
|
384
|
+
const widgetList = allWidgets || []
|
|
385
|
+
let hasBroadcastPeers = false
|
|
386
|
+
let allBroadcastActive = true
|
|
387
|
+
const broadcastConnectorIds = []
|
|
388
|
+
|
|
389
|
+
for (const conn of widgetConnectors) {
|
|
390
|
+
const peerId = conn.start?.widgetId === widget.id ? conn.end?.widgetId : conn.start?.widgetId
|
|
391
|
+
const peer = widgetList.find((w) => w.id === peerId)
|
|
392
|
+
if (peer && (peer.type === 'terminal' || peer.type === 'agent')) {
|
|
393
|
+
hasBroadcastPeers = true
|
|
394
|
+
broadcastConnectorIds.push(conn.id)
|
|
395
|
+
if (conn.meta?.messagingMode !== 'two-way') allBroadcastActive = false
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (hasBroadcastPeers) {
|
|
400
|
+
const isActive = allBroadcastActive
|
|
401
|
+
const insertIdx = adjusted.findIndex((f) => f.menu)
|
|
402
|
+
const broadcastFeature = {
|
|
403
|
+
id: 'broadcast',
|
|
404
|
+
type: 'action',
|
|
405
|
+
action: `broadcast-toggle:${broadcastConnectorIds.join(',')}:${isActive ? 'off' : 'on'}`,
|
|
406
|
+
label: isActive ? 'Broadcast On' : 'Broadcast',
|
|
407
|
+
icon: 'broadcast',
|
|
408
|
+
active: isActive,
|
|
409
|
+
}
|
|
410
|
+
if (insertIdx >= 0) adjusted.splice(insertIdx, 0, broadcastFeature)
|
|
411
|
+
else adjusted.push(broadcastFeature)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
358
415
|
return adjusted
|
|
359
|
-
}, [rawFeatures, widget.props?.github, widget.props?.collapsed, widget.type, widget.id, connectorCount])
|
|
416
|
+
}, [rawFeatures, widget.props?.github, widget.props?.collapsed, widget.type, widget.id, connectorCount, allWidgets])
|
|
360
417
|
|
|
361
|
-
const handleAction = useCallback((actionId) => {
|
|
418
|
+
const handleAction = useCallback((actionId, opts) => {
|
|
362
419
|
if (actionId === 'delete') {
|
|
363
420
|
onRemove?.(widget.id)
|
|
364
421
|
} else if (actionId === 'copy') {
|
|
365
|
-
|
|
422
|
+
if (opts?.altKey && onCopyWithConnectors) {
|
|
423
|
+
onCopyWithConnectors(widget)
|
|
424
|
+
} else {
|
|
425
|
+
onCopy?.(widget)
|
|
426
|
+
}
|
|
366
427
|
} else if (actionId === 'copy-text') {
|
|
367
428
|
const title = widget.props?.title || ''
|
|
368
429
|
const body = widget.props?.text || widget.props?.content || widget.props?.github?.body || ''
|
|
@@ -384,8 +445,20 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
384
445
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
385
446
|
})
|
|
386
447
|
}
|
|
448
|
+
} else if (actionId.startsWith('broadcast-toggle:')) {
|
|
449
|
+
// broadcast-toggle:<connectorId1,connectorId2,...>:<on|off>
|
|
450
|
+
const parts = actionId.split(':')
|
|
451
|
+
const connectorIds = parts[1].split(',')
|
|
452
|
+
const turnOn = parts[2] === 'on'
|
|
453
|
+
const bridge = window.__storyboardCanvasBridgeState
|
|
454
|
+
const canvasId = bridge?.canvasId || ''
|
|
455
|
+
const meta = turnOn ? { messagingMode: 'two-way' } : { messagingMode: null }
|
|
456
|
+
for (const cid of connectorIds) {
|
|
457
|
+
updateConnectorApi(canvasId, cid, meta)
|
|
458
|
+
.catch((err) => console.error('[canvas] Failed to toggle broadcast:', err))
|
|
459
|
+
}
|
|
387
460
|
}
|
|
388
|
-
}, [widget, onRemove, onCopy, onRefreshGitHub])
|
|
461
|
+
}, [widget, onRemove, onCopy, onCopyWithConnectors, onRefreshGitHub])
|
|
389
462
|
|
|
390
463
|
const handleWidgetFieldUpdate = useCallback((updates) => {
|
|
391
464
|
onUpdate?.(widget.id, updates)
|
|
@@ -413,6 +486,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
413
486
|
widgetRef={widgetRef}
|
|
414
487
|
onRefreshGitHub={onRefreshGitHub}
|
|
415
488
|
canRefreshGitHub={canRefreshGitHub}
|
|
489
|
+
multiSelected={multiSelected}
|
|
416
490
|
/>
|
|
417
491
|
</WidgetChrome>
|
|
418
492
|
)
|
|
@@ -422,6 +496,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
422
496
|
prev.selected === next.selected &&
|
|
423
497
|
prev.multiSelected === next.multiSelected &&
|
|
424
498
|
prev.connectorCount === next.connectorCount &&
|
|
499
|
+
prev.allWidgets === next.allWidgets &&
|
|
425
500
|
prev.readOnly === next.readOnly &&
|
|
426
501
|
prev.onSelect === next.onSelect &&
|
|
427
502
|
prev.onDeselect === next.onDeselect &&
|
|
@@ -556,6 +631,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
556
631
|
const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
|
|
557
632
|
const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
|
|
558
633
|
|
|
634
|
+
// Scroll lock: prevents focus-triggered scroll jumps when adding terminal/agent widgets.
|
|
635
|
+
// The lock captures the current scroll position and forces it back on every scroll event
|
|
636
|
+
// until unlocked by the widget's ready signal or a safety timeout.
|
|
637
|
+
// Visual UI (outline + banner) only appears after 1.5s if still locked.
|
|
638
|
+
|
|
559
639
|
// Refs for snap settings (used by drop handler inside effect closure)
|
|
560
640
|
const snapEnabledRef = useRef(snapEnabled)
|
|
561
641
|
const snapGridSizeRef = useRef(snapGridSize)
|
|
@@ -599,12 +679,38 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
599
679
|
// Prevents HMR echoes from overwriting in-flight local state.
|
|
600
680
|
const dirtyRef = useRef(false)
|
|
601
681
|
|
|
682
|
+
// Counter of in-flight writes. dirtyRef is only cleared when this reaches 0,
|
|
683
|
+
// preventing early clears when multiple writes are queued in sequence.
|
|
684
|
+
const inflightWritesRef = useRef(0)
|
|
685
|
+
|
|
686
|
+
// Grace period timer — after all writes complete, dirtyRef stays true for a
|
|
687
|
+
// brief window to absorb delayed file-watcher HMR events that arrive after
|
|
688
|
+
// the server's immediate push. Defense-in-depth for the write guard.
|
|
689
|
+
const dirtyGraceTimerRef = useRef(null)
|
|
690
|
+
|
|
602
691
|
// Serialized write queue — ensures JSONL events land in the right order
|
|
603
692
|
const writeQueueRef = useRef(Promise.resolve())
|
|
604
693
|
function queueWrite(fn) {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
694
|
+
clearTimeout(dirtyGraceTimerRef.current)
|
|
695
|
+
inflightWritesRef.current += 1
|
|
696
|
+
writeQueueRef.current = writeQueueRef.current
|
|
697
|
+
.then(fn)
|
|
698
|
+
.catch((err) => console.error('[canvas] Write queue error:', err))
|
|
699
|
+
.finally(() => {
|
|
700
|
+
inflightWritesRef.current -= 1
|
|
701
|
+
if (inflightWritesRef.current < 0) {
|
|
702
|
+
console.warn('[canvas] Write queue counter underflow — resetting')
|
|
703
|
+
inflightWritesRef.current = 0
|
|
704
|
+
}
|
|
705
|
+
if (inflightWritesRef.current === 0) {
|
|
706
|
+
// Grace period — absorb delayed watcher HMR events before clearing
|
|
707
|
+
dirtyGraceTimerRef.current = setTimeout(() => {
|
|
708
|
+
if (inflightWritesRef.current === 0) {
|
|
709
|
+
dirtyRef.current = false
|
|
710
|
+
}
|
|
711
|
+
}, 600)
|
|
712
|
+
}
|
|
713
|
+
})
|
|
608
714
|
return writeQueueRef.current
|
|
609
715
|
}
|
|
610
716
|
|
|
@@ -691,7 +797,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
691
797
|
|
|
692
798
|
if (canvas !== trackedCanvas) {
|
|
693
799
|
const isCanvasSwitch = trackedCanvas && canvas && trackedCanvas._route !== canvas._route
|
|
694
|
-
console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
|
|
800
|
+
if (getFlag('dev-logs')) console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
|
|
695
801
|
setTrackedCanvas(canvas)
|
|
696
802
|
|
|
697
803
|
// Skip replacing local state with server data when optimistic edits are
|
|
@@ -705,7 +811,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
705
811
|
|
|
706
812
|
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
707
813
|
setSnapGridSize(canvas?.gridSize || 40)
|
|
708
|
-
|
|
814
|
+
if (isCanvasSwitch) {
|
|
815
|
+
undoRedo.reset()
|
|
816
|
+
}
|
|
709
817
|
// Only reset viewport state when switching to a different canvas,
|
|
710
818
|
// not when the same canvas refreshes with server data.
|
|
711
819
|
if (isCanvasSwitch) {
|
|
@@ -725,7 +833,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
725
833
|
queueWrite(() =>
|
|
726
834
|
updateCanvas(canvasId, { widgets })
|
|
727
835
|
.catch((err) => console.error('[canvas] Failed to save:', err))
|
|
728
|
-
.finally(() => { dirtyRef.current = false })
|
|
729
836
|
)
|
|
730
837
|
}, 2000)
|
|
731
838
|
).current
|
|
@@ -773,7 +880,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
773
880
|
queueWrite(() =>
|
|
774
881
|
removeWidgetApi(canvasId, widgetId)
|
|
775
882
|
.catch((err) => console.error('[canvas] Failed to remove widget:', err))
|
|
776
|
-
.finally(() => { dirtyRef.current = false })
|
|
777
883
|
)
|
|
778
884
|
}, [canvasId, undoRedo, debouncedSave])
|
|
779
885
|
|
|
@@ -792,6 +898,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
792
898
|
const handleConnectorRemove = useCallback((connectorId) => {
|
|
793
899
|
undoRedo.snapshot(stateRef.current, 'connector-remove')
|
|
794
900
|
setLocalConnectors((prev) => prev.filter((c) => c.id !== connectorId))
|
|
901
|
+
dirtyRef.current = true
|
|
795
902
|
queueWrite(() =>
|
|
796
903
|
removeConnectorApi(canvasId, connectorId).catch((err) =>
|
|
797
904
|
console.error('[canvas] Failed to remove connector:', err)
|
|
@@ -963,10 +1070,16 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
963
1070
|
n++
|
|
964
1071
|
}
|
|
965
1072
|
const position = { x: baseX + n * 40, y: baseY + n * 40 }
|
|
1073
|
+
const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
|
|
966
1074
|
try {
|
|
967
1075
|
const copyProps = { ...widget.props }
|
|
968
1076
|
// Terminal widgets must get unique names — strip prettyName so the server generates a fresh one
|
|
969
|
-
if (
|
|
1077
|
+
if (isTerminal) delete copyProps.prettyName
|
|
1078
|
+
// Image widgets: duplicate the asset file so each widget owns its own copy
|
|
1079
|
+
if (widget.type === 'image' && copyProps.src) {
|
|
1080
|
+
const dupResult = await duplicateImage(copyProps.src)
|
|
1081
|
+
if (dupResult.success) copyProps.src = dupResult.filename
|
|
1082
|
+
}
|
|
970
1083
|
|
|
971
1084
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
972
1085
|
const result = await addWidgetApi(canvasId, {
|
|
@@ -983,6 +1096,293 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
983
1096
|
}
|
|
984
1097
|
}, [canvasId, localWidgets, undoRedo])
|
|
985
1098
|
|
|
1099
|
+
// Duplicate a single widget WITH its connectors (Alt+click on duplicate button)
|
|
1100
|
+
const handleWidgetCopyWithConnectors = useCallback(async (widget) => {
|
|
1101
|
+
if (!widget) return
|
|
1102
|
+
const widgets = [widget]
|
|
1103
|
+
|
|
1104
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1105
|
+
|
|
1106
|
+
const occupied = new Set(
|
|
1107
|
+
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
1108
|
+
)
|
|
1109
|
+
let offset = 1
|
|
1110
|
+
while (occupied.has(`${(widget.position?.x ?? 0) + offset * 40},${(widget.position?.y ?? 0) + offset * 40}`)) offset++
|
|
1111
|
+
|
|
1112
|
+
const imageOverrides = new Map()
|
|
1113
|
+
if (widget.type === 'image' && widget.props?.src) {
|
|
1114
|
+
try {
|
|
1115
|
+
const dupResult = await duplicateImage(widget.props.src)
|
|
1116
|
+
if (dupResult.success) imageOverrides.set(widget.id, dupResult.filename)
|
|
1117
|
+
} catch { /* use original src as fallback */ }
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const selectedIds = new Set([widget.id])
|
|
1121
|
+
const relevantConnectors = (localConnectors ?? []).filter(
|
|
1122
|
+
(c) => selectedIds.has(c.start?.widgetId) || selectedIds.has(c.end?.widgetId)
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
const ops = []
|
|
1126
|
+
for (const w of widgets) {
|
|
1127
|
+
const copyProps = { ...w.props }
|
|
1128
|
+
const isTerminal = w.type === 'terminal' || w.type === 'agent'
|
|
1129
|
+
if (isTerminal) delete copyProps.prettyName
|
|
1130
|
+
if (imageOverrides.has(w.id)) copyProps.src = imageOverrides.get(w.id)
|
|
1131
|
+
ops.push({
|
|
1132
|
+
op: 'create-widget',
|
|
1133
|
+
ref: `clone-${w.id}`,
|
|
1134
|
+
type: w.type,
|
|
1135
|
+
props: copyProps,
|
|
1136
|
+
position: {
|
|
1137
|
+
x: (w.position?.x ?? 0) + offset * 40,
|
|
1138
|
+
y: (w.position?.y ?? 0) + offset * 40,
|
|
1139
|
+
},
|
|
1140
|
+
})
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
for (const conn of relevantConnectors) {
|
|
1144
|
+
const startInSelection = selectedIds.has(conn.start?.widgetId)
|
|
1145
|
+
const endInSelection = selectedIds.has(conn.end?.widgetId)
|
|
1146
|
+
ops.push({
|
|
1147
|
+
op: 'create-connector',
|
|
1148
|
+
startWidgetId: startInSelection ? `$clone-${conn.start.widgetId}` : conn.start.widgetId,
|
|
1149
|
+
startAnchor: conn.start.anchor,
|
|
1150
|
+
endWidgetId: endInSelection ? `$clone-${conn.end.widgetId}` : conn.end.widgetId,
|
|
1151
|
+
endAnchor: conn.end.anchor,
|
|
1152
|
+
connectorType: conn.connectorType || 'default',
|
|
1153
|
+
})
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
try {
|
|
1157
|
+
const response = await batchOperations(canvasId, ops)
|
|
1158
|
+
if (!response.success) {
|
|
1159
|
+
console.error('[canvas] Batch duplicate failed:', response.error)
|
|
1160
|
+
return
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const newWidgets = []
|
|
1164
|
+
const newConnectors = []
|
|
1165
|
+
const refMap = response.refs || {}
|
|
1166
|
+
|
|
1167
|
+
for (const result of response.results) {
|
|
1168
|
+
if (result.op === 'create-widget' && result.widget) {
|
|
1169
|
+
newWidgets.push(result.widget)
|
|
1170
|
+
}
|
|
1171
|
+
if (result.op === 'create-connector' && result.connectorId) {
|
|
1172
|
+
const origOp = ops[result.index]
|
|
1173
|
+
const resolveId = (val) => {
|
|
1174
|
+
if (typeof val === 'string' && val.startsWith('$')) {
|
|
1175
|
+
return refMap[val.slice(1)] ?? val
|
|
1176
|
+
}
|
|
1177
|
+
return val
|
|
1178
|
+
}
|
|
1179
|
+
newConnectors.push({
|
|
1180
|
+
id: result.connectorId,
|
|
1181
|
+
type: 'connector',
|
|
1182
|
+
connectorType: origOp.connectorType || 'default',
|
|
1183
|
+
start: { widgetId: resolveId(origOp.startWidgetId), anchor: origOp.startAnchor },
|
|
1184
|
+
end: { widgetId: resolveId(origOp.endWidgetId), anchor: origOp.endAnchor },
|
|
1185
|
+
meta: {},
|
|
1186
|
+
})
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (newWidgets.length > 0) {
|
|
1191
|
+
setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
|
|
1192
|
+
setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
|
|
1193
|
+
}
|
|
1194
|
+
if (newConnectors.length > 0) {
|
|
1195
|
+
setLocalConnectors((prev) => [...prev, ...newConnectors])
|
|
1196
|
+
}
|
|
1197
|
+
} catch (err) {
|
|
1198
|
+
console.error('[canvas] Failed to duplicate with connectors:', err)
|
|
1199
|
+
}
|
|
1200
|
+
}, [canvasId, localWidgets, localConnectors, undoRedo])
|
|
1201
|
+
|
|
1202
|
+
// Duplicate all selected widgets in one undo step (Cmd+D)
|
|
1203
|
+
const handleDuplicateSelected = useCallback(async () => {
|
|
1204
|
+
const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
|
|
1205
|
+
if (widgets.length === 0) return
|
|
1206
|
+
|
|
1207
|
+
// Single undo snapshot for the entire batch
|
|
1208
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1209
|
+
|
|
1210
|
+
// Compute occupied positions to find free offset
|
|
1211
|
+
const occupied = new Set(
|
|
1212
|
+
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
1213
|
+
)
|
|
1214
|
+
let offset = 1
|
|
1215
|
+
const anyOccupied = () => widgets.some((w) => {
|
|
1216
|
+
const bx = (w.position?.x ?? 0) + offset * 40
|
|
1217
|
+
const by = (w.position?.y ?? 0) + offset * 40
|
|
1218
|
+
return occupied.has(`${bx},${by}`)
|
|
1219
|
+
})
|
|
1220
|
+
while (anyOccupied()) offset++
|
|
1221
|
+
|
|
1222
|
+
const newWidgets = []
|
|
1223
|
+
for (const widget of widgets) {
|
|
1224
|
+
const position = {
|
|
1225
|
+
x: (widget.position?.x ?? 0) + offset * 40,
|
|
1226
|
+
y: (widget.position?.y ?? 0) + offset * 40,
|
|
1227
|
+
}
|
|
1228
|
+
const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
|
|
1229
|
+
try {
|
|
1230
|
+
const copyProps = { ...widget.props }
|
|
1231
|
+
if (isTerminal) delete copyProps.prettyName
|
|
1232
|
+
if (widget.type === 'image' && copyProps.src) {
|
|
1233
|
+
try {
|
|
1234
|
+
const dupResult = await duplicateImage(copyProps.src)
|
|
1235
|
+
if (dupResult.success) copyProps.src = dupResult.filename
|
|
1236
|
+
} catch { /* use original src as fallback */ }
|
|
1237
|
+
}
|
|
1238
|
+
const result = await addWidgetApi(canvasId, {
|
|
1239
|
+
type: widget.type,
|
|
1240
|
+
props: copyProps,
|
|
1241
|
+
position,
|
|
1242
|
+
})
|
|
1243
|
+
if (result.success && result.widget) {
|
|
1244
|
+
newWidgets.push(result.widget)
|
|
1245
|
+
}
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
console.error('[canvas] Failed to duplicate widget:', err)
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
if (newWidgets.length > 0) {
|
|
1252
|
+
setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
|
|
1253
|
+
setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
|
|
1254
|
+
}
|
|
1255
|
+
}, [canvasId, localWidgets, selectedWidgetIds, undoRedo])
|
|
1256
|
+
|
|
1257
|
+
// Duplicate selected widgets WITH connectors (Cmd+Shift+D)
|
|
1258
|
+
// Uses the batch API for atomic operation — all widgets and connectors
|
|
1259
|
+
// are created in a single request with $ref resolution.
|
|
1260
|
+
const handleDuplicateWithConnectors = useCallback(async () => {
|
|
1261
|
+
const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
|
|
1262
|
+
if (widgets.length === 0) return
|
|
1263
|
+
|
|
1264
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1265
|
+
|
|
1266
|
+
// Compute offset — same logic as handleDuplicateSelected
|
|
1267
|
+
const occupied = new Set(
|
|
1268
|
+
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
1269
|
+
)
|
|
1270
|
+
let offset = 1
|
|
1271
|
+
const anyOccupied = () => widgets.some((w) => {
|
|
1272
|
+
const bx = (w.position?.x ?? 0) + offset * 40
|
|
1273
|
+
const by = (w.position?.y ?? 0) + offset * 40
|
|
1274
|
+
return occupied.has(`${bx},${by}`)
|
|
1275
|
+
})
|
|
1276
|
+
while (anyOccupied()) offset++
|
|
1277
|
+
|
|
1278
|
+
// Pre-process image widgets — duplicate asset files to get unique filenames
|
|
1279
|
+
const imageOverrides = new Map()
|
|
1280
|
+
for (const widget of widgets) {
|
|
1281
|
+
if (widget.type === 'image' && widget.props?.src) {
|
|
1282
|
+
try {
|
|
1283
|
+
const dupResult = await duplicateImage(widget.props.src)
|
|
1284
|
+
if (dupResult.success) imageOverrides.set(widget.id, dupResult.filename)
|
|
1285
|
+
} catch { /* use original src as fallback */ }
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Find all connectors touching at least one selected widget
|
|
1290
|
+
const selectedIds = new Set(widgets.map((w) => w.id))
|
|
1291
|
+
const relevantConnectors = (localConnectors ?? []).filter(
|
|
1292
|
+
(c) => selectedIds.has(c.start?.widgetId) || selectedIds.has(c.end?.widgetId)
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
// Build batch operations
|
|
1296
|
+
const ops = []
|
|
1297
|
+
|
|
1298
|
+
// 1. Create-widget ops with ref names for $ref resolution
|
|
1299
|
+
for (const widget of widgets) {
|
|
1300
|
+
const copyProps = { ...widget.props }
|
|
1301
|
+
const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
|
|
1302
|
+
if (isTerminal) delete copyProps.prettyName
|
|
1303
|
+
if (imageOverrides.has(widget.id)) copyProps.src = imageOverrides.get(widget.id)
|
|
1304
|
+
|
|
1305
|
+
ops.push({
|
|
1306
|
+
op: 'create-widget',
|
|
1307
|
+
ref: `clone-${widget.id}`,
|
|
1308
|
+
type: widget.type,
|
|
1309
|
+
props: copyProps,
|
|
1310
|
+
position: {
|
|
1311
|
+
x: (widget.position?.x ?? 0) + offset * 40,
|
|
1312
|
+
y: (widget.position?.y ?? 0) + offset * 40,
|
|
1313
|
+
},
|
|
1314
|
+
})
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// 2. Create-connector ops — remap selected endpoints to $ref clones
|
|
1318
|
+
for (const conn of relevantConnectors) {
|
|
1319
|
+
const startInSelection = selectedIds.has(conn.start?.widgetId)
|
|
1320
|
+
const endInSelection = selectedIds.has(conn.end?.widgetId)
|
|
1321
|
+
|
|
1322
|
+
ops.push({
|
|
1323
|
+
op: 'create-connector',
|
|
1324
|
+
startWidgetId: startInSelection ? `$clone-${conn.start.widgetId}` : conn.start.widgetId,
|
|
1325
|
+
startAnchor: conn.start.anchor,
|
|
1326
|
+
endWidgetId: endInSelection ? `$clone-${conn.end.widgetId}` : conn.end.widgetId,
|
|
1327
|
+
endAnchor: conn.end.anchor,
|
|
1328
|
+
connectorType: conn.connectorType || 'default',
|
|
1329
|
+
})
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
try {
|
|
1333
|
+
const response = await batchOperations(canvasId, ops)
|
|
1334
|
+
if (!response.success) {
|
|
1335
|
+
console.error('[canvas] Batch duplicate failed:', response.error)
|
|
1336
|
+
return
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// Extract created widgets and connectors from results
|
|
1340
|
+
const newWidgets = []
|
|
1341
|
+
const newConnectors = []
|
|
1342
|
+
const refMap = response.refs || {}
|
|
1343
|
+
|
|
1344
|
+
for (const result of response.results) {
|
|
1345
|
+
if (result.op === 'create-widget' && result.widget) {
|
|
1346
|
+
newWidgets.push(result.widget)
|
|
1347
|
+
}
|
|
1348
|
+
if (result.op === 'create-connector' && result.connectorId) {
|
|
1349
|
+
// Reconstruct connector object from the operation + resolved refs
|
|
1350
|
+
const origOp = ops[result.index]
|
|
1351
|
+
const resolveId = (val) => {
|
|
1352
|
+
if (typeof val === 'string' && val.startsWith('$')) {
|
|
1353
|
+
return refMap[val.slice(1)] ?? val
|
|
1354
|
+
}
|
|
1355
|
+
return val
|
|
1356
|
+
}
|
|
1357
|
+
newConnectors.push({
|
|
1358
|
+
id: result.connectorId,
|
|
1359
|
+
type: 'connector',
|
|
1360
|
+
connectorType: origOp.connectorType || 'default',
|
|
1361
|
+
start: { widgetId: resolveId(origOp.startWidgetId), anchor: origOp.startAnchor },
|
|
1362
|
+
end: { widgetId: resolveId(origOp.endWidgetId), anchor: origOp.endAnchor },
|
|
1363
|
+
meta: {},
|
|
1364
|
+
})
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
if (newWidgets.length > 0) {
|
|
1369
|
+
setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
|
|
1370
|
+
setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
|
|
1371
|
+
}
|
|
1372
|
+
if (newConnectors.length > 0) {
|
|
1373
|
+
setLocalConnectors((prev) => [...prev, ...newConnectors])
|
|
1374
|
+
}
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
console.error('[canvas] Failed to duplicate with connectors:', err)
|
|
1377
|
+
}
|
|
1378
|
+
}, [canvasId, localWidgets, localConnectors, selectedWidgetIds, undoRedo])
|
|
1379
|
+
|
|
1380
|
+
// Select all widgets (Cmd+A)
|
|
1381
|
+
const handleSelectAll = useCallback(() => {
|
|
1382
|
+
const allIds = (localWidgets ?? []).map((w) => w.id)
|
|
1383
|
+
if (allIds.length > 0) setSelectedWidgetIds(new Set(allIds))
|
|
1384
|
+
}, [localWidgets])
|
|
1385
|
+
|
|
986
1386
|
const showMissingGhBanner = useCallback(() => {
|
|
987
1387
|
setShowGhInstallBanner(true)
|
|
988
1388
|
}, [])
|
|
@@ -1037,8 +1437,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1037
1437
|
|
|
1038
1438
|
const debouncedSourceSave = useRef(
|
|
1039
1439
|
debounce((canvasId, sources) => {
|
|
1040
|
-
|
|
1041
|
-
|
|
1440
|
+
queueWrite(() =>
|
|
1441
|
+
updateCanvas(canvasId, { sources }).catch((err) =>
|
|
1442
|
+
console.error('[canvas] Failed to save sources:', err)
|
|
1443
|
+
)
|
|
1042
1444
|
)
|
|
1043
1445
|
}, 2000)
|
|
1044
1446
|
).current
|
|
@@ -1055,6 +1457,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1055
1457
|
const next = current.some((s) => s?.export === exportName)
|
|
1056
1458
|
? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
|
|
1057
1459
|
: [...current, { export: exportName, ...snapped }]
|
|
1460
|
+
dirtyRef.current = true
|
|
1058
1461
|
debouncedSourceSave(canvasId, next)
|
|
1059
1462
|
return next
|
|
1060
1463
|
})
|
|
@@ -1114,7 +1517,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1114
1517
|
queueWrite(() =>
|
|
1115
1518
|
updateCanvas(canvasId, { widgets: next })
|
|
1116
1519
|
.catch((err) => console.error('[canvas] Failed to save multi-move:', err))
|
|
1117
|
-
.finally(() => { dirtyRef.current = false })
|
|
1118
1520
|
)
|
|
1119
1521
|
return next
|
|
1120
1522
|
})
|
|
@@ -1147,7 +1549,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1147
1549
|
queueWrite(() =>
|
|
1148
1550
|
updateCanvas(canvasId, { sources: next })
|
|
1149
1551
|
.catch((err) => console.error('[canvas] Failed to save multi-move sources:', err))
|
|
1150
|
-
.finally(() => { dirtyRef.current = false })
|
|
1151
1552
|
)
|
|
1152
1553
|
}
|
|
1153
1554
|
return changed ? next : current
|
|
@@ -1167,7 +1568,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1167
1568
|
queueWrite(() =>
|
|
1168
1569
|
updateCanvas(canvasId, { sources: next })
|
|
1169
1570
|
.catch((err) => console.error('[canvas] Failed to save source position:', err))
|
|
1170
|
-
.finally(() => { dirtyRef.current = false })
|
|
1171
1571
|
)
|
|
1172
1572
|
return next
|
|
1173
1573
|
})
|
|
@@ -1185,7 +1585,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1185
1585
|
queueWrite(() =>
|
|
1186
1586
|
updateCanvas(canvasId, { widgets: next })
|
|
1187
1587
|
.catch((err) => console.error('[canvas] Failed to save widget position:', err))
|
|
1188
|
-
.finally(() => { dirtyRef.current = false })
|
|
1189
1588
|
)
|
|
1190
1589
|
return next
|
|
1191
1590
|
})
|
|
@@ -1210,20 +1609,21 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1210
1609
|
if (!el || loading) return
|
|
1211
1610
|
const saved = pendingScrollRestore.current
|
|
1212
1611
|
if (saved) {
|
|
1213
|
-
console.log('[viewport] restoring saved viewport — zoom:', saved.zoom, 'scroll:', saved.scrollLeft, saved.scrollTop)
|
|
1612
|
+
if (getFlag('dev-logs')) console.log('[viewport] restoring saved viewport — zoom:', saved.zoom, 'scroll:', saved.scrollLeft, saved.scrollTop)
|
|
1214
1613
|
// Fresh saved viewport — restore exactly
|
|
1215
1614
|
if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
|
|
1216
1615
|
if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
|
|
1217
1616
|
pendingScrollRestore.current = null
|
|
1218
1617
|
} else {
|
|
1219
|
-
console.log('[viewport] no saved viewport — fitting to objects')
|
|
1618
|
+
if (getFlag('dev-logs')) console.log('[viewport] no saved viewport — fitting to objects')
|
|
1220
1619
|
// No saved state or stale — zoom-to-fit all objects
|
|
1221
1620
|
const bounds = computeCanvasBounds(localWidgets, componentEntries)
|
|
1222
1621
|
if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
|
|
1223
1622
|
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
1224
1623
|
const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
|
|
1225
1624
|
const fitScale = Math.min(el.clientWidth / boxW, el.clientHeight / boxH)
|
|
1226
|
-
const
|
|
1625
|
+
const { ZOOM_MIN: zMin, ZOOM_MAX: zMax } = zoomLimits()
|
|
1626
|
+
const fitZoom = Math.min(zMax, Math.max(zMin, Math.round(fitScale * 100)))
|
|
1227
1627
|
const newScale = fitZoom / 100
|
|
1228
1628
|
zoomRef.current = fitZoom
|
|
1229
1629
|
// Imperative DOM update for initial zoom-to-fit — same path as applyZoom
|
|
@@ -1300,7 +1700,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1300
1700
|
useEffect(() => {
|
|
1301
1701
|
if (viewportInitName.current !== canvasId) return
|
|
1302
1702
|
const el = scrollRef.current
|
|
1303
|
-
console.log('[viewport] saving — zoom:', zoom, 'scroll:', el?.scrollLeft, el?.scrollTop)
|
|
1703
|
+
if (getFlag('dev-logs')) console.log('[viewport] saving — zoom:', zoom, 'scroll:', el?.scrollLeft, el?.scrollTop)
|
|
1304
1704
|
// Read current scroll so the zoom entry doesn't zero-out position,
|
|
1305
1705
|
// but the authoritative scroll save comes from the scroll handler.
|
|
1306
1706
|
saveViewportState(canvasId, {
|
|
@@ -1345,6 +1745,56 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1345
1745
|
}
|
|
1346
1746
|
}, [canvasId, loading])
|
|
1347
1747
|
|
|
1748
|
+
// Gather current viewport data from refs (safe for callbacks/timeouts)
|
|
1749
|
+
const getViewportData = useCallback(() => {
|
|
1750
|
+
const el = scrollRef.current
|
|
1751
|
+
if (!el) return null
|
|
1752
|
+
const scale = zoomRef.current / 100
|
|
1753
|
+
const scrollLeft = el.scrollLeft
|
|
1754
|
+
const scrollTop = el.scrollTop
|
|
1755
|
+
const cw = el.clientWidth
|
|
1756
|
+
const ch = el.clientHeight
|
|
1757
|
+
return {
|
|
1758
|
+
centerX: Math.round((scrollLeft + cw / 2) / scale),
|
|
1759
|
+
centerY: Math.round((scrollTop + ch / 2) / scale),
|
|
1760
|
+
zoom: zoomRef.current,
|
|
1761
|
+
topLeftX: Math.round(scrollLeft / scale),
|
|
1762
|
+
topLeftY: Math.round(scrollTop / scale),
|
|
1763
|
+
width: Math.round(cw / scale),
|
|
1764
|
+
height: Math.round(ch / scale),
|
|
1765
|
+
}
|
|
1766
|
+
}, [])
|
|
1767
|
+
|
|
1768
|
+
// Debounced viewport-changed HMR event — sends position/zoom to Vite server
|
|
1769
|
+
// so the selected-widgets bridge can write it to disk for agents.
|
|
1770
|
+
useEffect(() => {
|
|
1771
|
+
if (!import.meta.hot) return
|
|
1772
|
+
const el = scrollRef.current
|
|
1773
|
+
if (!el) return
|
|
1774
|
+
|
|
1775
|
+
const tabId = selectionTabIdRef.current
|
|
1776
|
+
|
|
1777
|
+
function sendViewport() {
|
|
1778
|
+
const viewport = getViewportData()
|
|
1779
|
+
if (viewport) {
|
|
1780
|
+
import.meta.hot.send('storyboard:viewport-changed', { tabId, canvasId, viewport })
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
const debouncedSend = debounce(sendViewport, 500)
|
|
1785
|
+
|
|
1786
|
+
function handleScroll() { debouncedSend() }
|
|
1787
|
+
el.addEventListener('scroll', handleScroll, { passive: true })
|
|
1788
|
+
|
|
1789
|
+
// Also send on zoom commits (zoom state changes trigger this effect)
|
|
1790
|
+
sendViewport()
|
|
1791
|
+
|
|
1792
|
+
return () => {
|
|
1793
|
+
debouncedSend.cancel()
|
|
1794
|
+
el.removeEventListener('scroll', handleScroll)
|
|
1795
|
+
}
|
|
1796
|
+
}, [canvasId, zoom, loading, getViewportData])
|
|
1797
|
+
|
|
1348
1798
|
/**
|
|
1349
1799
|
* Zoom to a new level, anchoring on an optional client-space point.
|
|
1350
1800
|
* When a cursor position is provided (e.g. from a wheel event), the
|
|
@@ -1359,6 +1809,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1359
1809
|
function applyZoom(newZoom, clientX, clientY) {
|
|
1360
1810
|
const el = scrollRef.current
|
|
1361
1811
|
const zoomEl = zoomElRef.current
|
|
1812
|
+
const { ZOOM_MIN, ZOOM_MAX } = zoomLimits()
|
|
1362
1813
|
const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
|
|
1363
1814
|
|
|
1364
1815
|
if (!el || !zoomEl) {
|
|
@@ -1405,7 +1856,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1405
1856
|
if (!zoomEventTimer.current) {
|
|
1406
1857
|
zoomEventTimer.current = setTimeout(() => {
|
|
1407
1858
|
zoomEventTimer.current = null
|
|
1408
|
-
window[CANVAS_BRIDGE_STATE_KEY]
|
|
1859
|
+
const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
|
|
1860
|
+
bridge.active = true
|
|
1861
|
+
bridge.canvasId = canvasId
|
|
1862
|
+
bridge.zoom = zoomRef.current
|
|
1863
|
+
window[CANVAS_BRIDGE_STATE_KEY] = bridge
|
|
1409
1864
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
1410
1865
|
detail: { zoom: zoomRef.current }
|
|
1411
1866
|
}))
|
|
@@ -1415,7 +1870,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1415
1870
|
|
|
1416
1871
|
// Signal canvas mount/unmount to CoreUIBar
|
|
1417
1872
|
useEffect(() => {
|
|
1418
|
-
window[CANVAS_BRIDGE_STATE_KEY]
|
|
1873
|
+
const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
|
|
1874
|
+
bridge.active = true
|
|
1875
|
+
bridge.canvasId = canvasId
|
|
1876
|
+
bridge.zoom = zoomRef.current
|
|
1877
|
+
window[CANVAS_BRIDGE_STATE_KEY] = bridge
|
|
1419
1878
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
|
|
1420
1879
|
detail: { canvasId, zoom: zoomRef.current }
|
|
1421
1880
|
}))
|
|
@@ -1435,14 +1894,15 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1435
1894
|
}, [canvasId])
|
|
1436
1895
|
|
|
1437
1896
|
// Tell the Vite dev server to suppress full-reloads while this canvas is active.
|
|
1438
|
-
//
|
|
1897
|
+
// Controlled by the "canvas-auto-reload" feature flag (default: false = guard ON).
|
|
1898
|
+
// When the flag is true, the guard is skipped so canvas pages receive HMR updates.
|
|
1439
1899
|
// Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
|
|
1440
1900
|
useEffect(() => {
|
|
1441
1901
|
if (!import.meta.hot) return
|
|
1442
|
-
const
|
|
1443
|
-
if (
|
|
1902
|
+
const autoReload = getFlag('canvas-auto-reload')
|
|
1903
|
+
if (autoReload) return
|
|
1444
1904
|
|
|
1445
|
-
const msg = { active: true
|
|
1905
|
+
const msg = { active: true }
|
|
1446
1906
|
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
1447
1907
|
const interval = setInterval(() => {
|
|
1448
1908
|
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
@@ -1450,7 +1910,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1450
1910
|
|
|
1451
1911
|
return () => {
|
|
1452
1912
|
clearInterval(interval)
|
|
1453
|
-
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false
|
|
1913
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false })
|
|
1454
1914
|
}
|
|
1455
1915
|
}, [canvasId])
|
|
1456
1916
|
|
|
@@ -1484,7 +1944,8 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1484
1944
|
|
|
1485
1945
|
function sendFocus() {
|
|
1486
1946
|
const { widgetIds, widgets } = getSelectedWidgetData()
|
|
1487
|
-
|
|
1947
|
+
const viewport = getViewportData()
|
|
1948
|
+
import.meta.hot.send('storyboard:canvas-focused', { tabId, canvasId, widgetIds, widgets, viewport })
|
|
1488
1949
|
}
|
|
1489
1950
|
|
|
1490
1951
|
sendFocus()
|
|
@@ -1511,7 +1972,8 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1511
1972
|
const tabId = selectionTabIdRef.current
|
|
1512
1973
|
const timer = setTimeout(() => {
|
|
1513
1974
|
const { widgetIds, widgets } = getSelectedWidgetData()
|
|
1514
|
-
|
|
1975
|
+
const viewport = getViewportData()
|
|
1976
|
+
import.meta.hot.send('storyboard:selection-changed', { tabId, canvasId, widgetIds: widgetIds, widgets, viewport })
|
|
1515
1977
|
}, 500)
|
|
1516
1978
|
|
|
1517
1979
|
return () => clearTimeout(timer)
|
|
@@ -1520,6 +1982,12 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1520
1982
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
1521
1983
|
const addWidget = useCallback(async (type, extraProps = {}) => {
|
|
1522
1984
|
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
1985
|
+
// For terminal/agent, apply config-based dimension defaults over schema defaults
|
|
1986
|
+
if (type === 'terminal' || type === 'agent') {
|
|
1987
|
+
const dims = getTerminalDimensions(extraProps.agentId, { width: defaultProps.width ?? 800, height: defaultProps.height ?? 450 })
|
|
1988
|
+
defaultProps.width = dims.width
|
|
1989
|
+
defaultProps.height = dims.height
|
|
1990
|
+
}
|
|
1523
1991
|
const mergedProps = { ...defaultProps, ...extraProps }
|
|
1524
1992
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1525
1993
|
const pos = centerPositionForWidget(center, type, mergedProps)
|
|
@@ -1560,7 +2028,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1560
2028
|
}
|
|
1561
2029
|
}, [canvasId, undoRedo])
|
|
1562
2030
|
|
|
1563
|
-
// Listen for CoreUIBar add-widget events
|
|
2031
|
+
// Listen for CoreUIBar add-widget and update-widget events
|
|
1564
2032
|
useEffect(() => {
|
|
1565
2033
|
function handleAddWidget(e) {
|
|
1566
2034
|
addWidget(e.detail.type, e.detail.props)
|
|
@@ -1568,13 +2036,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1568
2036
|
function handleAddStoryWidget(e) {
|
|
1569
2037
|
addStoryWidget(e.detail.storyId)
|
|
1570
2038
|
}
|
|
2039
|
+
function handleUpdateWidget(e) {
|
|
2040
|
+
const { widgetId, updates } = e.detail || {}
|
|
2041
|
+
if (widgetId && updates) handleWidgetUpdate(widgetId, updates)
|
|
2042
|
+
}
|
|
1571
2043
|
document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
1572
2044
|
document.addEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
|
|
2045
|
+
document.addEventListener('storyboard:canvas:update-widget', handleUpdateWidget)
|
|
1573
2046
|
return () => {
|
|
1574
2047
|
document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
1575
2048
|
document.removeEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
|
|
2049
|
+
document.removeEventListener('storyboard:canvas:update-widget', handleUpdateWidget)
|
|
1576
2050
|
}
|
|
1577
|
-
}, [addWidget, addStoryWidget])
|
|
2051
|
+
}, [addWidget, addStoryWidget, handleWidgetUpdate])
|
|
1578
2052
|
|
|
1579
2053
|
// Listen for zoom changes from CoreUIBar
|
|
1580
2054
|
useEffect(() => {
|
|
@@ -1654,7 +2128,8 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1654
2128
|
|
|
1655
2129
|
// Find the zoom level that fits the bounding box in the viewport
|
|
1656
2130
|
const fitScale = Math.min(viewW / boxW, viewH / boxH)
|
|
1657
|
-
const
|
|
2131
|
+
const { ZOOM_MIN: zMin, ZOOM_MAX: zMax } = zoomLimits()
|
|
2132
|
+
const fitZoom = Math.min(zMax, Math.max(zMin, Math.round(fitScale * 100)))
|
|
1658
2133
|
const newScale = fitZoom / 100
|
|
1659
2134
|
|
|
1660
2135
|
// Imperative DOM update — same path as applyZoom
|
|
@@ -1697,14 +2172,20 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1697
2172
|
|
|
1698
2173
|
// Broadcast zoom level to CoreUIBar whenever it changes
|
|
1699
2174
|
useEffect(() => {
|
|
1700
|
-
window[CANVAS_BRIDGE_STATE_KEY]
|
|
2175
|
+
const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
|
|
2176
|
+
bridge.active = true
|
|
2177
|
+
bridge.canvasId = canvasId
|
|
2178
|
+
bridge.zoom = zoom
|
|
2179
|
+
window[CANVAS_BRIDGE_STATE_KEY] = bridge
|
|
1701
2180
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
1702
2181
|
detail: { zoom }
|
|
1703
2182
|
}))
|
|
1704
2183
|
}, [canvasId, zoom])
|
|
1705
2184
|
|
|
1706
|
-
// Keep bridge in sync with widgets/connectors for expand features
|
|
1707
|
-
|
|
2185
|
+
// Keep bridge in sync with widgets/connectors for expand features.
|
|
2186
|
+
// Child widgets now use props directly for split-screen gating, but
|
|
2187
|
+
// FigmaEmbed/PrototypeEmbed/etc. still read this bridge at expand time.
|
|
2188
|
+
useMemo(() => {
|
|
1708
2189
|
const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
|
|
1709
2190
|
bridge.widgets = localWidgets
|
|
1710
2191
|
bridge.connectors = localConnectors
|
|
@@ -1748,6 +2229,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1748
2229
|
// Multi-delete — snapshot once, remove all, persist via updateCanvas
|
|
1749
2230
|
undoRedo.snapshot(stateRef.current, 'multi-remove')
|
|
1750
2231
|
debouncedSave.cancel()
|
|
2232
|
+
dirtyRef.current = true
|
|
1751
2233
|
setLocalWidgets((prev) => {
|
|
1752
2234
|
if (!prev) return prev
|
|
1753
2235
|
const next = prev.filter(w => !selectedWidgetIds.has(w.id))
|
|
@@ -1934,6 +2416,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1934
2416
|
const relY = (w.position?.y ?? 0) - minY
|
|
1935
2417
|
const pasteProps = { ...w.props }
|
|
1936
2418
|
if (w.type === 'terminal' || w.type === 'agent') delete pasteProps.prettyName
|
|
2419
|
+
// Image widgets: duplicate the asset so the paste owns its own copy
|
|
2420
|
+
if (w.type === 'image' && pasteProps.src) {
|
|
2421
|
+
try {
|
|
2422
|
+
const dupResult = await duplicateImage(pasteProps.src)
|
|
2423
|
+
if (dupResult.success) pasteProps.src = dupResult.filename
|
|
2424
|
+
} catch { /* use original src as fallback */ }
|
|
2425
|
+
}
|
|
1937
2426
|
const result = await addWidgetApi(canvasId, {
|
|
1938
2427
|
type: w.type,
|
|
1939
2428
|
props: pasteProps,
|
|
@@ -1956,11 +2445,34 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1956
2445
|
}
|
|
1957
2446
|
|
|
1958
2447
|
e.preventDefault()
|
|
2448
|
+
await pasteTextAsWidget(text, pasteCtx)
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
// Shared helper: resolve pasted text into a widget and add it to the canvas.
|
|
2452
|
+
// Used by both native paste and the programmatic paste-url event.
|
|
2453
|
+
async function pasteTextAsWidget(text, pasteCtx) {
|
|
1959
2454
|
const resolved = resolvePaste(text, pasteCtx, getPasteRules())
|
|
1960
2455
|
if (!resolved) return
|
|
1961
|
-
|
|
2456
|
+
let { type } = resolved
|
|
1962
2457
|
let props = resolved.props
|
|
1963
2458
|
|
|
2459
|
+
// Component/story URLs → story widget (instead of prototype embed)
|
|
2460
|
+
if (type === 'prototype' && props?.src) {
|
|
2461
|
+
const srcPath = props.src.replace(/[?#].*$/, '').replace(/\/+$/, '')
|
|
2462
|
+
const storyId = storyRouteIndex.get(srcPath)
|
|
2463
|
+
if (storyId) {
|
|
2464
|
+
type = 'story'
|
|
2465
|
+
const parsed = pasteCtx.parseUrl(text)
|
|
2466
|
+
const searchParams = new URLSearchParams(parsed?.search || '')
|
|
2467
|
+
props = {
|
|
2468
|
+
storyId,
|
|
2469
|
+
exportName: searchParams.get('export') || '',
|
|
2470
|
+
width: 600,
|
|
2471
|
+
height: 400,
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
|
|
1964
2476
|
if (type === 'link-preview' && isGitHubEmbedUrl(props?.url || text)) {
|
|
1965
2477
|
const githubUpdates = await buildGitHubPreviewUpdates(props?.url || text)
|
|
1966
2478
|
if (githubUpdates) props = { ...props, ...githubUpdates }
|
|
@@ -1984,8 +2496,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1984
2496
|
}
|
|
1985
2497
|
}
|
|
1986
2498
|
|
|
2499
|
+
// Listen for programmatic paste-url events from the command palette
|
|
2500
|
+
function handlePasteUrl(e) {
|
|
2501
|
+
const text = e.detail?.url?.trim()
|
|
2502
|
+
if (!text) return
|
|
2503
|
+
pasteTextAsWidget(text, pasteCtx)
|
|
2504
|
+
}
|
|
2505
|
+
|
|
1987
2506
|
document.addEventListener('paste', handlePaste)
|
|
1988
|
-
|
|
2507
|
+
document.addEventListener('storyboard:canvas:paste-url', handlePasteUrl)
|
|
2508
|
+
return () => {
|
|
2509
|
+
document.removeEventListener('paste', handlePaste)
|
|
2510
|
+
document.removeEventListener('storyboard:canvas:paste-url', handlePasteUrl)
|
|
2511
|
+
}
|
|
1989
2512
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1990
2513
|
}, [canvasId, undoRedo, localWidgets])
|
|
1991
2514
|
|
|
@@ -2066,7 +2589,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2066
2589
|
queueWrite(() =>
|
|
2067
2590
|
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors })
|
|
2068
2591
|
.catch((err) => console.error('[canvas] Failed to persist undo:', err))
|
|
2069
|
-
.finally(() => { dirtyRef.current = false })
|
|
2070
2592
|
)
|
|
2071
2593
|
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
2072
2594
|
|
|
@@ -2082,16 +2604,17 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2082
2604
|
queueWrite(() =>
|
|
2083
2605
|
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors })
|
|
2084
2606
|
.catch((err) => console.error('[canvas] Failed to persist redo:', err))
|
|
2085
|
-
.finally(() => { dirtyRef.current = false })
|
|
2086
2607
|
)
|
|
2087
2608
|
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
2088
2609
|
|
|
2089
|
-
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
|
|
2610
|
+
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z / Cmd+D / Cmd+A)
|
|
2090
2611
|
useEffect(() => {
|
|
2091
2612
|
if (!import.meta.hot) return
|
|
2092
2613
|
function handleKeyDown(e) {
|
|
2093
2614
|
const tag = e.target.tagName
|
|
2094
2615
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
2616
|
+
// Don't intercept shortcuts when the command palette is open
|
|
2617
|
+
if (e.target.closest?.('[cmdk-root]')) return
|
|
2095
2618
|
const mod = e.metaKey || e.ctrlKey
|
|
2096
2619
|
if (mod && e.key === 'z' && !e.shiftKey) {
|
|
2097
2620
|
e.preventDefault()
|
|
@@ -2101,10 +2624,21 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2101
2624
|
e.preventDefault()
|
|
2102
2625
|
handleRedo()
|
|
2103
2626
|
}
|
|
2627
|
+
if (mod && e.key.toLowerCase() === 'd' && e.shiftKey) {
|
|
2628
|
+
e.preventDefault()
|
|
2629
|
+
handleDuplicateWithConnectors()
|
|
2630
|
+
} else if (mod && e.key.toLowerCase() === 'd' && !e.shiftKey) {
|
|
2631
|
+
e.preventDefault()
|
|
2632
|
+
handleDuplicateSelected()
|
|
2633
|
+
}
|
|
2634
|
+
if (mod && e.key === 'a') {
|
|
2635
|
+
e.preventDefault()
|
|
2636
|
+
handleSelectAll()
|
|
2637
|
+
}
|
|
2104
2638
|
}
|
|
2105
2639
|
document.addEventListener('keydown', handleKeyDown)
|
|
2106
2640
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
2107
|
-
}, [handleUndo, handleRedo])
|
|
2641
|
+
}, [handleUndo, handleRedo, handleDuplicateSelected, handleDuplicateWithConnectors, handleSelectAll])
|
|
2108
2642
|
|
|
2109
2643
|
// Listen for undo/redo from CoreUIBar (Svelte toolbar)
|
|
2110
2644
|
useEffect(() => {
|
|
@@ -2274,6 +2808,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2274
2808
|
zoomRef: zoomRef,
|
|
2275
2809
|
setSelectedWidgetIds,
|
|
2276
2810
|
widgets: localWidgets,
|
|
2811
|
+
connectors: localConnectors,
|
|
2277
2812
|
componentEntries,
|
|
2278
2813
|
fallbackSizes: WIDGET_FALLBACK_SIZES,
|
|
2279
2814
|
spaceHeld,
|
|
@@ -2331,6 +2866,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2331
2866
|
id={`jsx-${exportName}`}
|
|
2332
2867
|
data-tc-x={sourcePosition.x}
|
|
2333
2868
|
data-tc-y={sourcePosition.y}
|
|
2869
|
+
data-widget-raised={selectedWidgetIds.has(`jsx-${exportName}`) || undefined}
|
|
2334
2870
|
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
2335
2871
|
{...canvasPrimerAttrs}
|
|
2336
2872
|
style={canvasThemeVars}
|
|
@@ -2367,13 +2903,12 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2367
2903
|
}
|
|
2368
2904
|
|
|
2369
2905
|
// 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
|
|
2370
|
-
//
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
for (const widget of sortedWidgets) {
|
|
2906
|
+
// Stable DOM order — visual stacking is controlled by z-index on the
|
|
2907
|
+
// wrapper div (data-widget-raised), NOT by re-sorting the array.
|
|
2908
|
+
// Re-sorting caused iframe widgets (stories, embeds) to remount and
|
|
2909
|
+
// reload every time selection changed, because moving an iframe node
|
|
2910
|
+
// in the DOM destroys its browsing context.
|
|
2911
|
+
for (const widget of (localWidgets ?? [])) {
|
|
2377
2912
|
// In production, render terminal widgets as read-only instead of hiding them
|
|
2378
2913
|
const effectiveWidget = (!isLocalDev && (widget.type === 'terminal' || widget.type === 'agent'))
|
|
2379
2914
|
? { ...widget, type: 'terminal-read' }
|
|
@@ -2384,6 +2919,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2384
2919
|
id={effectiveWidget.id}
|
|
2385
2920
|
data-tc-x={effectiveWidget?.position?.x ?? 0}
|
|
2386
2921
|
data-tc-y={effectiveWidget?.position?.y ?? 0}
|
|
2922
|
+
data-widget-raised={selectedWidgetIds.has(widget.id) || undefined}
|
|
2387
2923
|
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
2388
2924
|
{...canvasPrimerAttrs}
|
|
2389
2925
|
style={canvasThemeVars}
|
|
@@ -2398,11 +2934,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2398
2934
|
widget={effectiveWidget}
|
|
2399
2935
|
selected={selectedWidgetIds.has(widget.id)}
|
|
2400
2936
|
multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
|
|
2401
|
-
connectorCount={localConnectors.filter((c) => c.start?.widgetId === widget.id || c.end?.widgetId === widget.id)
|
|
2937
|
+
connectorCount={localConnectors.filter((c) => c.start?.widgetId === widget.id || c.end?.widgetId === widget.id)}
|
|
2938
|
+
allWidgets={localWidgets}
|
|
2402
2939
|
onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
|
|
2403
2940
|
onDeselect={handleDeselectAll}
|
|
2404
2941
|
onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
|
|
2405
2942
|
onCopy={isLocalDev ? handleWidgetCopy : undefined}
|
|
2943
|
+
onCopyWithConnectors={isLocalDev ? handleWidgetCopyWithConnectors : undefined}
|
|
2406
2944
|
onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
|
|
2407
2945
|
onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
|
|
2408
2946
|
canRefreshGitHub={isLocalDev}
|
|
@@ -2460,6 +2998,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2460
2998
|
<ConnectorLayer
|
|
2461
2999
|
connectors={filteredConnectors}
|
|
2462
3000
|
widgets={localWidgets ?? []}
|
|
3001
|
+
selectedWidgetIds={selectedWidgetIds}
|
|
2463
3002
|
onRemove={isLocalDev ? handleConnectorRemove : undefined}
|
|
2464
3003
|
onEndpointDrag={undefined}
|
|
2465
3004
|
dragPreview={connectorDrag}
|