@dfosco/storyboard-react 4.2.0-beta.1 → 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 +5 -4
- package/src/AuthModal/AuthModal.jsx +6 -2
- package/src/BranchBar/BranchBar.jsx +20 -6
- package/src/BranchBar/BranchBar.module.css +13 -4
- package/src/BranchBar/useBranches.js +20 -6
- package/src/BranchBar/useBranches.test.js +68 -0
- package/src/CommandPalette/CommandPalette.jsx +478 -186
- package/src/CommandPalette/command-palette.css +142 -78
- package/src/Icon.jsx +157 -58
- package/src/Viewfinder.jsx +561 -191
- package/src/Viewfinder.module.css +434 -93
- package/src/Workspace.jsx +7 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
- package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
- package/src/canvas/CanvasPage.jsx +738 -216
- package/src/canvas/CanvasPage.module.css +13 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
- package/src/canvas/ConnectorLayer.jsx +121 -153
- package/src/canvas/ConnectorLayer.module.css +69 -0
- package/src/canvas/PageSelector.test.jsx +15 -6
- package/src/canvas/canvasApi.js +68 -2
- package/src/canvas/connectorGeometry.js +132 -0
- package/src/canvas/hotPoolDevLogs.js +25 -0
- package/src/canvas/useCanvas.js +1 -1
- 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 +62 -47
- package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
- package/src/canvas/widgets/ImageWidget.jsx +130 -9
- package/src/canvas/widgets/ImageWidget.module.css +30 -0
- package/src/canvas/widgets/LinkPreview.jsx +112 -4
- package/src/canvas/widgets/LinkPreview.module.css +127 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +164 -17
- package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
- package/src/canvas/widgets/PromptWidget.jsx +414 -0
- package/src/canvas/widgets/PromptWidget.module.css +273 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +77 -38
- package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
- package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
- package/src/canvas/widgets/ResizeHandle.jsx +17 -6
- package/src/canvas/widgets/StoryWidget.jsx +72 -15
- package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
- package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
- package/src/canvas/widgets/TerminalWidget.jsx +496 -69
- package/src/canvas/widgets/TerminalWidget.module.css +271 -8
- package/src/canvas/widgets/TilesWidget.jsx +302 -0
- package/src/canvas/widgets/TilesWidget.module.css +133 -0
- package/src/canvas/widgets/WidgetChrome.jsx +73 -153
- package/src/canvas/widgets/WidgetChrome.module.css +30 -1
- package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
- package/src/canvas/widgets/expandUtils.js +557 -0
- package/src/canvas/widgets/expandUtils.test.js +155 -0
- package/src/canvas/widgets/index.js +9 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
- 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 +55 -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/useConfig.js +14 -0
- package/src/hooks/usePrototypeReloadGuard.js +64 -0
- package/src/index.js +8 -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 +324 -30
|
@@ -6,11 +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, 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'
|
|
18
|
+
|
|
14
19
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
15
20
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
16
21
|
import useUndoRedo from './useUndoRedo.js'
|
|
@@ -19,6 +24,7 @@ import MarqueeOverlay from './MarqueeOverlay.jsx'
|
|
|
19
24
|
import {
|
|
20
25
|
addWidget as addWidgetApi,
|
|
21
26
|
checkGitHubCliAvailable,
|
|
27
|
+
duplicateImage,
|
|
22
28
|
fetchGitHubEmbed,
|
|
23
29
|
getCanvas as getCanvasApi,
|
|
24
30
|
removeWidget as removeWidgetApi,
|
|
@@ -27,6 +33,8 @@ import {
|
|
|
27
33
|
uploadImage,
|
|
28
34
|
addConnector as addConnectorApi,
|
|
29
35
|
removeConnector as removeConnectorApi,
|
|
36
|
+
updateConnector as updateConnectorApi,
|
|
37
|
+
batchOperations,
|
|
30
38
|
} from './canvasApi.js'
|
|
31
39
|
import PageSelector from './PageSelector.jsx'
|
|
32
40
|
import Icon from '../Icon.jsx'
|
|
@@ -34,8 +42,11 @@ import { stories as storyIndex } from 'virtual:storyboard-data-index'
|
|
|
34
42
|
import styles from './CanvasPage.module.css'
|
|
35
43
|
import ConnectorLayer from './ConnectorLayer.jsx'
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
}
|
|
39
50
|
|
|
40
51
|
/** Saved viewport state older than this is considered stale — zoom-to-fit instead. */
|
|
41
52
|
const VIEWPORT_TTL_MS = 15 * 60 * 1000
|
|
@@ -44,6 +55,7 @@ const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
|
|
|
44
55
|
const GH_INSTALL_URL = 'https://github.com/cli/cli'
|
|
45
56
|
|
|
46
57
|
registerSmoothCorners()
|
|
58
|
+
registerHotPoolDevLogs()
|
|
47
59
|
|
|
48
60
|
/** Matches branch-deploy base path prefixes like /branch--my-feature/ */
|
|
49
61
|
const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
|
|
@@ -132,16 +144,17 @@ function getViewportStorageKey(canvasId) {
|
|
|
132
144
|
function loadViewportState(canvasId) {
|
|
133
145
|
try {
|
|
134
146
|
const raw = localStorage.getItem(getViewportStorageKey(canvasId))
|
|
135
|
-
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 }
|
|
136
148
|
const state = JSON.parse(raw)
|
|
137
149
|
const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
|
|
138
150
|
const age = Date.now() - timestamp
|
|
139
151
|
if (age > VIEWPORT_TTL_MS) {
|
|
140
|
-
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')
|
|
141
153
|
localStorage.removeItem(getViewportStorageKey(canvasId))
|
|
142
154
|
return null
|
|
143
155
|
}
|
|
144
|
-
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()
|
|
145
158
|
return {
|
|
146
159
|
zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
|
|
147
160
|
scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
|
|
@@ -271,13 +284,15 @@ function computeCanvasBounds(widgets, componentEntries) {
|
|
|
271
284
|
}
|
|
272
285
|
|
|
273
286
|
/** Renders a single JSON-defined widget by type lookup. */
|
|
274
|
-
function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub }) {
|
|
287
|
+
function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub, multiSelected }) {
|
|
275
288
|
const Component = getWidgetComponent(widget.type)
|
|
276
289
|
if (!Component) {
|
|
277
290
|
console.warn(`[canvas] Unknown widget type: ${widget.type}`)
|
|
278
291
|
return null
|
|
279
292
|
}
|
|
280
|
-
const resizable =
|
|
293
|
+
const resizable = (widget.type === 'terminal' || widget.type === 'agent')
|
|
294
|
+
? isTerminalResizable(widget.props?.agentId) && !!onUpdate
|
|
295
|
+
: isResizable(widget.type) && !!onUpdate
|
|
281
296
|
// Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
|
|
282
297
|
const elementProps = {
|
|
283
298
|
id: widget.id,
|
|
@@ -286,6 +301,7 @@ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefre
|
|
|
286
301
|
resizable,
|
|
287
302
|
onRefreshGitHub,
|
|
288
303
|
canRefreshGitHub,
|
|
304
|
+
multiSelected,
|
|
289
305
|
}
|
|
290
306
|
if (Component.$$typeof === Symbol.for('react.forward_ref')) {
|
|
291
307
|
elementProps.ref = widgetRef
|
|
@@ -303,11 +319,14 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
303
319
|
widget,
|
|
304
320
|
selected,
|
|
305
321
|
multiSelected,
|
|
322
|
+
connectorCount,
|
|
323
|
+
allWidgets,
|
|
306
324
|
onSelect,
|
|
307
325
|
onDeselect,
|
|
308
326
|
onUpdate,
|
|
309
327
|
onRemove,
|
|
310
328
|
onCopy,
|
|
329
|
+
onCopyWithConnectors,
|
|
311
330
|
onRefreshGitHub,
|
|
312
331
|
canRefreshGitHub,
|
|
313
332
|
onConnectorDragStart,
|
|
@@ -319,7 +338,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
319
338
|
// Dynamically adjust features based on widget state
|
|
320
339
|
const features = useMemo(() => {
|
|
321
340
|
const isGitHub = !!widget.props?.github
|
|
322
|
-
|
|
341
|
+
const adjusted = rawFeatures.map((f) => {
|
|
323
342
|
// Toggle collapse label and hide when content is short (no github = no collapse)
|
|
324
343
|
if (f.action === 'toggle-collapse') {
|
|
325
344
|
if (widget.type === 'link-preview' && !isGitHub) return null
|
|
@@ -333,13 +352,78 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
333
352
|
if (f.action === 'refresh-github' && !isGitHub) return null
|
|
334
353
|
return f
|
|
335
354
|
}).filter(Boolean)
|
|
336
|
-
}, [rawFeatures, widget.props?.github, widget.props?.collapsed])
|
|
337
355
|
|
|
338
|
-
|
|
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.
|
|
359
|
+
if (isExpandable(widget.type)) {
|
|
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
|
+
})
|
|
365
|
+
if (hasConnected) {
|
|
366
|
+
// Insert before the first menu-only feature
|
|
367
|
+
const insertIdx = adjusted.findIndex((f) => f.menu)
|
|
368
|
+
const splitFeature = {
|
|
369
|
+
id: 'split-screen',
|
|
370
|
+
type: 'action',
|
|
371
|
+
action: 'split-screen',
|
|
372
|
+
label: 'Split Screen',
|
|
373
|
+
icon: 'columns',
|
|
374
|
+
prod: true,
|
|
375
|
+
}
|
|
376
|
+
if (insertIdx >= 0) adjusted.splice(insertIdx, 0, splitFeature)
|
|
377
|
+
else adjusted.push(splitFeature)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
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
|
+
|
|
415
|
+
return adjusted
|
|
416
|
+
}, [rawFeatures, widget.props?.github, widget.props?.collapsed, widget.type, widget.id, connectorCount, allWidgets])
|
|
417
|
+
|
|
418
|
+
const handleAction = useCallback((actionId, opts) => {
|
|
339
419
|
if (actionId === 'delete') {
|
|
340
420
|
onRemove?.(widget.id)
|
|
341
421
|
} else if (actionId === 'copy') {
|
|
342
|
-
|
|
422
|
+
if (opts?.altKey && onCopyWithConnectors) {
|
|
423
|
+
onCopyWithConnectors(widget)
|
|
424
|
+
} else {
|
|
425
|
+
onCopy?.(widget)
|
|
426
|
+
}
|
|
343
427
|
} else if (actionId === 'copy-text') {
|
|
344
428
|
const title = widget.props?.title || ''
|
|
345
429
|
const body = widget.props?.text || widget.props?.content || widget.props?.github?.body || ''
|
|
@@ -361,8 +445,20 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
361
445
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
362
446
|
})
|
|
363
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
|
+
}
|
|
364
460
|
}
|
|
365
|
-
}, [widget, onRemove, onCopy, onRefreshGitHub])
|
|
461
|
+
}, [widget, onRemove, onCopy, onCopyWithConnectors, onRefreshGitHub])
|
|
366
462
|
|
|
367
463
|
const handleWidgetFieldUpdate = useCallback((updates) => {
|
|
368
464
|
onUpdate?.(widget.id, updates)
|
|
@@ -390,6 +486,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
390
486
|
widgetRef={widgetRef}
|
|
391
487
|
onRefreshGitHub={onRefreshGitHub}
|
|
392
488
|
canRefreshGitHub={canRefreshGitHub}
|
|
489
|
+
multiSelected={multiSelected}
|
|
393
490
|
/>
|
|
394
491
|
</WidgetChrome>
|
|
395
492
|
)
|
|
@@ -398,6 +495,8 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
398
495
|
prev.widget === next.widget &&
|
|
399
496
|
prev.selected === next.selected &&
|
|
400
497
|
prev.multiSelected === next.multiSelected &&
|
|
498
|
+
prev.connectorCount === next.connectorCount &&
|
|
499
|
+
prev.allWidgets === next.allWidgets &&
|
|
401
500
|
prev.readOnly === next.readOnly &&
|
|
402
501
|
prev.onSelect === next.onSelect &&
|
|
403
502
|
prev.onDeselect === next.onDeselect &&
|
|
@@ -532,6 +631,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
532
631
|
const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
|
|
533
632
|
const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
|
|
534
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
|
+
|
|
535
639
|
// Refs for snap settings (used by drop handler inside effect closure)
|
|
536
640
|
const snapEnabledRef = useRef(snapEnabled)
|
|
537
641
|
const snapGridSizeRef = useRef(snapGridSize)
|
|
@@ -571,12 +675,42 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
571
675
|
stateRef.current = { widgets: localWidgets, sources: localSources, connectors: localConnectors }
|
|
572
676
|
}, [localWidgets, localSources, localConnectors])
|
|
573
677
|
|
|
678
|
+
// Dirty flag — true while optimistic edits haven't been persisted yet.
|
|
679
|
+
// Prevents HMR echoes from overwriting in-flight local state.
|
|
680
|
+
const dirtyRef = useRef(false)
|
|
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
|
+
|
|
574
691
|
// Serialized write queue — ensures JSONL events land in the right order
|
|
575
692
|
const writeQueueRef = useRef(Promise.resolve())
|
|
576
693
|
function queueWrite(fn) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
+
})
|
|
580
714
|
return writeQueueRef.current
|
|
581
715
|
}
|
|
582
716
|
|
|
@@ -663,14 +797,23 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
663
797
|
|
|
664
798
|
if (canvas !== trackedCanvas) {
|
|
665
799
|
const isCanvasSwitch = trackedCanvas && canvas && trackedCanvas._route !== canvas._route
|
|
666
|
-
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')
|
|
667
801
|
setTrackedCanvas(canvas)
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
802
|
+
|
|
803
|
+
// Skip replacing local state with server data when optimistic edits are
|
|
804
|
+
// pending — the local state is more recent. The next save will persist it
|
|
805
|
+
// and the subsequent server push (after dirty clears) will reconcile.
|
|
806
|
+
if (!dirtyRef.current || isCanvasSwitch) {
|
|
807
|
+
setLocalWidgets(canvas?.widgets ?? null)
|
|
808
|
+
setLocalConnectors(canvas?.connectors ?? [])
|
|
809
|
+
setLocalSources(canvas?.sources ?? [])
|
|
810
|
+
}
|
|
811
|
+
|
|
671
812
|
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
672
813
|
setSnapGridSize(canvas?.gridSize || 40)
|
|
673
|
-
|
|
814
|
+
if (isCanvasSwitch) {
|
|
815
|
+
undoRedo.reset()
|
|
816
|
+
}
|
|
674
817
|
// Only reset viewport state when switching to a different canvas,
|
|
675
818
|
// not when the same canvas refreshes with server data.
|
|
676
819
|
if (isCanvasSwitch) {
|
|
@@ -683,11 +826,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
683
826
|
}
|
|
684
827
|
}
|
|
685
828
|
|
|
686
|
-
// Debounced save to server
|
|
829
|
+
// Debounced save to server — routed through queueWrite to serialize
|
|
830
|
+
// with deletes and other writes, preventing stale data from overwriting.
|
|
687
831
|
const debouncedSave = useRef(
|
|
688
832
|
debounce((canvasId, widgets) => {
|
|
689
|
-
|
|
690
|
-
|
|
833
|
+
queueWrite(() =>
|
|
834
|
+
updateCanvas(canvasId, { widgets })
|
|
835
|
+
.catch((err) => console.error('[canvas] Failed to save:', err))
|
|
691
836
|
)
|
|
692
837
|
}, 2000)
|
|
693
838
|
).current
|
|
@@ -705,12 +850,17 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
705
850
|
const next = prev.map((w) =>
|
|
706
851
|
w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
|
|
707
852
|
)
|
|
853
|
+
dirtyRef.current = true
|
|
708
854
|
debouncedSave(canvasId, next)
|
|
709
855
|
return next
|
|
710
856
|
})
|
|
711
857
|
}, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
|
|
712
858
|
|
|
713
859
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
860
|
+
// Cancel any pending debounced save — it may contain stale data
|
|
861
|
+
// that includes the widget we're about to delete
|
|
862
|
+
debouncedSave.cancel()
|
|
863
|
+
|
|
714
864
|
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
715
865
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
716
866
|
// Cascade: remove connectors referencing this widget
|
|
@@ -726,12 +876,12 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
726
876
|
}
|
|
727
877
|
return prev.filter((c) => c.start.widgetId !== widgetId && c.end.widgetId !== widgetId)
|
|
728
878
|
})
|
|
879
|
+
dirtyRef.current = true
|
|
729
880
|
queueWrite(() =>
|
|
730
|
-
removeWidgetApi(canvasId, widgetId)
|
|
731
|
-
console.error('[canvas] Failed to remove widget:', err)
|
|
732
|
-
)
|
|
881
|
+
removeWidgetApi(canvasId, widgetId)
|
|
882
|
+
.catch((err) => console.error('[canvas] Failed to remove widget:', err))
|
|
733
883
|
)
|
|
734
|
-
}, [canvasId, undoRedo])
|
|
884
|
+
}, [canvasId, undoRedo, debouncedSave])
|
|
735
885
|
|
|
736
886
|
const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
|
|
737
887
|
try {
|
|
@@ -748,6 +898,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
748
898
|
const handleConnectorRemove = useCallback((connectorId) => {
|
|
749
899
|
undoRedo.snapshot(stateRef.current, 'connector-remove')
|
|
750
900
|
setLocalConnectors((prev) => prev.filter((c) => c.id !== connectorId))
|
|
901
|
+
dirtyRef.current = true
|
|
751
902
|
queueWrite(() =>
|
|
752
903
|
removeConnectorApi(canvasId, connectorId).catch((err) =>
|
|
753
904
|
console.error('[canvas] Failed to remove connector:', err)
|
|
@@ -802,21 +953,52 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
802
953
|
y: (scrollEl.scrollTop + clientY - rect.top) / scale,
|
|
803
954
|
})
|
|
804
955
|
|
|
805
|
-
// Find nearest anchor on any other widget within snap
|
|
806
|
-
|
|
956
|
+
// Find nearest anchor on any other widget within a rectangular snap zone.
|
|
957
|
+
// Each anchor has a 30px-wide strip (15px each side) extending from the widget edge.
|
|
958
|
+
const SNAP_EXTEND = 15
|
|
959
|
+
const SNAP_DEPTH = 40
|
|
960
|
+
const SNAP_CROSS = 20 // perpendicular expansion so you can approach from any direction
|
|
807
961
|
const sourceType = startWidget.type
|
|
808
962
|
const findNearestAnchor = (canvasPt) => {
|
|
809
963
|
const currentWidgets = stateRef.current.widgets ?? []
|
|
810
964
|
let best = null
|
|
811
|
-
let bestDist =
|
|
965
|
+
let bestDist = Infinity
|
|
812
966
|
for (const w of currentWidgets) {
|
|
813
967
|
if (w.id === widgetId) continue
|
|
814
|
-
// Check if this widget type accepts connections from the source type
|
|
815
968
|
if (!canAcceptConnection(w.type, sourceType)) continue
|
|
969
|
+
|
|
970
|
+
let ww, wh
|
|
971
|
+
const el = document.getElementById(w.id)
|
|
972
|
+
if (el) {
|
|
973
|
+
const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
|
|
974
|
+
if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
|
|
975
|
+
}
|
|
976
|
+
if (!ww) ww = w.props?.width ?? w.bounds?.width ?? 270
|
|
977
|
+
if (!wh) wh = w.props?.height ?? w.bounds?.height ?? 170
|
|
978
|
+
const wx = w.position?.x ?? 0
|
|
979
|
+
const wy = w.position?.y ?? 0
|
|
980
|
+
|
|
816
981
|
for (const anch of ['top', 'bottom', 'left', 'right']) {
|
|
817
|
-
// Skip unavailable or disabled anchors
|
|
818
982
|
const anchorState = getAnchorState(w.type, anch)
|
|
819
983
|
if (anchorState !== 'available') continue
|
|
984
|
+
|
|
985
|
+
// Build a rectangular hit zone for this anchor
|
|
986
|
+
let inZone = false
|
|
987
|
+
if (anch === 'top') {
|
|
988
|
+
inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
|
|
989
|
+
canvasPt.y >= wy - SNAP_DEPTH && canvasPt.y <= wy + SNAP_EXTEND
|
|
990
|
+
} else if (anch === 'bottom') {
|
|
991
|
+
inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
|
|
992
|
+
canvasPt.y >= wy + wh - SNAP_EXTEND && canvasPt.y <= wy + wh + SNAP_DEPTH
|
|
993
|
+
} else if (anch === 'left') {
|
|
994
|
+
inZone = canvasPt.x >= wx - SNAP_DEPTH && canvasPt.x <= wx + SNAP_EXTEND &&
|
|
995
|
+
canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
|
|
996
|
+
} else if (anch === 'right') {
|
|
997
|
+
inZone = canvasPt.x >= wx + ww - SNAP_EXTEND && canvasPt.x <= wx + ww + SNAP_DEPTH &&
|
|
998
|
+
canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
|
|
999
|
+
}
|
|
1000
|
+
if (!inZone) continue
|
|
1001
|
+
|
|
820
1002
|
const pt = computeAnchorPt(w, anch)
|
|
821
1003
|
const dist = Math.hypot(pt.x - canvasPt.x, pt.y - canvasPt.y)
|
|
822
1004
|
if (dist < bestDist) {
|
|
@@ -872,147 +1054,334 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
872
1054
|
document.addEventListener('pointerup', handlePointerUp)
|
|
873
1055
|
}, [handleConnectorAdd])
|
|
874
1056
|
|
|
875
|
-
//
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
e.preventDefault()
|
|
879
|
-
const scrollEl = scrollRef.current
|
|
880
|
-
if (!scrollEl) return
|
|
881
|
-
const scale = zoomRef.current / 100
|
|
882
|
-
const rect = scrollEl.getBoundingClientRect()
|
|
883
|
-
|
|
884
|
-
// The fixed end stays put; the dragged end follows cursor
|
|
885
|
-
const fixedEnd = endpoint === 'start' ? 'end' : 'start'
|
|
886
|
-
const fixedSide = connector[fixedEnd]
|
|
887
|
-
const fixedWidget = (stateRef.current.widgets ?? []).find((w) => w.id === fixedSide.widgetId)
|
|
888
|
-
if (!fixedWidget) return
|
|
1057
|
+
// Endpoint drag removed — dragging from a filled anchor now always
|
|
1058
|
+
// creates a new connection via handleConnectorDragStart instead of
|
|
1059
|
+
// repositioning the existing one.
|
|
889
1060
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1061
|
+
const handleWidgetCopy = useCallback(async (widget) => {
|
|
1062
|
+
// Find the next free offset — check how many copies already exist at +n*40
|
|
1063
|
+
const baseX = widget.position?.x ?? 0
|
|
1064
|
+
const baseY = widget.position?.y ?? 0
|
|
1065
|
+
const occupied = new Set(
|
|
1066
|
+
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
1067
|
+
)
|
|
1068
|
+
let n = 1
|
|
1069
|
+
while (occupied.has(`${baseX + n * 40},${baseY + n * 40}`)) {
|
|
1070
|
+
n++
|
|
1071
|
+
}
|
|
1072
|
+
const position = { x: baseX + n * 40, y: baseY + n * 40 }
|
|
1073
|
+
const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
|
|
1074
|
+
try {
|
|
1075
|
+
const copyProps = { ...widget.props }
|
|
1076
|
+
// Terminal widgets must get unique names — strip prettyName so the server generates a fresh one
|
|
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
|
|
896
1082
|
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1083
|
+
|
|
1084
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1085
|
+
const result = await addWidgetApi(canvasId, {
|
|
1086
|
+
type: widget.type,
|
|
1087
|
+
props: copyProps,
|
|
1088
|
+
position,
|
|
1089
|
+
})
|
|
1090
|
+
if (result.success && result.widget) {
|
|
1091
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1092
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
907
1093
|
}
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
console.error('[canvas] Failed to copy widget:', err)
|
|
908
1096
|
}
|
|
1097
|
+
}, [canvasId, localWidgets, undoRedo])
|
|
909
1098
|
|
|
910
|
-
|
|
911
|
-
|
|
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]
|
|
912
1103
|
|
|
913
|
-
|
|
914
|
-
x: (scrollEl.scrollLeft + clientX - rect.left) / scale,
|
|
915
|
-
y: (scrollEl.scrollTop + clientY - rect.top) / scale,
|
|
916
|
-
})
|
|
1104
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
917
1105
|
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
|
935
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
|
+
})
|
|
936
1187
|
}
|
|
937
1188
|
}
|
|
938
|
-
return best
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
const cursorPt = toCanvasPoint(e.clientX, e.clientY)
|
|
942
|
-
const snap = findNearestAnchorLocal(cursorPt)
|
|
943
|
-
setConnectorDrag({
|
|
944
|
-
startWidgetId: fixedWidgetId,
|
|
945
|
-
startAnchor: fixedSide.anchor,
|
|
946
|
-
startPt: fixedPt,
|
|
947
|
-
endPt: snap ? snap.pt : cursorPt,
|
|
948
|
-
endAnchor: snap ? snap.anchor : fixedSide.anchor,
|
|
949
|
-
snapTarget: snap,
|
|
950
|
-
})
|
|
951
1189
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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)
|
|
961
1199
|
}
|
|
1200
|
+
}, [canvasId, localWidgets, localConnectors, undoRedo])
|
|
962
1201
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
|
966
1206
|
|
|
967
|
-
|
|
968
|
-
|
|
1207
|
+
// Single undo snapshot for the entire batch
|
|
1208
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
969
1209
|
|
|
970
|
-
|
|
971
|
-
|
|
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++
|
|
972
1221
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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,
|
|
980
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)
|
|
981
1248
|
}
|
|
982
|
-
setConnectorDrag(null)
|
|
983
1249
|
}
|
|
984
1250
|
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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])
|
|
988
1256
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
|
993
1267
|
const occupied = new Set(
|
|
994
1268
|
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
995
1269
|
)
|
|
996
|
-
let
|
|
997
|
-
|
|
998
|
-
|
|
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
|
+
}
|
|
999
1287
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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}`,
|
|
1004
1308
|
type: widget.type,
|
|
1005
|
-
props:
|
|
1006
|
-
position
|
|
1309
|
+
props: copyProps,
|
|
1310
|
+
position: {
|
|
1311
|
+
x: (widget.position?.x ?? 0) + offset * 40,
|
|
1312
|
+
y: (widget.position?.y ?? 0) + offset * 40,
|
|
1313
|
+
},
|
|
1007
1314
|
})
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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])
|
|
1011
1374
|
}
|
|
1012
1375
|
} catch (err) {
|
|
1013
|
-
console.error('[canvas] Failed to
|
|
1376
|
+
console.error('[canvas] Failed to duplicate with connectors:', err)
|
|
1014
1377
|
}
|
|
1015
|
-
}, [canvasId, localWidgets, undoRedo])
|
|
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])
|
|
1016
1385
|
|
|
1017
1386
|
const showMissingGhBanner = useCallback(() => {
|
|
1018
1387
|
setShowGhInstallBanner(true)
|
|
@@ -1068,8 +1437,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1068
1437
|
|
|
1069
1438
|
const debouncedSourceSave = useRef(
|
|
1070
1439
|
debounce((canvasId, sources) => {
|
|
1071
|
-
|
|
1072
|
-
|
|
1440
|
+
queueWrite(() =>
|
|
1441
|
+
updateCanvas(canvasId, { sources }).catch((err) =>
|
|
1442
|
+
console.error('[canvas] Failed to save sources:', err)
|
|
1443
|
+
)
|
|
1073
1444
|
)
|
|
1074
1445
|
}, 2000)
|
|
1075
1446
|
).current
|
|
@@ -1086,6 +1457,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1086
1457
|
const next = current.some((s) => s?.export === exportName)
|
|
1087
1458
|
? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
|
|
1088
1459
|
: [...current, { export: exportName, ...snapped }]
|
|
1460
|
+
dirtyRef.current = true
|
|
1089
1461
|
debouncedSourceSave(canvasId, next)
|
|
1090
1462
|
return next
|
|
1091
1463
|
})
|
|
@@ -1141,10 +1513,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1141
1513
|
}
|
|
1142
1514
|
return w
|
|
1143
1515
|
})
|
|
1516
|
+
dirtyRef.current = true
|
|
1144
1517
|
queueWrite(() =>
|
|
1145
|
-
updateCanvas(canvasId, { widgets: next })
|
|
1146
|
-
console.error('[canvas] Failed to save multi-move:', err)
|
|
1147
|
-
)
|
|
1518
|
+
updateCanvas(canvasId, { widgets: next })
|
|
1519
|
+
.catch((err) => console.error('[canvas] Failed to save multi-move:', err))
|
|
1148
1520
|
)
|
|
1149
1521
|
return next
|
|
1150
1522
|
})
|
|
@@ -1173,10 +1545,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1173
1545
|
return s
|
|
1174
1546
|
})
|
|
1175
1547
|
if (changed) {
|
|
1548
|
+
dirtyRef.current = true
|
|
1176
1549
|
queueWrite(() =>
|
|
1177
|
-
updateCanvas(canvasId, { sources: next })
|
|
1178
|
-
console.error('[canvas] Failed to save multi-move sources:', err)
|
|
1179
|
-
)
|
|
1550
|
+
updateCanvas(canvasId, { sources: next })
|
|
1551
|
+
.catch((err) => console.error('[canvas] Failed to save multi-move sources:', err))
|
|
1180
1552
|
)
|
|
1181
1553
|
}
|
|
1182
1554
|
return changed ? next : current
|
|
@@ -1192,10 +1564,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1192
1564
|
const next = current.some((s) => s?.export === sourceExport)
|
|
1193
1565
|
? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
|
|
1194
1566
|
: [...current, { export: sourceExport, position: rounded }]
|
|
1567
|
+
dirtyRef.current = true
|
|
1195
1568
|
queueWrite(() =>
|
|
1196
|
-
updateCanvas(canvasId, { sources: next })
|
|
1197
|
-
console.error('[canvas] Failed to save source position:', err)
|
|
1198
|
-
)
|
|
1569
|
+
updateCanvas(canvasId, { sources: next })
|
|
1570
|
+
.catch((err) => console.error('[canvas] Failed to save source position:', err))
|
|
1199
1571
|
)
|
|
1200
1572
|
return next
|
|
1201
1573
|
})
|
|
@@ -1203,15 +1575,16 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1203
1575
|
}
|
|
1204
1576
|
|
|
1205
1577
|
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
1578
|
+
debouncedSave.cancel()
|
|
1206
1579
|
setLocalWidgets((prev) => {
|
|
1207
1580
|
if (!prev) return prev
|
|
1208
1581
|
const next = prev.map((w) =>
|
|
1209
1582
|
w.id === dragId ? { ...w, position: rounded } : w
|
|
1210
1583
|
)
|
|
1584
|
+
dirtyRef.current = true
|
|
1211
1585
|
queueWrite(() =>
|
|
1212
|
-
updateCanvas(canvasId, { widgets: next })
|
|
1213
|
-
console.error('[canvas] Failed to save widget position:', err)
|
|
1214
|
-
)
|
|
1586
|
+
updateCanvas(canvasId, { widgets: next })
|
|
1587
|
+
.catch((err) => console.error('[canvas] Failed to save widget position:', err))
|
|
1215
1588
|
)
|
|
1216
1589
|
return next
|
|
1217
1590
|
})
|
|
@@ -1236,20 +1609,21 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1236
1609
|
if (!el || loading) return
|
|
1237
1610
|
const saved = pendingScrollRestore.current
|
|
1238
1611
|
if (saved) {
|
|
1239
|
-
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)
|
|
1240
1613
|
// Fresh saved viewport — restore exactly
|
|
1241
1614
|
if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
|
|
1242
1615
|
if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
|
|
1243
1616
|
pendingScrollRestore.current = null
|
|
1244
1617
|
} else {
|
|
1245
|
-
console.log('[viewport] no saved viewport — fitting to objects')
|
|
1618
|
+
if (getFlag('dev-logs')) console.log('[viewport] no saved viewport — fitting to objects')
|
|
1246
1619
|
// No saved state or stale — zoom-to-fit all objects
|
|
1247
1620
|
const bounds = computeCanvasBounds(localWidgets, componentEntries)
|
|
1248
1621
|
if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
|
|
1249
1622
|
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
1250
1623
|
const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
|
|
1251
1624
|
const fitScale = Math.min(el.clientWidth / boxW, el.clientHeight / boxH)
|
|
1252
|
-
const
|
|
1625
|
+
const { ZOOM_MIN: zMin, ZOOM_MAX: zMax } = zoomLimits()
|
|
1626
|
+
const fitZoom = Math.min(zMax, Math.max(zMin, Math.round(fitScale * 100)))
|
|
1253
1627
|
const newScale = fitZoom / 100
|
|
1254
1628
|
zoomRef.current = fitZoom
|
|
1255
1629
|
// Imperative DOM update for initial zoom-to-fit — same path as applyZoom
|
|
@@ -1326,7 +1700,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1326
1700
|
useEffect(() => {
|
|
1327
1701
|
if (viewportInitName.current !== canvasId) return
|
|
1328
1702
|
const el = scrollRef.current
|
|
1329
|
-
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)
|
|
1330
1704
|
// Read current scroll so the zoom entry doesn't zero-out position,
|
|
1331
1705
|
// but the authoritative scroll save comes from the scroll handler.
|
|
1332
1706
|
saveViewportState(canvasId, {
|
|
@@ -1371,6 +1745,56 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1371
1745
|
}
|
|
1372
1746
|
}, [canvasId, loading])
|
|
1373
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
|
+
|
|
1374
1798
|
/**
|
|
1375
1799
|
* Zoom to a new level, anchoring on an optional client-space point.
|
|
1376
1800
|
* When a cursor position is provided (e.g. from a wheel event), the
|
|
@@ -1385,6 +1809,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1385
1809
|
function applyZoom(newZoom, clientX, clientY) {
|
|
1386
1810
|
const el = scrollRef.current
|
|
1387
1811
|
const zoomEl = zoomElRef.current
|
|
1812
|
+
const { ZOOM_MIN, ZOOM_MAX } = zoomLimits()
|
|
1388
1813
|
const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
|
|
1389
1814
|
|
|
1390
1815
|
if (!el || !zoomEl) {
|
|
@@ -1431,7 +1856,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1431
1856
|
if (!zoomEventTimer.current) {
|
|
1432
1857
|
zoomEventTimer.current = setTimeout(() => {
|
|
1433
1858
|
zoomEventTimer.current = null
|
|
1434
|
-
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
|
|
1435
1864
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
1436
1865
|
detail: { zoom: zoomRef.current }
|
|
1437
1866
|
}))
|
|
@@ -1441,7 +1870,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1441
1870
|
|
|
1442
1871
|
// Signal canvas mount/unmount to CoreUIBar
|
|
1443
1872
|
useEffect(() => {
|
|
1444
|
-
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
|
|
1445
1878
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
|
|
1446
1879
|
detail: { canvasId, zoom: zoomRef.current }
|
|
1447
1880
|
}))
|
|
@@ -1461,14 +1894,15 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1461
1894
|
}, [canvasId])
|
|
1462
1895
|
|
|
1463
1896
|
// Tell the Vite dev server to suppress full-reloads while this canvas is active.
|
|
1464
|
-
//
|
|
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.
|
|
1465
1899
|
// Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
|
|
1466
1900
|
useEffect(() => {
|
|
1467
1901
|
if (!import.meta.hot) return
|
|
1468
|
-
const
|
|
1469
|
-
if (
|
|
1902
|
+
const autoReload = getFlag('canvas-auto-reload')
|
|
1903
|
+
if (autoReload) return
|
|
1470
1904
|
|
|
1471
|
-
const msg = { active: true
|
|
1905
|
+
const msg = { active: true }
|
|
1472
1906
|
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
1473
1907
|
const interval = setInterval(() => {
|
|
1474
1908
|
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
@@ -1476,7 +1910,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1476
1910
|
|
|
1477
1911
|
return () => {
|
|
1478
1912
|
clearInterval(interval)
|
|
1479
|
-
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false
|
|
1913
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false })
|
|
1480
1914
|
}
|
|
1481
1915
|
}, [canvasId])
|
|
1482
1916
|
|
|
@@ -1510,7 +1944,8 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1510
1944
|
|
|
1511
1945
|
function sendFocus() {
|
|
1512
1946
|
const { widgetIds, widgets } = getSelectedWidgetData()
|
|
1513
|
-
|
|
1947
|
+
const viewport = getViewportData()
|
|
1948
|
+
import.meta.hot.send('storyboard:canvas-focused', { tabId, canvasId, widgetIds, widgets, viewport })
|
|
1514
1949
|
}
|
|
1515
1950
|
|
|
1516
1951
|
sendFocus()
|
|
@@ -1537,21 +1972,29 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1537
1972
|
const tabId = selectionTabIdRef.current
|
|
1538
1973
|
const timer = setTimeout(() => {
|
|
1539
1974
|
const { widgetIds, widgets } = getSelectedWidgetData()
|
|
1540
|
-
|
|
1975
|
+
const viewport = getViewportData()
|
|
1976
|
+
import.meta.hot.send('storyboard:selection-changed', { tabId, canvasId, widgetIds: widgetIds, widgets, viewport })
|
|
1541
1977
|
}, 500)
|
|
1542
1978
|
|
|
1543
1979
|
return () => clearTimeout(timer)
|
|
1544
1980
|
}, [selectedWidgetIds, canvasId, getSelectedWidgetData])
|
|
1545
1981
|
|
|
1546
1982
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
1547
|
-
const addWidget = useCallback(async (type) => {
|
|
1983
|
+
const addWidget = useCallback(async (type, extraProps = {}) => {
|
|
1548
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
|
+
}
|
|
1991
|
+
const mergedProps = { ...defaultProps, ...extraProps }
|
|
1549
1992
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1550
|
-
const pos = centerPositionForWidget(center, type,
|
|
1993
|
+
const pos = centerPositionForWidget(center, type, mergedProps)
|
|
1551
1994
|
try {
|
|
1552
1995
|
const result = await addWidgetApi(canvasId, {
|
|
1553
1996
|
type,
|
|
1554
|
-
props:
|
|
1997
|
+
props: mergedProps,
|
|
1555
1998
|
position: pos,
|
|
1556
1999
|
})
|
|
1557
2000
|
if (result.success && result.widget) {
|
|
@@ -1585,21 +2028,27 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1585
2028
|
}
|
|
1586
2029
|
}, [canvasId, undoRedo])
|
|
1587
2030
|
|
|
1588
|
-
// Listen for CoreUIBar add-widget events
|
|
2031
|
+
// Listen for CoreUIBar add-widget and update-widget events
|
|
1589
2032
|
useEffect(() => {
|
|
1590
2033
|
function handleAddWidget(e) {
|
|
1591
|
-
addWidget(e.detail.type)
|
|
2034
|
+
addWidget(e.detail.type, e.detail.props)
|
|
1592
2035
|
}
|
|
1593
2036
|
function handleAddStoryWidget(e) {
|
|
1594
2037
|
addStoryWidget(e.detail.storyId)
|
|
1595
2038
|
}
|
|
2039
|
+
function handleUpdateWidget(e) {
|
|
2040
|
+
const { widgetId, updates } = e.detail || {}
|
|
2041
|
+
if (widgetId && updates) handleWidgetUpdate(widgetId, updates)
|
|
2042
|
+
}
|
|
1596
2043
|
document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
1597
2044
|
document.addEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
|
|
2045
|
+
document.addEventListener('storyboard:canvas:update-widget', handleUpdateWidget)
|
|
1598
2046
|
return () => {
|
|
1599
2047
|
document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
1600
2048
|
document.removeEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
|
|
2049
|
+
document.removeEventListener('storyboard:canvas:update-widget', handleUpdateWidget)
|
|
1601
2050
|
}
|
|
1602
|
-
}, [addWidget, addStoryWidget])
|
|
2051
|
+
}, [addWidget, addStoryWidget, handleWidgetUpdate])
|
|
1603
2052
|
|
|
1604
2053
|
// Listen for zoom changes from CoreUIBar
|
|
1605
2054
|
useEffect(() => {
|
|
@@ -1679,7 +2128,8 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1679
2128
|
|
|
1680
2129
|
// Find the zoom level that fits the bounding box in the viewport
|
|
1681
2130
|
const fitScale = Math.min(viewW / boxW, viewH / boxH)
|
|
1682
|
-
const
|
|
2131
|
+
const { ZOOM_MIN: zMin, ZOOM_MAX: zMax } = zoomLimits()
|
|
2132
|
+
const fitZoom = Math.min(zMax, Math.max(zMin, Math.round(fitScale * 100)))
|
|
1683
2133
|
const newScale = fitZoom / 100
|
|
1684
2134
|
|
|
1685
2135
|
// Imperative DOM update — same path as applyZoom
|
|
@@ -1722,12 +2172,26 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1722
2172
|
|
|
1723
2173
|
// Broadcast zoom level to CoreUIBar whenever it changes
|
|
1724
2174
|
useEffect(() => {
|
|
1725
|
-
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
|
|
1726
2180
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
1727
2181
|
detail: { zoom }
|
|
1728
2182
|
}))
|
|
1729
2183
|
}, [canvasId, zoom])
|
|
1730
2184
|
|
|
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(() => {
|
|
2189
|
+
const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
|
|
2190
|
+
bridge.widgets = localWidgets
|
|
2191
|
+
bridge.connectors = localConnectors
|
|
2192
|
+
window[CANVAS_BRIDGE_STATE_KEY] = bridge
|
|
2193
|
+
}, [localWidgets, localConnectors])
|
|
2194
|
+
|
|
1731
2195
|
// Delete selected widget on Delete/Backspace key
|
|
1732
2196
|
useEffect(() => {
|
|
1733
2197
|
function handleSelectStart(e) {
|
|
@@ -1765,6 +2229,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1765
2229
|
// Multi-delete — snapshot once, remove all, persist via updateCanvas
|
|
1766
2230
|
undoRedo.snapshot(stateRef.current, 'multi-remove')
|
|
1767
2231
|
debouncedSave.cancel()
|
|
2232
|
+
dirtyRef.current = true
|
|
1768
2233
|
setLocalWidgets((prev) => {
|
|
1769
2234
|
if (!prev) return prev
|
|
1770
2235
|
const next = prev.filter(w => !selectedWidgetIds.has(w.id))
|
|
@@ -1857,6 +2322,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1857
2322
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1858
2323
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1859
2324
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
2325
|
+
navigator.clipboard?.writeText(result.widget.id).catch(() => {})
|
|
1860
2326
|
}
|
|
1861
2327
|
return true
|
|
1862
2328
|
} catch (err) {
|
|
@@ -1948,9 +2414,18 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1948
2414
|
for (const w of sourceWidgets) {
|
|
1949
2415
|
const relX = (w.position?.x ?? 0) - minX
|
|
1950
2416
|
const relY = (w.position?.y ?? 0) - minY
|
|
2417
|
+
const pasteProps = { ...w.props }
|
|
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
|
+
}
|
|
1951
2426
|
const result = await addWidgetApi(canvasId, {
|
|
1952
2427
|
type: w.type,
|
|
1953
|
-
props:
|
|
2428
|
+
props: pasteProps,
|
|
1954
2429
|
position: { x: baseX + relX, y: baseY + relY },
|
|
1955
2430
|
})
|
|
1956
2431
|
if (result.success && result.widget) {
|
|
@@ -1970,11 +2445,34 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1970
2445
|
}
|
|
1971
2446
|
|
|
1972
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) {
|
|
1973
2454
|
const resolved = resolvePaste(text, pasteCtx, getPasteRules())
|
|
1974
2455
|
if (!resolved) return
|
|
1975
|
-
|
|
2456
|
+
let { type } = resolved
|
|
1976
2457
|
let props = resolved.props
|
|
1977
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
|
+
|
|
1978
2476
|
if (type === 'link-preview' && isGitHubEmbedUrl(props?.url || text)) {
|
|
1979
2477
|
const githubUpdates = await buildGitHubPreviewUpdates(props?.url || text)
|
|
1980
2478
|
if (githubUpdates) props = { ...props, ...githubUpdates }
|
|
@@ -1998,8 +2496,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1998
2496
|
}
|
|
1999
2497
|
}
|
|
2000
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
|
+
|
|
2001
2506
|
document.addEventListener('paste', handlePaste)
|
|
2002
|
-
|
|
2507
|
+
document.addEventListener('storyboard:canvas:paste-url', handlePasteUrl)
|
|
2508
|
+
return () => {
|
|
2509
|
+
document.removeEventListener('paste', handlePaste)
|
|
2510
|
+
document.removeEventListener('storyboard:canvas:paste-url', handlePasteUrl)
|
|
2511
|
+
}
|
|
2003
2512
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2004
2513
|
}, [canvasId, undoRedo, localWidgets])
|
|
2005
2514
|
|
|
@@ -2073,13 +2582,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2073
2582
|
if (!previous) return
|
|
2074
2583
|
debouncedSave.cancel()
|
|
2075
2584
|
debouncedSourceSave.cancel()
|
|
2585
|
+
dirtyRef.current = true
|
|
2076
2586
|
setLocalWidgets(previous.widgets)
|
|
2077
2587
|
setLocalSources(previous.sources)
|
|
2078
2588
|
setLocalConnectors(previous.connectors ?? [])
|
|
2079
2589
|
queueWrite(() =>
|
|
2080
|
-
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors })
|
|
2081
|
-
console.error('[canvas] Failed to persist undo:', err)
|
|
2082
|
-
)
|
|
2590
|
+
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors })
|
|
2591
|
+
.catch((err) => console.error('[canvas] Failed to persist undo:', err))
|
|
2083
2592
|
)
|
|
2084
2593
|
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
2085
2594
|
|
|
@@ -2088,22 +2597,24 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2088
2597
|
if (!next) return
|
|
2089
2598
|
debouncedSave.cancel()
|
|
2090
2599
|
debouncedSourceSave.cancel()
|
|
2600
|
+
dirtyRef.current = true
|
|
2091
2601
|
setLocalWidgets(next.widgets)
|
|
2092
2602
|
setLocalSources(next.sources)
|
|
2093
2603
|
setLocalConnectors(next.connectors ?? [])
|
|
2094
2604
|
queueWrite(() =>
|
|
2095
|
-
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors })
|
|
2096
|
-
console.error('[canvas] Failed to persist redo:', err)
|
|
2097
|
-
)
|
|
2605
|
+
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors })
|
|
2606
|
+
.catch((err) => console.error('[canvas] Failed to persist redo:', err))
|
|
2098
2607
|
)
|
|
2099
2608
|
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
2100
2609
|
|
|
2101
|
-
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
|
|
2610
|
+
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z / Cmd+D / Cmd+A)
|
|
2102
2611
|
useEffect(() => {
|
|
2103
2612
|
if (!import.meta.hot) return
|
|
2104
2613
|
function handleKeyDown(e) {
|
|
2105
2614
|
const tag = e.target.tagName
|
|
2106
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
|
|
2107
2618
|
const mod = e.metaKey || e.ctrlKey
|
|
2108
2619
|
if (mod && e.key === 'z' && !e.shiftKey) {
|
|
2109
2620
|
e.preventDefault()
|
|
@@ -2113,10 +2624,21 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2113
2624
|
e.preventDefault()
|
|
2114
2625
|
handleRedo()
|
|
2115
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
|
+
}
|
|
2116
2638
|
}
|
|
2117
2639
|
document.addEventListener('keydown', handleKeyDown)
|
|
2118
2640
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
2119
|
-
}, [handleUndo, handleRedo])
|
|
2641
|
+
}, [handleUndo, handleRedo, handleDuplicateSelected, handleDuplicateWithConnectors, handleSelectAll])
|
|
2120
2642
|
|
|
2121
2643
|
// Listen for undo/redo from CoreUIBar (Svelte toolbar)
|
|
2122
2644
|
useEffect(() => {
|
|
@@ -2286,6 +2808,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2286
2808
|
zoomRef: zoomRef,
|
|
2287
2809
|
setSelectedWidgetIds,
|
|
2288
2810
|
widgets: localWidgets,
|
|
2811
|
+
connectors: localConnectors,
|
|
2289
2812
|
componentEntries,
|
|
2290
2813
|
fallbackSizes: WIDGET_FALLBACK_SIZES,
|
|
2291
2814
|
spaceHeld,
|
|
@@ -2343,6 +2866,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2343
2866
|
id={`jsx-${exportName}`}
|
|
2344
2867
|
data-tc-x={sourcePosition.x}
|
|
2345
2868
|
data-tc-y={sourcePosition.y}
|
|
2869
|
+
data-widget-raised={selectedWidgetIds.has(`jsx-${exportName}`) || undefined}
|
|
2346
2870
|
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
2347
2871
|
{...canvasPrimerAttrs}
|
|
2348
2872
|
style={canvasThemeVars}
|
|
@@ -2379,38 +2903,44 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2379
2903
|
}
|
|
2380
2904
|
|
|
2381
2905
|
// 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
|
|
2382
|
-
//
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
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 ?? [])) {
|
|
2912
|
+
// In production, render terminal widgets as read-only instead of hiding them
|
|
2913
|
+
const effectiveWidget = (!isLocalDev && (widget.type === 'terminal' || widget.type === 'agent'))
|
|
2914
|
+
? { ...widget, type: 'terminal-read' }
|
|
2915
|
+
: widget
|
|
2390
2916
|
allChildren.push(
|
|
2391
2917
|
<div
|
|
2392
|
-
key={
|
|
2393
|
-
id={
|
|
2394
|
-
data-tc-x={
|
|
2395
|
-
data-tc-y={
|
|
2918
|
+
key={effectiveWidget.id}
|
|
2919
|
+
id={effectiveWidget.id}
|
|
2920
|
+
data-tc-x={effectiveWidget?.position?.x ?? 0}
|
|
2921
|
+
data-tc-y={effectiveWidget?.position?.y ?? 0}
|
|
2922
|
+
data-widget-raised={selectedWidgetIds.has(widget.id) || undefined}
|
|
2396
2923
|
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
2397
2924
|
{...canvasPrimerAttrs}
|
|
2398
2925
|
style={canvasThemeVars}
|
|
2399
2926
|
onClick={isLocalDev ? (e) => {
|
|
2400
2927
|
e.stopPropagation()
|
|
2401
2928
|
if (!e.target.closest('.tc-drag-handle')) {
|
|
2402
|
-
handleWidgetSelect(
|
|
2929
|
+
handleWidgetSelect(effectiveWidget.id, e.shiftKey)
|
|
2403
2930
|
}
|
|
2404
2931
|
} : undefined}
|
|
2405
2932
|
>
|
|
2406
2933
|
<ChromeWrappedWidget
|
|
2407
|
-
widget={
|
|
2934
|
+
widget={effectiveWidget}
|
|
2408
2935
|
selected={selectedWidgetIds.has(widget.id)}
|
|
2409
2936
|
multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
|
|
2937
|
+
connectorCount={localConnectors.filter((c) => c.start?.widgetId === widget.id || c.end?.widgetId === widget.id)}
|
|
2938
|
+
allWidgets={localWidgets}
|
|
2410
2939
|
onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
|
|
2411
2940
|
onDeselect={handleDeselectAll}
|
|
2412
2941
|
onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
|
|
2413
2942
|
onCopy={isLocalDev ? handleWidgetCopy : undefined}
|
|
2943
|
+
onCopyWithConnectors={isLocalDev ? handleWidgetCopyWithConnectors : undefined}
|
|
2414
2944
|
onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
|
|
2415
2945
|
onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
|
|
2416
2946
|
canRefreshGitHub={isLocalDev}
|
|
@@ -2423,19 +2953,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2423
2953
|
|
|
2424
2954
|
const scale = zoom / 100
|
|
2425
2955
|
|
|
2426
|
-
const
|
|
2427
|
-
? new Set((localWidgets ?? []).filter(w => w.type === 'terminal').map(w => w.id))
|
|
2428
|
-
: null
|
|
2429
|
-
|
|
2430
|
-
const filteredConnectors = terminalWidgetIds?.size
|
|
2431
|
-
? localConnectors.filter(c => !terminalWidgetIds.has(c.startWidgetId) && !terminalWidgetIds.has(c.endWidgetId))
|
|
2432
|
-
: localConnectors
|
|
2956
|
+
const filteredConnectors = localConnectors
|
|
2433
2957
|
|
|
2434
2958
|
return (
|
|
2435
2959
|
<>
|
|
2436
2960
|
<div className={styles.canvasTitle}>
|
|
2437
2961
|
<a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
|
|
2438
|
-
<Icon name="
|
|
2962
|
+
<Icon name="home" size={16} color="#fff" />
|
|
2439
2963
|
</a>
|
|
2440
2964
|
<CanvasTitleEditable
|
|
2441
2965
|
canvasId={canvasId}
|
|
@@ -2444,9 +2968,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2444
2968
|
isLocalDev={isLocalDev}
|
|
2445
2969
|
/>
|
|
2446
2970
|
<PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
|
|
2447
|
-
{isLocalDev && (
|
|
2448
|
-
<span className={styles.localEditingLabel}>Local editing</span>
|
|
2449
|
-
)}
|
|
2450
2971
|
</div>
|
|
2451
2972
|
<div
|
|
2452
2973
|
ref={scrollRef}
|
|
@@ -2477,8 +2998,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2477
2998
|
<ConnectorLayer
|
|
2478
2999
|
connectors={filteredConnectors}
|
|
2479
3000
|
widgets={localWidgets ?? []}
|
|
3001
|
+
selectedWidgetIds={selectedWidgetIds}
|
|
2480
3002
|
onRemove={isLocalDev ? handleConnectorRemove : undefined}
|
|
2481
|
-
onEndpointDrag={
|
|
3003
|
+
onEndpointDrag={undefined}
|
|
2482
3004
|
dragPreview={connectorDrag}
|
|
2483
3005
|
hidden={widgetDragging}
|
|
2484
3006
|
/>
|