@dfosco/storyboard-react 4.2.0-beta.4 → 4.2.1
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 +10 -11
- package/src/AuthModal/AuthModal.jsx +6 -8
- 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 +480 -187
- package/src/CommandPalette/command-palette.css +142 -78
- package/src/Icon.jsx +157 -58
- package/src/Viewfinder.jsx +562 -207
- 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 +11 -7
- package/src/canvas/CanvasPage.jsx +739 -219
- package/src/canvas/CanvasPage.module.css +13 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
- package/src/canvas/ConnectorLayer.jsx +121 -165
- 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/canvasReloadGuard.test.js +1 -1
- 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 +474 -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 +113 -5
- package/src/canvas/widgets/LinkPreview.module.css +127 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +167 -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 -39
- 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 +73 -15
- package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
- package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
- package/src/canvas/widgets/TerminalWidget.jsx +445 -67
- package/src/canvas/widgets/TerminalWidget.module.css +271 -8
- package/src/canvas/widgets/TilesWidget.jsx +300 -0
- package/src/canvas/widgets/TilesWidget.module.css +133 -0
- package/src/canvas/widgets/WidgetChrome.jsx +74 -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 +560 -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 +48 -20
- package/src/hooks/useConfig.js +14 -0
- package/src/hooks/usePrototypeReloadGuard.js +64 -0
- package/src/hooks/useSceneData.js +1 -0
- package/src/hooks/useThemeState.test.js +1 -1
- 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 +407 -67
- package/src/vite/data-plugin.test.js +1 -1
|
@@ -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,9 +55,7 @@ const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
|
|
|
44
55
|
const GH_INSTALL_URL = 'https://github.com/cli/cli'
|
|
45
56
|
|
|
46
57
|
registerSmoothCorners()
|
|
47
|
-
|
|
48
|
-
/** Matches branch-deploy base path prefixes like /branch--my-feature/ */
|
|
49
|
-
const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
|
|
58
|
+
registerHotPoolDevLogs()
|
|
50
59
|
|
|
51
60
|
// Build a reverse map from story route paths → { storyId, route }
|
|
52
61
|
const storyRouteIndex = new Map()
|
|
@@ -132,16 +141,17 @@ function getViewportStorageKey(canvasId) {
|
|
|
132
141
|
function loadViewportState(canvasId) {
|
|
133
142
|
try {
|
|
134
143
|
const raw = localStorage.getItem(getViewportStorageKey(canvasId))
|
|
135
|
-
if (!raw) { console.log('[viewport] no saved state for', canvasId); return null }
|
|
144
|
+
if (!raw) { if (getFlag('dev-logs')) console.log('[viewport] no saved state for', canvasId); return null }
|
|
136
145
|
const state = JSON.parse(raw)
|
|
137
146
|
const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
|
|
138
147
|
const age = Date.now() - timestamp
|
|
139
148
|
if (age > VIEWPORT_TTL_MS) {
|
|
140
|
-
console.log('[viewport] stale state for', canvasId, '— age:', Math.round(age / 1000), 's')
|
|
149
|
+
if (getFlag('dev-logs')) console.log('[viewport] stale state for', canvasId, '— age:', Math.round(age / 1000), 's')
|
|
141
150
|
localStorage.removeItem(getViewportStorageKey(canvasId))
|
|
142
151
|
return null
|
|
143
152
|
}
|
|
144
|
-
console.log('[viewport] loaded state for', canvasId, '— age:', Math.round(age / 1000), 's, zoom:', state.zoom, 'scroll:', state.scrollLeft, state.scrollTop)
|
|
153
|
+
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)
|
|
154
|
+
const { ZOOM_MIN, ZOOM_MAX } = zoomLimits()
|
|
145
155
|
return {
|
|
146
156
|
zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
|
|
147
157
|
scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
|
|
@@ -271,13 +281,15 @@ function computeCanvasBounds(widgets, componentEntries) {
|
|
|
271
281
|
}
|
|
272
282
|
|
|
273
283
|
/** Renders a single JSON-defined widget by type lookup. */
|
|
274
|
-
function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub }) {
|
|
284
|
+
function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub, multiSelected }) {
|
|
275
285
|
const Component = getWidgetComponent(widget.type)
|
|
276
286
|
if (!Component) {
|
|
277
287
|
console.warn(`[canvas] Unknown widget type: ${widget.type}`)
|
|
278
288
|
return null
|
|
279
289
|
}
|
|
280
|
-
const resizable =
|
|
290
|
+
const resizable = (widget.type === 'terminal' || widget.type === 'agent')
|
|
291
|
+
? isTerminalResizable(widget.props?.agentId) && !!onUpdate
|
|
292
|
+
: isResizable(widget.type) && !!onUpdate
|
|
281
293
|
// Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
|
|
282
294
|
const elementProps = {
|
|
283
295
|
id: widget.id,
|
|
@@ -286,6 +298,7 @@ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefre
|
|
|
286
298
|
resizable,
|
|
287
299
|
onRefreshGitHub,
|
|
288
300
|
canRefreshGitHub,
|
|
301
|
+
multiSelected,
|
|
289
302
|
}
|
|
290
303
|
if (Component.$$typeof === Symbol.for('react.forward_ref')) {
|
|
291
304
|
elementProps.ref = widgetRef
|
|
@@ -303,11 +316,14 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
303
316
|
widget,
|
|
304
317
|
selected,
|
|
305
318
|
multiSelected,
|
|
319
|
+
connectorCount,
|
|
320
|
+
allWidgets,
|
|
306
321
|
onSelect,
|
|
307
322
|
onDeselect,
|
|
308
323
|
onUpdate,
|
|
309
324
|
onRemove,
|
|
310
325
|
onCopy,
|
|
326
|
+
onCopyWithConnectors,
|
|
311
327
|
onRefreshGitHub,
|
|
312
328
|
canRefreshGitHub,
|
|
313
329
|
onConnectorDragStart,
|
|
@@ -319,7 +335,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
319
335
|
// Dynamically adjust features based on widget state
|
|
320
336
|
const features = useMemo(() => {
|
|
321
337
|
const isGitHub = !!widget.props?.github
|
|
322
|
-
|
|
338
|
+
const adjusted = rawFeatures.map((f) => {
|
|
323
339
|
// Toggle collapse label and hide when content is short (no github = no collapse)
|
|
324
340
|
if (f.action === 'toggle-collapse') {
|
|
325
341
|
if (widget.type === 'link-preview' && !isGitHub) return null
|
|
@@ -333,13 +349,79 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
333
349
|
if (f.action === 'refresh-github' && !isGitHub) return null
|
|
334
350
|
return f
|
|
335
351
|
}).filter(Boolean)
|
|
336
|
-
}, [rawFeatures, widget.props?.github, widget.props?.collapsed])
|
|
337
352
|
|
|
338
|
-
|
|
353
|
+
// Add dynamic "Split Screen" action when a connected split target exists.
|
|
354
|
+
// Uses connectorCount/allWidgets props (reactive) instead of the global
|
|
355
|
+
// bridge state which may be stale during React render.
|
|
356
|
+
if (isExpandable(widget.type)) {
|
|
357
|
+
const hasConnected = (connectorCount || []).some((c) => {
|
|
358
|
+
const otherId = c.start?.widgetId === widget.id ? c.end?.widgetId : c.start?.widgetId
|
|
359
|
+
const otherWidget = (allWidgets || []).find((w) => w.id === otherId)
|
|
360
|
+
return otherWidget && isSplitScreenCapable(otherWidget.type)
|
|
361
|
+
})
|
|
362
|
+
if (hasConnected) {
|
|
363
|
+
// Insert before the first menu-only feature
|
|
364
|
+
const insertIdx = adjusted.findIndex((f) => f.menu)
|
|
365
|
+
const splitFeature = {
|
|
366
|
+
id: 'split-screen',
|
|
367
|
+
type: 'action',
|
|
368
|
+
action: 'split-screen',
|
|
369
|
+
label: 'Split Screen',
|
|
370
|
+
icon: 'columns',
|
|
371
|
+
prod: true,
|
|
372
|
+
}
|
|
373
|
+
if (insertIdx >= 0) adjusted.splice(insertIdx, 0, splitFeature)
|
|
374
|
+
else adjusted.push(splitFeature)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Add dynamic "Broadcast" toggle for terminal/agent widgets with connected peers
|
|
379
|
+
if (widget.type === 'terminal' || widget.type === 'agent') {
|
|
380
|
+
const widgetConnectors = connectorCount || []
|
|
381
|
+
const widgetList = allWidgets || []
|
|
382
|
+
let hasBroadcastPeers = false
|
|
383
|
+
let allBroadcastActive = true
|
|
384
|
+
const broadcastConnectorIds = []
|
|
385
|
+
|
|
386
|
+
for (const conn of widgetConnectors) {
|
|
387
|
+
const peerId = conn.start?.widgetId === widget.id ? conn.end?.widgetId : conn.start?.widgetId
|
|
388
|
+
const peer = widgetList.find((w) => w.id === peerId)
|
|
389
|
+
if (peer && (peer.type === 'terminal' || peer.type === 'agent')) {
|
|
390
|
+
hasBroadcastPeers = true
|
|
391
|
+
broadcastConnectorIds.push(conn.id)
|
|
392
|
+
if (conn.meta?.messagingMode !== 'two-way') allBroadcastActive = false
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (hasBroadcastPeers) {
|
|
397
|
+
const isActive = allBroadcastActive
|
|
398
|
+
const insertIdx = adjusted.findIndex((f) => f.menu)
|
|
399
|
+
const broadcastFeature = {
|
|
400
|
+
id: 'broadcast',
|
|
401
|
+
type: 'action',
|
|
402
|
+
action: `broadcast-toggle:${broadcastConnectorIds.join(',')}:${isActive ? 'off' : 'on'}`,
|
|
403
|
+
label: isActive ? 'Broadcast On' : 'Broadcast',
|
|
404
|
+
icon: 'broadcast',
|
|
405
|
+
active: isActive,
|
|
406
|
+
}
|
|
407
|
+
if (insertIdx >= 0) adjusted.splice(insertIdx, 0, broadcastFeature)
|
|
408
|
+
else adjusted.push(broadcastFeature)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return adjusted
|
|
413
|
+
}, [rawFeatures, widget.props?.github, widget.props?.collapsed, widget.type, widget.id, connectorCount, allWidgets])
|
|
414
|
+
|
|
415
|
+
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
|
416
|
+
const handleAction = useCallback((actionId, opts) => {
|
|
339
417
|
if (actionId === 'delete') {
|
|
340
418
|
onRemove?.(widget.id)
|
|
341
419
|
} else if (actionId === 'copy') {
|
|
342
|
-
|
|
420
|
+
if (opts?.altKey && onCopyWithConnectors) {
|
|
421
|
+
onCopyWithConnectors(widget)
|
|
422
|
+
} else {
|
|
423
|
+
onCopy?.(widget)
|
|
424
|
+
}
|
|
343
425
|
} else if (actionId === 'copy-text') {
|
|
344
426
|
const title = widget.props?.title || ''
|
|
345
427
|
const body = widget.props?.text || widget.props?.content || widget.props?.github?.body || ''
|
|
@@ -361,8 +443,20 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
361
443
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
362
444
|
})
|
|
363
445
|
}
|
|
446
|
+
} else if (actionId.startsWith('broadcast-toggle:')) {
|
|
447
|
+
// broadcast-toggle:<connectorId1,connectorId2,...>:<on|off>
|
|
448
|
+
const parts = actionId.split(':')
|
|
449
|
+
const connectorIds = parts[1].split(',')
|
|
450
|
+
const turnOn = parts[2] === 'on'
|
|
451
|
+
const bridge = window.__storyboardCanvasBridgeState
|
|
452
|
+
const canvasId = bridge?.canvasId || ''
|
|
453
|
+
const meta = turnOn ? { messagingMode: 'two-way' } : { messagingMode: null }
|
|
454
|
+
for (const cid of connectorIds) {
|
|
455
|
+
updateConnectorApi(canvasId, cid, meta)
|
|
456
|
+
.catch((err) => console.error('[canvas] Failed to toggle broadcast:', err))
|
|
457
|
+
}
|
|
364
458
|
}
|
|
365
|
-
}, [widget, onRemove, onCopy, onRefreshGitHub])
|
|
459
|
+
}, [widget, onRemove, onCopy, onCopyWithConnectors, onRefreshGitHub])
|
|
366
460
|
|
|
367
461
|
const handleWidgetFieldUpdate = useCallback((updates) => {
|
|
368
462
|
onUpdate?.(widget.id, updates)
|
|
@@ -390,6 +484,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
390
484
|
widgetRef={widgetRef}
|
|
391
485
|
onRefreshGitHub={onRefreshGitHub}
|
|
392
486
|
canRefreshGitHub={canRefreshGitHub}
|
|
487
|
+
multiSelected={multiSelected}
|
|
393
488
|
/>
|
|
394
489
|
</WidgetChrome>
|
|
395
490
|
)
|
|
@@ -398,6 +493,8 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
398
493
|
prev.widget === next.widget &&
|
|
399
494
|
prev.selected === next.selected &&
|
|
400
495
|
prev.multiSelected === next.multiSelected &&
|
|
496
|
+
prev.connectorCount === next.connectorCount &&
|
|
497
|
+
prev.allWidgets === next.allWidgets &&
|
|
401
498
|
prev.readOnly === next.readOnly &&
|
|
402
499
|
prev.onSelect === next.onSelect &&
|
|
403
500
|
prev.onDeselect === next.onDeselect &&
|
|
@@ -532,6 +629,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
532
629
|
const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
|
|
533
630
|
const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
|
|
534
631
|
|
|
632
|
+
// Scroll lock: prevents focus-triggered scroll jumps when adding terminal/agent widgets.
|
|
633
|
+
// The lock captures the current scroll position and forces it back on every scroll event
|
|
634
|
+
// until unlocked by the widget's ready signal or a safety timeout.
|
|
635
|
+
// Visual UI (outline + banner) only appears after 1.5s if still locked.
|
|
636
|
+
|
|
535
637
|
// Refs for snap settings (used by drop handler inside effect closure)
|
|
536
638
|
const snapEnabledRef = useRef(snapEnabled)
|
|
537
639
|
const snapGridSizeRef = useRef(snapGridSize)
|
|
@@ -571,12 +673,42 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
571
673
|
stateRef.current = { widgets: localWidgets, sources: localSources, connectors: localConnectors }
|
|
572
674
|
}, [localWidgets, localSources, localConnectors])
|
|
573
675
|
|
|
676
|
+
// Dirty flag — true while optimistic edits haven't been persisted yet.
|
|
677
|
+
// Prevents HMR echoes from overwriting in-flight local state.
|
|
678
|
+
const dirtyRef = useRef(false)
|
|
679
|
+
|
|
680
|
+
// Counter of in-flight writes. dirtyRef is only cleared when this reaches 0,
|
|
681
|
+
// preventing early clears when multiple writes are queued in sequence.
|
|
682
|
+
const inflightWritesRef = useRef(0)
|
|
683
|
+
|
|
684
|
+
// Grace period timer — after all writes complete, dirtyRef stays true for a
|
|
685
|
+
// brief window to absorb delayed file-watcher HMR events that arrive after
|
|
686
|
+
// the server's immediate push. Defense-in-depth for the write guard.
|
|
687
|
+
const dirtyGraceTimerRef = useRef(null)
|
|
688
|
+
|
|
574
689
|
// Serialized write queue — ensures JSONL events land in the right order
|
|
575
690
|
const writeQueueRef = useRef(Promise.resolve())
|
|
576
691
|
function queueWrite(fn) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
692
|
+
clearTimeout(dirtyGraceTimerRef.current)
|
|
693
|
+
inflightWritesRef.current += 1
|
|
694
|
+
writeQueueRef.current = writeQueueRef.current
|
|
695
|
+
.then(fn)
|
|
696
|
+
.catch((err) => console.error('[canvas] Write queue error:', err))
|
|
697
|
+
.finally(() => {
|
|
698
|
+
inflightWritesRef.current -= 1
|
|
699
|
+
if (inflightWritesRef.current < 0) {
|
|
700
|
+
console.warn('[canvas] Write queue counter underflow — resetting')
|
|
701
|
+
inflightWritesRef.current = 0
|
|
702
|
+
}
|
|
703
|
+
if (inflightWritesRef.current === 0) {
|
|
704
|
+
// Grace period — absorb delayed watcher HMR events before clearing
|
|
705
|
+
dirtyGraceTimerRef.current = setTimeout(() => {
|
|
706
|
+
if (inflightWritesRef.current === 0) {
|
|
707
|
+
dirtyRef.current = false
|
|
708
|
+
}
|
|
709
|
+
}, 600)
|
|
710
|
+
}
|
|
711
|
+
})
|
|
580
712
|
return writeQueueRef.current
|
|
581
713
|
}
|
|
582
714
|
|
|
@@ -663,14 +795,23 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
663
795
|
|
|
664
796
|
if (canvas !== trackedCanvas) {
|
|
665
797
|
const isCanvasSwitch = trackedCanvas && canvas && trackedCanvas._route !== canvas._route
|
|
666
|
-
console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
|
|
798
|
+
if (getFlag('dev-logs')) console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
|
|
667
799
|
setTrackedCanvas(canvas)
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
800
|
+
|
|
801
|
+
// Skip replacing local state with server data when optimistic edits are
|
|
802
|
+
// pending — the local state is more recent. The next save will persist it
|
|
803
|
+
// and the subsequent server push (after dirty clears) will reconcile.
|
|
804
|
+
if (!dirtyRef.current || isCanvasSwitch) {
|
|
805
|
+
setLocalWidgets(canvas?.widgets ?? null)
|
|
806
|
+
setLocalConnectors(canvas?.connectors ?? [])
|
|
807
|
+
setLocalSources(canvas?.sources ?? [])
|
|
808
|
+
}
|
|
809
|
+
|
|
671
810
|
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
672
811
|
setSnapGridSize(canvas?.gridSize || 40)
|
|
673
|
-
|
|
812
|
+
if (isCanvasSwitch) {
|
|
813
|
+
undoRedo.reset()
|
|
814
|
+
}
|
|
674
815
|
// Only reset viewport state when switching to a different canvas,
|
|
675
816
|
// not when the same canvas refreshes with server data.
|
|
676
817
|
if (isCanvasSwitch) {
|
|
@@ -683,11 +824,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
683
824
|
}
|
|
684
825
|
}
|
|
685
826
|
|
|
686
|
-
// Debounced save to server
|
|
827
|
+
// Debounced save to server — routed through queueWrite to serialize
|
|
828
|
+
// with deletes and other writes, preventing stale data from overwriting.
|
|
687
829
|
const debouncedSave = useRef(
|
|
688
830
|
debounce((canvasId, widgets) => {
|
|
689
|
-
|
|
690
|
-
|
|
831
|
+
queueWrite(() =>
|
|
832
|
+
updateCanvas(canvasId, { widgets })
|
|
833
|
+
.catch((err) => console.error('[canvas] Failed to save:', err))
|
|
691
834
|
)
|
|
692
835
|
}, 2000)
|
|
693
836
|
).current
|
|
@@ -705,12 +848,17 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
705
848
|
const next = prev.map((w) =>
|
|
706
849
|
w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
|
|
707
850
|
)
|
|
851
|
+
dirtyRef.current = true
|
|
708
852
|
debouncedSave(canvasId, next)
|
|
709
853
|
return next
|
|
710
854
|
})
|
|
711
855
|
}, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
|
|
712
856
|
|
|
713
857
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
858
|
+
// Cancel any pending debounced save — it may contain stale data
|
|
859
|
+
// that includes the widget we're about to delete
|
|
860
|
+
debouncedSave.cancel()
|
|
861
|
+
|
|
714
862
|
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
715
863
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
716
864
|
// Cascade: remove connectors referencing this widget
|
|
@@ -726,12 +874,12 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
726
874
|
}
|
|
727
875
|
return prev.filter((c) => c.start.widgetId !== widgetId && c.end.widgetId !== widgetId)
|
|
728
876
|
})
|
|
877
|
+
dirtyRef.current = true
|
|
729
878
|
queueWrite(() =>
|
|
730
|
-
removeWidgetApi(canvasId, widgetId)
|
|
731
|
-
console.error('[canvas] Failed to remove widget:', err)
|
|
732
|
-
)
|
|
879
|
+
removeWidgetApi(canvasId, widgetId)
|
|
880
|
+
.catch((err) => console.error('[canvas] Failed to remove widget:', err))
|
|
733
881
|
)
|
|
734
|
-
}, [canvasId, undoRedo])
|
|
882
|
+
}, [canvasId, undoRedo, debouncedSave])
|
|
735
883
|
|
|
736
884
|
const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
|
|
737
885
|
try {
|
|
@@ -748,6 +896,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
748
896
|
const handleConnectorRemove = useCallback((connectorId) => {
|
|
749
897
|
undoRedo.snapshot(stateRef.current, 'connector-remove')
|
|
750
898
|
setLocalConnectors((prev) => prev.filter((c) => c.id !== connectorId))
|
|
899
|
+
dirtyRef.current = true
|
|
751
900
|
queueWrite(() =>
|
|
752
901
|
removeConnectorApi(canvasId, connectorId).catch((err) =>
|
|
753
902
|
console.error('[canvas] Failed to remove connector:', err)
|
|
@@ -802,21 +951,52 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
802
951
|
y: (scrollEl.scrollTop + clientY - rect.top) / scale,
|
|
803
952
|
})
|
|
804
953
|
|
|
805
|
-
// Find nearest anchor on any other widget within snap
|
|
806
|
-
|
|
954
|
+
// Find nearest anchor on any other widget within a rectangular snap zone.
|
|
955
|
+
// Each anchor has a 30px-wide strip (15px each side) extending from the widget edge.
|
|
956
|
+
const SNAP_EXTEND = 15
|
|
957
|
+
const SNAP_DEPTH = 40
|
|
958
|
+
const SNAP_CROSS = 20 // perpendicular expansion so you can approach from any direction
|
|
807
959
|
const sourceType = startWidget.type
|
|
808
960
|
const findNearestAnchor = (canvasPt) => {
|
|
809
961
|
const currentWidgets = stateRef.current.widgets ?? []
|
|
810
962
|
let best = null
|
|
811
|
-
let bestDist =
|
|
963
|
+
let bestDist = Infinity
|
|
812
964
|
for (const w of currentWidgets) {
|
|
813
965
|
if (w.id === widgetId) continue
|
|
814
|
-
// Check if this widget type accepts connections from the source type
|
|
815
966
|
if (!canAcceptConnection(w.type, sourceType)) continue
|
|
967
|
+
|
|
968
|
+
let ww, wh
|
|
969
|
+
const el = document.getElementById(w.id)
|
|
970
|
+
if (el) {
|
|
971
|
+
const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
|
|
972
|
+
if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
|
|
973
|
+
}
|
|
974
|
+
if (!ww) ww = w.props?.width ?? w.bounds?.width ?? 270
|
|
975
|
+
if (!wh) wh = w.props?.height ?? w.bounds?.height ?? 170
|
|
976
|
+
const wx = w.position?.x ?? 0
|
|
977
|
+
const wy = w.position?.y ?? 0
|
|
978
|
+
|
|
816
979
|
for (const anch of ['top', 'bottom', 'left', 'right']) {
|
|
817
|
-
// Skip unavailable or disabled anchors
|
|
818
980
|
const anchorState = getAnchorState(w.type, anch)
|
|
819
981
|
if (anchorState !== 'available') continue
|
|
982
|
+
|
|
983
|
+
// Build a rectangular hit zone for this anchor
|
|
984
|
+
let inZone = false
|
|
985
|
+
if (anch === 'top') {
|
|
986
|
+
inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
|
|
987
|
+
canvasPt.y >= wy - SNAP_DEPTH && canvasPt.y <= wy + SNAP_EXTEND
|
|
988
|
+
} else if (anch === 'bottom') {
|
|
989
|
+
inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
|
|
990
|
+
canvasPt.y >= wy + wh - SNAP_EXTEND && canvasPt.y <= wy + wh + SNAP_DEPTH
|
|
991
|
+
} else if (anch === 'left') {
|
|
992
|
+
inZone = canvasPt.x >= wx - SNAP_DEPTH && canvasPt.x <= wx + SNAP_EXTEND &&
|
|
993
|
+
canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
|
|
994
|
+
} else if (anch === 'right') {
|
|
995
|
+
inZone = canvasPt.x >= wx + ww - SNAP_EXTEND && canvasPt.x <= wx + ww + SNAP_DEPTH &&
|
|
996
|
+
canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
|
|
997
|
+
}
|
|
998
|
+
if (!inZone) continue
|
|
999
|
+
|
|
820
1000
|
const pt = computeAnchorPt(w, anch)
|
|
821
1001
|
const dist = Math.hypot(pt.x - canvasPt.x, pt.y - canvasPt.y)
|
|
822
1002
|
if (dist < bestDist) {
|
|
@@ -872,147 +1052,334 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
872
1052
|
document.addEventListener('pointerup', handlePointerUp)
|
|
873
1053
|
}, [handleConnectorAdd])
|
|
874
1054
|
|
|
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
|
|
1055
|
+
// Endpoint drag removed — dragging from a filled anchor now always
|
|
1056
|
+
// creates a new connection via handleConnectorDragStart instead of
|
|
1057
|
+
// repositioning the existing one.
|
|
889
1058
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1059
|
+
const handleWidgetCopy = useCallback(async (widget) => {
|
|
1060
|
+
// Find the next free offset — check how many copies already exist at +n*40
|
|
1061
|
+
const baseX = widget.position?.x ?? 0
|
|
1062
|
+
const baseY = widget.position?.y ?? 0
|
|
1063
|
+
const occupied = new Set(
|
|
1064
|
+
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
1065
|
+
)
|
|
1066
|
+
let n = 1
|
|
1067
|
+
while (occupied.has(`${baseX + n * 40},${baseY + n * 40}`)) {
|
|
1068
|
+
n++
|
|
1069
|
+
}
|
|
1070
|
+
const position = { x: baseX + n * 40, y: baseY + n * 40 }
|
|
1071
|
+
const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
|
|
1072
|
+
try {
|
|
1073
|
+
const copyProps = { ...widget.props }
|
|
1074
|
+
// Terminal widgets must get unique names — strip prettyName so the server generates a fresh one
|
|
1075
|
+
if (isTerminal) delete copyProps.prettyName
|
|
1076
|
+
// Image widgets: duplicate the asset file so each widget owns its own copy
|
|
1077
|
+
if (widget.type === 'image' && copyProps.src) {
|
|
1078
|
+
const dupResult = await duplicateImage(copyProps.src)
|
|
1079
|
+
if (dupResult.success) copyProps.src = dupResult.filename
|
|
896
1080
|
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1081
|
+
|
|
1082
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1083
|
+
const result = await addWidgetApi(canvasId, {
|
|
1084
|
+
type: widget.type,
|
|
1085
|
+
props: copyProps,
|
|
1086
|
+
position,
|
|
1087
|
+
})
|
|
1088
|
+
if (result.success && result.widget) {
|
|
1089
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1090
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
907
1091
|
}
|
|
1092
|
+
} catch (err) {
|
|
1093
|
+
console.error('[canvas] Failed to copy widget:', err)
|
|
908
1094
|
}
|
|
1095
|
+
}, [canvasId, localWidgets, undoRedo])
|
|
909
1096
|
|
|
910
|
-
|
|
911
|
-
|
|
1097
|
+
// Duplicate a single widget WITH its connectors (Alt+click on duplicate button)
|
|
1098
|
+
const handleWidgetCopyWithConnectors = useCallback(async (widget) => {
|
|
1099
|
+
if (!widget) return
|
|
1100
|
+
const widgets = [widget]
|
|
912
1101
|
|
|
913
|
-
|
|
914
|
-
x: (scrollEl.scrollLeft + clientX - rect.left) / scale,
|
|
915
|
-
y: (scrollEl.scrollTop + clientY - rect.top) / scale,
|
|
916
|
-
})
|
|
1102
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
917
1103
|
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1104
|
+
const occupied = new Set(
|
|
1105
|
+
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
1106
|
+
)
|
|
1107
|
+
let offset = 1
|
|
1108
|
+
while (occupied.has(`${(widget.position?.x ?? 0) + offset * 40},${(widget.position?.y ?? 0) + offset * 40}`)) offset++
|
|
1109
|
+
|
|
1110
|
+
const imageOverrides = new Map()
|
|
1111
|
+
if (widget.type === 'image' && widget.props?.src) {
|
|
1112
|
+
try {
|
|
1113
|
+
const dupResult = await duplicateImage(widget.props.src)
|
|
1114
|
+
if (dupResult.success) imageOverrides.set(widget.id, dupResult.filename)
|
|
1115
|
+
} catch { /* use original src as fallback */ }
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const selectedIds = new Set([widget.id])
|
|
1119
|
+
const relevantConnectors = (localConnectors ?? []).filter(
|
|
1120
|
+
(c) => selectedIds.has(c.start?.widgetId) || selectedIds.has(c.end?.widgetId)
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
const ops = []
|
|
1124
|
+
for (const w of widgets) {
|
|
1125
|
+
const copyProps = { ...w.props }
|
|
1126
|
+
const isTerminal = w.type === 'terminal' || w.type === 'agent'
|
|
1127
|
+
if (isTerminal) delete copyProps.prettyName
|
|
1128
|
+
if (imageOverrides.has(w.id)) copyProps.src = imageOverrides.get(w.id)
|
|
1129
|
+
ops.push({
|
|
1130
|
+
op: 'create-widget',
|
|
1131
|
+
ref: `clone-${w.id}`,
|
|
1132
|
+
type: w.type,
|
|
1133
|
+
props: copyProps,
|
|
1134
|
+
position: {
|
|
1135
|
+
x: (w.position?.x ?? 0) + offset * 40,
|
|
1136
|
+
y: (w.position?.y ?? 0) + offset * 40,
|
|
1137
|
+
},
|
|
1138
|
+
})
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
for (const conn of relevantConnectors) {
|
|
1142
|
+
const startInSelection = selectedIds.has(conn.start?.widgetId)
|
|
1143
|
+
const endInSelection = selectedIds.has(conn.end?.widgetId)
|
|
1144
|
+
ops.push({
|
|
1145
|
+
op: 'create-connector',
|
|
1146
|
+
startWidgetId: startInSelection ? `$clone-${conn.start.widgetId}` : conn.start.widgetId,
|
|
1147
|
+
startAnchor: conn.start.anchor,
|
|
1148
|
+
endWidgetId: endInSelection ? `$clone-${conn.end.widgetId}` : conn.end.widgetId,
|
|
1149
|
+
endAnchor: conn.end.anchor,
|
|
1150
|
+
connectorType: conn.connectorType || 'default',
|
|
1151
|
+
})
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
try {
|
|
1155
|
+
const response = await batchOperations(canvasId, ops)
|
|
1156
|
+
if (!response.success) {
|
|
1157
|
+
console.error('[canvas] Batch duplicate failed:', response.error)
|
|
1158
|
+
return
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const newWidgets = []
|
|
1162
|
+
const newConnectors = []
|
|
1163
|
+
const refMap = response.refs || {}
|
|
1164
|
+
|
|
1165
|
+
for (const result of response.results) {
|
|
1166
|
+
if (result.op === 'create-widget' && result.widget) {
|
|
1167
|
+
newWidgets.push(result.widget)
|
|
1168
|
+
}
|
|
1169
|
+
if (result.op === 'create-connector' && result.connectorId) {
|
|
1170
|
+
const origOp = ops[result.index]
|
|
1171
|
+
const resolveId = (val) => {
|
|
1172
|
+
if (typeof val === 'string' && val.startsWith('$')) {
|
|
1173
|
+
return refMap[val.slice(1)] ?? val
|
|
1174
|
+
}
|
|
1175
|
+
return val
|
|
935
1176
|
}
|
|
1177
|
+
newConnectors.push({
|
|
1178
|
+
id: result.connectorId,
|
|
1179
|
+
type: 'connector',
|
|
1180
|
+
connectorType: origOp.connectorType || 'default',
|
|
1181
|
+
start: { widgetId: resolveId(origOp.startWidgetId), anchor: origOp.startAnchor },
|
|
1182
|
+
end: { widgetId: resolveId(origOp.endWidgetId), anchor: origOp.endAnchor },
|
|
1183
|
+
meta: {},
|
|
1184
|
+
})
|
|
936
1185
|
}
|
|
937
1186
|
}
|
|
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
1187
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1188
|
+
if (newWidgets.length > 0) {
|
|
1189
|
+
setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
|
|
1190
|
+
setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
|
|
1191
|
+
}
|
|
1192
|
+
if (newConnectors.length > 0) {
|
|
1193
|
+
setLocalConnectors((prev) => [...prev, ...newConnectors])
|
|
1194
|
+
}
|
|
1195
|
+
} catch (err) {
|
|
1196
|
+
console.error('[canvas] Failed to duplicate with connectors:', err)
|
|
961
1197
|
}
|
|
1198
|
+
}, [canvasId, localWidgets, localConnectors, undoRedo])
|
|
962
1199
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1200
|
+
// Duplicate all selected widgets in one undo step (Cmd+D)
|
|
1201
|
+
const handleDuplicateSelected = useCallback(async () => {
|
|
1202
|
+
const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
|
|
1203
|
+
if (widgets.length === 0) return
|
|
966
1204
|
|
|
967
|
-
|
|
968
|
-
|
|
1205
|
+
// Single undo snapshot for the entire batch
|
|
1206
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
969
1207
|
|
|
970
|
-
|
|
971
|
-
|
|
1208
|
+
// Compute occupied positions to find free offset
|
|
1209
|
+
const occupied = new Set(
|
|
1210
|
+
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
1211
|
+
)
|
|
1212
|
+
let offset = 1
|
|
1213
|
+
const anyOccupied = () => widgets.some((w) => {
|
|
1214
|
+
const bx = (w.position?.x ?? 0) + offset * 40
|
|
1215
|
+
const by = (w.position?.y ?? 0) + offset * 40
|
|
1216
|
+
return occupied.has(`${bx},${by}`)
|
|
1217
|
+
})
|
|
1218
|
+
while (anyOccupied()) offset++
|
|
972
1219
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1220
|
+
const newWidgets = []
|
|
1221
|
+
for (const widget of widgets) {
|
|
1222
|
+
const position = {
|
|
1223
|
+
x: (widget.position?.x ?? 0) + offset * 40,
|
|
1224
|
+
y: (widget.position?.y ?? 0) + offset * 40,
|
|
1225
|
+
}
|
|
1226
|
+
const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
|
|
1227
|
+
try {
|
|
1228
|
+
const copyProps = { ...widget.props }
|
|
1229
|
+
if (isTerminal) delete copyProps.prettyName
|
|
1230
|
+
if (widget.type === 'image' && copyProps.src) {
|
|
1231
|
+
try {
|
|
1232
|
+
const dupResult = await duplicateImage(copyProps.src)
|
|
1233
|
+
if (dupResult.success) copyProps.src = dupResult.filename
|
|
1234
|
+
} catch { /* use original src as fallback */ }
|
|
1235
|
+
}
|
|
1236
|
+
const result = await addWidgetApi(canvasId, {
|
|
1237
|
+
type: widget.type,
|
|
1238
|
+
props: copyProps,
|
|
1239
|
+
position,
|
|
980
1240
|
})
|
|
1241
|
+
if (result.success && result.widget) {
|
|
1242
|
+
newWidgets.push(result.widget)
|
|
1243
|
+
}
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
console.error('[canvas] Failed to duplicate widget:', err)
|
|
981
1246
|
}
|
|
982
|
-
setConnectorDrag(null)
|
|
983
1247
|
}
|
|
984
1248
|
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1249
|
+
if (newWidgets.length > 0) {
|
|
1250
|
+
setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
|
|
1251
|
+
setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
|
|
1252
|
+
}
|
|
1253
|
+
}, [canvasId, localWidgets, selectedWidgetIds, undoRedo])
|
|
988
1254
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1255
|
+
// Duplicate selected widgets WITH connectors (Cmd+Shift+D)
|
|
1256
|
+
// Uses the batch API for atomic operation — all widgets and connectors
|
|
1257
|
+
// are created in a single request with $ref resolution.
|
|
1258
|
+
const handleDuplicateWithConnectors = useCallback(async () => {
|
|
1259
|
+
const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
|
|
1260
|
+
if (widgets.length === 0) return
|
|
1261
|
+
|
|
1262
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1263
|
+
|
|
1264
|
+
// Compute offset — same logic as handleDuplicateSelected
|
|
993
1265
|
const occupied = new Set(
|
|
994
1266
|
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
995
1267
|
)
|
|
996
|
-
let
|
|
997
|
-
|
|
998
|
-
|
|
1268
|
+
let offset = 1
|
|
1269
|
+
const anyOccupied = () => widgets.some((w) => {
|
|
1270
|
+
const bx = (w.position?.x ?? 0) + offset * 40
|
|
1271
|
+
const by = (w.position?.y ?? 0) + offset * 40
|
|
1272
|
+
return occupied.has(`${bx},${by}`)
|
|
1273
|
+
})
|
|
1274
|
+
while (anyOccupied()) offset++
|
|
1275
|
+
|
|
1276
|
+
// Pre-process image widgets — duplicate asset files to get unique filenames
|
|
1277
|
+
const imageOverrides = new Map()
|
|
1278
|
+
for (const widget of widgets) {
|
|
1279
|
+
if (widget.type === 'image' && widget.props?.src) {
|
|
1280
|
+
try {
|
|
1281
|
+
const dupResult = await duplicateImage(widget.props.src)
|
|
1282
|
+
if (dupResult.success) imageOverrides.set(widget.id, dupResult.filename)
|
|
1283
|
+
} catch { /* use original src as fallback */ }
|
|
1284
|
+
}
|
|
999
1285
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1286
|
+
|
|
1287
|
+
// Find all connectors touching at least one selected widget
|
|
1288
|
+
const selectedIds = new Set(widgets.map((w) => w.id))
|
|
1289
|
+
const relevantConnectors = (localConnectors ?? []).filter(
|
|
1290
|
+
(c) => selectedIds.has(c.start?.widgetId) || selectedIds.has(c.end?.widgetId)
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
// Build batch operations
|
|
1294
|
+
const ops = []
|
|
1295
|
+
|
|
1296
|
+
// 1. Create-widget ops with ref names for $ref resolution
|
|
1297
|
+
for (const widget of widgets) {
|
|
1298
|
+
const copyProps = { ...widget.props }
|
|
1299
|
+
const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
|
|
1300
|
+
if (isTerminal) delete copyProps.prettyName
|
|
1301
|
+
if (imageOverrides.has(widget.id)) copyProps.src = imageOverrides.get(widget.id)
|
|
1302
|
+
|
|
1303
|
+
ops.push({
|
|
1304
|
+
op: 'create-widget',
|
|
1305
|
+
ref: `clone-${widget.id}`,
|
|
1004
1306
|
type: widget.type,
|
|
1005
|
-
props:
|
|
1006
|
-
position
|
|
1307
|
+
props: copyProps,
|
|
1308
|
+
position: {
|
|
1309
|
+
x: (widget.position?.x ?? 0) + offset * 40,
|
|
1310
|
+
y: (widget.position?.y ?? 0) + offset * 40,
|
|
1311
|
+
},
|
|
1007
1312
|
})
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// 2. Create-connector ops — remap selected endpoints to $ref clones
|
|
1316
|
+
for (const conn of relevantConnectors) {
|
|
1317
|
+
const startInSelection = selectedIds.has(conn.start?.widgetId)
|
|
1318
|
+
const endInSelection = selectedIds.has(conn.end?.widgetId)
|
|
1319
|
+
|
|
1320
|
+
ops.push({
|
|
1321
|
+
op: 'create-connector',
|
|
1322
|
+
startWidgetId: startInSelection ? `$clone-${conn.start.widgetId}` : conn.start.widgetId,
|
|
1323
|
+
startAnchor: conn.start.anchor,
|
|
1324
|
+
endWidgetId: endInSelection ? `$clone-${conn.end.widgetId}` : conn.end.widgetId,
|
|
1325
|
+
endAnchor: conn.end.anchor,
|
|
1326
|
+
connectorType: conn.connectorType || 'default',
|
|
1327
|
+
})
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
try {
|
|
1331
|
+
const response = await batchOperations(canvasId, ops)
|
|
1332
|
+
if (!response.success) {
|
|
1333
|
+
console.error('[canvas] Batch duplicate failed:', response.error)
|
|
1334
|
+
return
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// Extract created widgets and connectors from results
|
|
1338
|
+
const newWidgets = []
|
|
1339
|
+
const newConnectors = []
|
|
1340
|
+
const refMap = response.refs || {}
|
|
1341
|
+
|
|
1342
|
+
for (const result of response.results) {
|
|
1343
|
+
if (result.op === 'create-widget' && result.widget) {
|
|
1344
|
+
newWidgets.push(result.widget)
|
|
1345
|
+
}
|
|
1346
|
+
if (result.op === 'create-connector' && result.connectorId) {
|
|
1347
|
+
// Reconstruct connector object from the operation + resolved refs
|
|
1348
|
+
const origOp = ops[result.index]
|
|
1349
|
+
const resolveId = (val) => {
|
|
1350
|
+
if (typeof val === 'string' && val.startsWith('$')) {
|
|
1351
|
+
return refMap[val.slice(1)] ?? val
|
|
1352
|
+
}
|
|
1353
|
+
return val
|
|
1354
|
+
}
|
|
1355
|
+
newConnectors.push({
|
|
1356
|
+
id: result.connectorId,
|
|
1357
|
+
type: 'connector',
|
|
1358
|
+
connectorType: origOp.connectorType || 'default',
|
|
1359
|
+
start: { widgetId: resolveId(origOp.startWidgetId), anchor: origOp.startAnchor },
|
|
1360
|
+
end: { widgetId: resolveId(origOp.endWidgetId), anchor: origOp.endAnchor },
|
|
1361
|
+
meta: {},
|
|
1362
|
+
})
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (newWidgets.length > 0) {
|
|
1367
|
+
setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
|
|
1368
|
+
setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
|
|
1369
|
+
}
|
|
1370
|
+
if (newConnectors.length > 0) {
|
|
1371
|
+
setLocalConnectors((prev) => [...prev, ...newConnectors])
|
|
1011
1372
|
}
|
|
1012
1373
|
} catch (err) {
|
|
1013
|
-
console.error('[canvas] Failed to
|
|
1374
|
+
console.error('[canvas] Failed to duplicate with connectors:', err)
|
|
1014
1375
|
}
|
|
1015
|
-
}, [canvasId, localWidgets, undoRedo])
|
|
1376
|
+
}, [canvasId, localWidgets, localConnectors, selectedWidgetIds, undoRedo])
|
|
1377
|
+
|
|
1378
|
+
// Select all widgets (Cmd+A)
|
|
1379
|
+
const handleSelectAll = useCallback(() => {
|
|
1380
|
+
const allIds = (localWidgets ?? []).map((w) => w.id)
|
|
1381
|
+
if (allIds.length > 0) setSelectedWidgetIds(new Set(allIds))
|
|
1382
|
+
}, [localWidgets])
|
|
1016
1383
|
|
|
1017
1384
|
const showMissingGhBanner = useCallback(() => {
|
|
1018
1385
|
setShowGhInstallBanner(true)
|
|
@@ -1068,8 +1435,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1068
1435
|
|
|
1069
1436
|
const debouncedSourceSave = useRef(
|
|
1070
1437
|
debounce((canvasId, sources) => {
|
|
1071
|
-
|
|
1072
|
-
|
|
1438
|
+
queueWrite(() =>
|
|
1439
|
+
updateCanvas(canvasId, { sources }).catch((err) =>
|
|
1440
|
+
console.error('[canvas] Failed to save sources:', err)
|
|
1441
|
+
)
|
|
1073
1442
|
)
|
|
1074
1443
|
}, 2000)
|
|
1075
1444
|
).current
|
|
@@ -1086,6 +1455,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1086
1455
|
const next = current.some((s) => s?.export === exportName)
|
|
1087
1456
|
? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
|
|
1088
1457
|
: [...current, { export: exportName, ...snapped }]
|
|
1458
|
+
dirtyRef.current = true
|
|
1089
1459
|
debouncedSourceSave(canvasId, next)
|
|
1090
1460
|
return next
|
|
1091
1461
|
})
|
|
@@ -1141,10 +1511,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1141
1511
|
}
|
|
1142
1512
|
return w
|
|
1143
1513
|
})
|
|
1514
|
+
dirtyRef.current = true
|
|
1144
1515
|
queueWrite(() =>
|
|
1145
|
-
updateCanvas(canvasId, { widgets: next })
|
|
1146
|
-
console.error('[canvas] Failed to save multi-move:', err)
|
|
1147
|
-
)
|
|
1516
|
+
updateCanvas(canvasId, { widgets: next })
|
|
1517
|
+
.catch((err) => console.error('[canvas] Failed to save multi-move:', err))
|
|
1148
1518
|
)
|
|
1149
1519
|
return next
|
|
1150
1520
|
})
|
|
@@ -1173,10 +1543,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1173
1543
|
return s
|
|
1174
1544
|
})
|
|
1175
1545
|
if (changed) {
|
|
1546
|
+
dirtyRef.current = true
|
|
1176
1547
|
queueWrite(() =>
|
|
1177
|
-
updateCanvas(canvasId, { sources: next })
|
|
1178
|
-
console.error('[canvas] Failed to save multi-move sources:', err)
|
|
1179
|
-
)
|
|
1548
|
+
updateCanvas(canvasId, { sources: next })
|
|
1549
|
+
.catch((err) => console.error('[canvas] Failed to save multi-move sources:', err))
|
|
1180
1550
|
)
|
|
1181
1551
|
}
|
|
1182
1552
|
return changed ? next : current
|
|
@@ -1192,10 +1562,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1192
1562
|
const next = current.some((s) => s?.export === sourceExport)
|
|
1193
1563
|
? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
|
|
1194
1564
|
: [...current, { export: sourceExport, position: rounded }]
|
|
1565
|
+
dirtyRef.current = true
|
|
1195
1566
|
queueWrite(() =>
|
|
1196
|
-
updateCanvas(canvasId, { sources: next })
|
|
1197
|
-
console.error('[canvas] Failed to save source position:', err)
|
|
1198
|
-
)
|
|
1567
|
+
updateCanvas(canvasId, { sources: next })
|
|
1568
|
+
.catch((err) => console.error('[canvas] Failed to save source position:', err))
|
|
1199
1569
|
)
|
|
1200
1570
|
return next
|
|
1201
1571
|
})
|
|
@@ -1203,15 +1573,16 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1203
1573
|
}
|
|
1204
1574
|
|
|
1205
1575
|
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
1576
|
+
debouncedSave.cancel()
|
|
1206
1577
|
setLocalWidgets((prev) => {
|
|
1207
1578
|
if (!prev) return prev
|
|
1208
1579
|
const next = prev.map((w) =>
|
|
1209
1580
|
w.id === dragId ? { ...w, position: rounded } : w
|
|
1210
1581
|
)
|
|
1582
|
+
dirtyRef.current = true
|
|
1211
1583
|
queueWrite(() =>
|
|
1212
|
-
updateCanvas(canvasId, { widgets: next })
|
|
1213
|
-
console.error('[canvas] Failed to save widget position:', err)
|
|
1214
|
-
)
|
|
1584
|
+
updateCanvas(canvasId, { widgets: next })
|
|
1585
|
+
.catch((err) => console.error('[canvas] Failed to save widget position:', err))
|
|
1215
1586
|
)
|
|
1216
1587
|
return next
|
|
1217
1588
|
})
|
|
@@ -1236,20 +1607,21 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1236
1607
|
if (!el || loading) return
|
|
1237
1608
|
const saved = pendingScrollRestore.current
|
|
1238
1609
|
if (saved) {
|
|
1239
|
-
console.log('[viewport] restoring saved viewport — zoom:', saved.zoom, 'scroll:', saved.scrollLeft, saved.scrollTop)
|
|
1610
|
+
if (getFlag('dev-logs')) console.log('[viewport] restoring saved viewport — zoom:', saved.zoom, 'scroll:', saved.scrollLeft, saved.scrollTop)
|
|
1240
1611
|
// Fresh saved viewport — restore exactly
|
|
1241
1612
|
if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
|
|
1242
1613
|
if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
|
|
1243
1614
|
pendingScrollRestore.current = null
|
|
1244
1615
|
} else {
|
|
1245
|
-
console.log('[viewport] no saved viewport — fitting to objects')
|
|
1616
|
+
if (getFlag('dev-logs')) console.log('[viewport] no saved viewport — fitting to objects')
|
|
1246
1617
|
// No saved state or stale — zoom-to-fit all objects
|
|
1247
1618
|
const bounds = computeCanvasBounds(localWidgets, componentEntries)
|
|
1248
1619
|
if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
|
|
1249
1620
|
const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
|
|
1250
1621
|
const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
|
|
1251
1622
|
const fitScale = Math.min(el.clientWidth / boxW, el.clientHeight / boxH)
|
|
1252
|
-
const
|
|
1623
|
+
const { ZOOM_MIN: zMin, ZOOM_MAX: zMax } = zoomLimits()
|
|
1624
|
+
const fitZoom = Math.min(zMax, Math.max(zMin, Math.round(fitScale * 100)))
|
|
1253
1625
|
const newScale = fitZoom / 100
|
|
1254
1626
|
zoomRef.current = fitZoom
|
|
1255
1627
|
// Imperative DOM update for initial zoom-to-fit — same path as applyZoom
|
|
@@ -1326,7 +1698,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1326
1698
|
useEffect(() => {
|
|
1327
1699
|
if (viewportInitName.current !== canvasId) return
|
|
1328
1700
|
const el = scrollRef.current
|
|
1329
|
-
console.log('[viewport] saving — zoom:', zoom, 'scroll:', el?.scrollLeft, el?.scrollTop)
|
|
1701
|
+
if (getFlag('dev-logs')) console.log('[viewport] saving — zoom:', zoom, 'scroll:', el?.scrollLeft, el?.scrollTop)
|
|
1330
1702
|
// Read current scroll so the zoom entry doesn't zero-out position,
|
|
1331
1703
|
// but the authoritative scroll save comes from the scroll handler.
|
|
1332
1704
|
saveViewportState(canvasId, {
|
|
@@ -1371,6 +1743,56 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1371
1743
|
}
|
|
1372
1744
|
}, [canvasId, loading])
|
|
1373
1745
|
|
|
1746
|
+
// Gather current viewport data from refs (safe for callbacks/timeouts)
|
|
1747
|
+
const getViewportData = useCallback(() => {
|
|
1748
|
+
const el = scrollRef.current
|
|
1749
|
+
if (!el) return null
|
|
1750
|
+
const scale = zoomRef.current / 100
|
|
1751
|
+
const scrollLeft = el.scrollLeft
|
|
1752
|
+
const scrollTop = el.scrollTop
|
|
1753
|
+
const cw = el.clientWidth
|
|
1754
|
+
const ch = el.clientHeight
|
|
1755
|
+
return {
|
|
1756
|
+
centerX: Math.round((scrollLeft + cw / 2) / scale),
|
|
1757
|
+
centerY: Math.round((scrollTop + ch / 2) / scale),
|
|
1758
|
+
zoom: zoomRef.current,
|
|
1759
|
+
topLeftX: Math.round(scrollLeft / scale),
|
|
1760
|
+
topLeftY: Math.round(scrollTop / scale),
|
|
1761
|
+
width: Math.round(cw / scale),
|
|
1762
|
+
height: Math.round(ch / scale),
|
|
1763
|
+
}
|
|
1764
|
+
}, [])
|
|
1765
|
+
|
|
1766
|
+
// Debounced viewport-changed HMR event — sends position/zoom to Vite server
|
|
1767
|
+
// so the selected-widgets bridge can write it to disk for agents.
|
|
1768
|
+
useEffect(() => {
|
|
1769
|
+
if (!import.meta.hot) return
|
|
1770
|
+
const el = scrollRef.current
|
|
1771
|
+
if (!el) return
|
|
1772
|
+
|
|
1773
|
+
const tabId = selectionTabIdRef.current
|
|
1774
|
+
|
|
1775
|
+
function sendViewport() {
|
|
1776
|
+
const viewport = getViewportData()
|
|
1777
|
+
if (viewport) {
|
|
1778
|
+
import.meta.hot.send('storyboard:viewport-changed', { tabId, canvasId, viewport })
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
const debouncedSend = debounce(sendViewport, 500)
|
|
1783
|
+
|
|
1784
|
+
function handleScroll() { debouncedSend() }
|
|
1785
|
+
el.addEventListener('scroll', handleScroll, { passive: true })
|
|
1786
|
+
|
|
1787
|
+
// Also send on zoom commits (zoom state changes trigger this effect)
|
|
1788
|
+
sendViewport()
|
|
1789
|
+
|
|
1790
|
+
return () => {
|
|
1791
|
+
debouncedSend.cancel()
|
|
1792
|
+
el.removeEventListener('scroll', handleScroll)
|
|
1793
|
+
}
|
|
1794
|
+
}, [canvasId, zoom, loading, getViewportData])
|
|
1795
|
+
|
|
1374
1796
|
/**
|
|
1375
1797
|
* Zoom to a new level, anchoring on an optional client-space point.
|
|
1376
1798
|
* When a cursor position is provided (e.g. from a wheel event), the
|
|
@@ -1385,6 +1807,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1385
1807
|
function applyZoom(newZoom, clientX, clientY) {
|
|
1386
1808
|
const el = scrollRef.current
|
|
1387
1809
|
const zoomEl = zoomElRef.current
|
|
1810
|
+
const { ZOOM_MIN, ZOOM_MAX } = zoomLimits()
|
|
1388
1811
|
const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
|
|
1389
1812
|
|
|
1390
1813
|
if (!el || !zoomEl) {
|
|
@@ -1431,7 +1854,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1431
1854
|
if (!zoomEventTimer.current) {
|
|
1432
1855
|
zoomEventTimer.current = setTimeout(() => {
|
|
1433
1856
|
zoomEventTimer.current = null
|
|
1434
|
-
window[CANVAS_BRIDGE_STATE_KEY]
|
|
1857
|
+
const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
|
|
1858
|
+
bridge.active = true
|
|
1859
|
+
bridge.canvasId = canvasId
|
|
1860
|
+
bridge.zoom = zoomRef.current
|
|
1861
|
+
window[CANVAS_BRIDGE_STATE_KEY] = bridge
|
|
1435
1862
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
1436
1863
|
detail: { zoom: zoomRef.current }
|
|
1437
1864
|
}))
|
|
@@ -1441,7 +1868,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1441
1868
|
|
|
1442
1869
|
// Signal canvas mount/unmount to CoreUIBar
|
|
1443
1870
|
useEffect(() => {
|
|
1444
|
-
window[CANVAS_BRIDGE_STATE_KEY]
|
|
1871
|
+
const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
|
|
1872
|
+
bridge.active = true
|
|
1873
|
+
bridge.canvasId = canvasId
|
|
1874
|
+
bridge.zoom = zoomRef.current
|
|
1875
|
+
window[CANVAS_BRIDGE_STATE_KEY] = bridge
|
|
1445
1876
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
|
|
1446
1877
|
detail: { canvasId, zoom: zoomRef.current }
|
|
1447
1878
|
}))
|
|
@@ -1461,14 +1892,15 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1461
1892
|
}, [canvasId])
|
|
1462
1893
|
|
|
1463
1894
|
// Tell the Vite dev server to suppress full-reloads while this canvas is active.
|
|
1464
|
-
//
|
|
1895
|
+
// Controlled by the "canvas-auto-reload" feature flag (default: false = guard ON).
|
|
1896
|
+
// When the flag is true, the guard is skipped so canvas pages receive HMR updates.
|
|
1465
1897
|
// Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
|
|
1466
1898
|
useEffect(() => {
|
|
1467
1899
|
if (!import.meta.hot) return
|
|
1468
|
-
const
|
|
1469
|
-
if (
|
|
1900
|
+
const autoReload = getFlag('canvas-auto-reload')
|
|
1901
|
+
if (autoReload) return
|
|
1470
1902
|
|
|
1471
|
-
const msg = { active: true
|
|
1903
|
+
const msg = { active: true }
|
|
1472
1904
|
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
1473
1905
|
const interval = setInterval(() => {
|
|
1474
1906
|
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
@@ -1476,7 +1908,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1476
1908
|
|
|
1477
1909
|
return () => {
|
|
1478
1910
|
clearInterval(interval)
|
|
1479
|
-
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false
|
|
1911
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false })
|
|
1480
1912
|
}
|
|
1481
1913
|
}, [canvasId])
|
|
1482
1914
|
|
|
@@ -1510,7 +1942,8 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1510
1942
|
|
|
1511
1943
|
function sendFocus() {
|
|
1512
1944
|
const { widgetIds, widgets } = getSelectedWidgetData()
|
|
1513
|
-
|
|
1945
|
+
const viewport = getViewportData()
|
|
1946
|
+
import.meta.hot.send('storyboard:canvas-focused', { tabId, canvasId, widgetIds, widgets, viewport })
|
|
1514
1947
|
}
|
|
1515
1948
|
|
|
1516
1949
|
sendFocus()
|
|
@@ -1537,21 +1970,29 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1537
1970
|
const tabId = selectionTabIdRef.current
|
|
1538
1971
|
const timer = setTimeout(() => {
|
|
1539
1972
|
const { widgetIds, widgets } = getSelectedWidgetData()
|
|
1540
|
-
|
|
1973
|
+
const viewport = getViewportData()
|
|
1974
|
+
import.meta.hot.send('storyboard:selection-changed', { tabId, canvasId, widgetIds: widgetIds, widgets, viewport })
|
|
1541
1975
|
}, 500)
|
|
1542
1976
|
|
|
1543
1977
|
return () => clearTimeout(timer)
|
|
1544
1978
|
}, [selectedWidgetIds, canvasId, getSelectedWidgetData])
|
|
1545
1979
|
|
|
1546
1980
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
1547
|
-
const addWidget = useCallback(async (type) => {
|
|
1981
|
+
const addWidget = useCallback(async (type, extraProps = {}) => {
|
|
1548
1982
|
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
1983
|
+
// For terminal/agent, apply config-based dimension defaults over schema defaults
|
|
1984
|
+
if (type === 'terminal' || type === 'agent') {
|
|
1985
|
+
const dims = getTerminalDimensions(extraProps.agentId, { width: defaultProps.width ?? 800, height: defaultProps.height ?? 450 })
|
|
1986
|
+
defaultProps.width = dims.width
|
|
1987
|
+
defaultProps.height = dims.height
|
|
1988
|
+
}
|
|
1989
|
+
const mergedProps = { ...defaultProps, ...extraProps }
|
|
1549
1990
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1550
|
-
const pos = centerPositionForWidget(center, type,
|
|
1991
|
+
const pos = centerPositionForWidget(center, type, mergedProps)
|
|
1551
1992
|
try {
|
|
1552
1993
|
const result = await addWidgetApi(canvasId, {
|
|
1553
1994
|
type,
|
|
1554
|
-
props:
|
|
1995
|
+
props: mergedProps,
|
|
1555
1996
|
position: pos,
|
|
1556
1997
|
})
|
|
1557
1998
|
if (result.success && result.widget) {
|
|
@@ -1585,21 +2026,27 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1585
2026
|
}
|
|
1586
2027
|
}, [canvasId, undoRedo])
|
|
1587
2028
|
|
|
1588
|
-
// Listen for CoreUIBar add-widget events
|
|
2029
|
+
// Listen for CoreUIBar add-widget and update-widget events
|
|
1589
2030
|
useEffect(() => {
|
|
1590
2031
|
function handleAddWidget(e) {
|
|
1591
|
-
addWidget(e.detail.type)
|
|
2032
|
+
addWidget(e.detail.type, e.detail.props)
|
|
1592
2033
|
}
|
|
1593
2034
|
function handleAddStoryWidget(e) {
|
|
1594
2035
|
addStoryWidget(e.detail.storyId)
|
|
1595
2036
|
}
|
|
2037
|
+
function handleUpdateWidget(e) {
|
|
2038
|
+
const { widgetId, updates } = e.detail || {}
|
|
2039
|
+
if (widgetId && updates) handleWidgetUpdate(widgetId, updates)
|
|
2040
|
+
}
|
|
1596
2041
|
document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
1597
2042
|
document.addEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
|
|
2043
|
+
document.addEventListener('storyboard:canvas:update-widget', handleUpdateWidget)
|
|
1598
2044
|
return () => {
|
|
1599
2045
|
document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
1600
2046
|
document.removeEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
|
|
2047
|
+
document.removeEventListener('storyboard:canvas:update-widget', handleUpdateWidget)
|
|
1601
2048
|
}
|
|
1602
|
-
}, [addWidget, addStoryWidget])
|
|
2049
|
+
}, [addWidget, addStoryWidget, handleWidgetUpdate])
|
|
1603
2050
|
|
|
1604
2051
|
// Listen for zoom changes from CoreUIBar
|
|
1605
2052
|
useEffect(() => {
|
|
@@ -1679,7 +2126,8 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1679
2126
|
|
|
1680
2127
|
// Find the zoom level that fits the bounding box in the viewport
|
|
1681
2128
|
const fitScale = Math.min(viewW / boxW, viewH / boxH)
|
|
1682
|
-
const
|
|
2129
|
+
const { ZOOM_MIN: zMin, ZOOM_MAX: zMax } = zoomLimits()
|
|
2130
|
+
const fitZoom = Math.min(zMax, Math.max(zMin, Math.round(fitScale * 100)))
|
|
1683
2131
|
const newScale = fitZoom / 100
|
|
1684
2132
|
|
|
1685
2133
|
// Imperative DOM update — same path as applyZoom
|
|
@@ -1722,12 +2170,26 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1722
2170
|
|
|
1723
2171
|
// Broadcast zoom level to CoreUIBar whenever it changes
|
|
1724
2172
|
useEffect(() => {
|
|
1725
|
-
window[CANVAS_BRIDGE_STATE_KEY]
|
|
2173
|
+
const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
|
|
2174
|
+
bridge.active = true
|
|
2175
|
+
bridge.canvasId = canvasId
|
|
2176
|
+
bridge.zoom = zoom
|
|
2177
|
+
window[CANVAS_BRIDGE_STATE_KEY] = bridge
|
|
1726
2178
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
1727
2179
|
detail: { zoom }
|
|
1728
2180
|
}))
|
|
1729
2181
|
}, [canvasId, zoom])
|
|
1730
2182
|
|
|
2183
|
+
// Keep bridge in sync with widgets/connectors for expand features.
|
|
2184
|
+
// Child widgets now use props directly for split-screen gating, but
|
|
2185
|
+
// FigmaEmbed/PrototypeEmbed/etc. still read this bridge at expand time.
|
|
2186
|
+
useMemo(() => {
|
|
2187
|
+
const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
|
|
2188
|
+
bridge.widgets = localWidgets
|
|
2189
|
+
bridge.connectors = localConnectors
|
|
2190
|
+
window[CANVAS_BRIDGE_STATE_KEY] = bridge
|
|
2191
|
+
}, [localWidgets, localConnectors])
|
|
2192
|
+
|
|
1731
2193
|
// Delete selected widget on Delete/Backspace key
|
|
1732
2194
|
useEffect(() => {
|
|
1733
2195
|
function handleSelectStart(e) {
|
|
@@ -1765,6 +2227,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1765
2227
|
// Multi-delete — snapshot once, remove all, persist via updateCanvas
|
|
1766
2228
|
undoRedo.snapshot(stateRef.current, 'multi-remove')
|
|
1767
2229
|
debouncedSave.cancel()
|
|
2230
|
+
dirtyRef.current = true
|
|
1768
2231
|
setLocalWidgets((prev) => {
|
|
1769
2232
|
if (!prev) return prev
|
|
1770
2233
|
const next = prev.filter(w => !selectedWidgetIds.has(w.id))
|
|
@@ -1857,6 +2320,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1857
2320
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1858
2321
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1859
2322
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
2323
|
+
navigator.clipboard?.writeText(result.widget.id).catch(() => {})
|
|
1860
2324
|
}
|
|
1861
2325
|
return true
|
|
1862
2326
|
} catch (err) {
|
|
@@ -1948,9 +2412,18 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1948
2412
|
for (const w of sourceWidgets) {
|
|
1949
2413
|
const relX = (w.position?.x ?? 0) - minX
|
|
1950
2414
|
const relY = (w.position?.y ?? 0) - minY
|
|
2415
|
+
const pasteProps = { ...w.props }
|
|
2416
|
+
if (w.type === 'terminal' || w.type === 'agent') delete pasteProps.prettyName
|
|
2417
|
+
// Image widgets: duplicate the asset so the paste owns its own copy
|
|
2418
|
+
if (w.type === 'image' && pasteProps.src) {
|
|
2419
|
+
try {
|
|
2420
|
+
const dupResult = await duplicateImage(pasteProps.src)
|
|
2421
|
+
if (dupResult.success) pasteProps.src = dupResult.filename
|
|
2422
|
+
} catch { /* use original src as fallback */ }
|
|
2423
|
+
}
|
|
1951
2424
|
const result = await addWidgetApi(canvasId, {
|
|
1952
2425
|
type: w.type,
|
|
1953
|
-
props:
|
|
2426
|
+
props: pasteProps,
|
|
1954
2427
|
position: { x: baseX + relX, y: baseY + relY },
|
|
1955
2428
|
})
|
|
1956
2429
|
if (result.success && result.widget) {
|
|
@@ -1970,11 +2443,34 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1970
2443
|
}
|
|
1971
2444
|
|
|
1972
2445
|
e.preventDefault()
|
|
2446
|
+
await pasteTextAsWidget(text, pasteCtx)
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// Shared helper: resolve pasted text into a widget and add it to the canvas.
|
|
2450
|
+
// Used by both native paste and the programmatic paste-url event.
|
|
2451
|
+
async function pasteTextAsWidget(text, pasteCtx) {
|
|
1973
2452
|
const resolved = resolvePaste(text, pasteCtx, getPasteRules())
|
|
1974
2453
|
if (!resolved) return
|
|
1975
|
-
|
|
2454
|
+
let { type } = resolved
|
|
1976
2455
|
let props = resolved.props
|
|
1977
2456
|
|
|
2457
|
+
// Component/story URLs → story widget (instead of prototype embed)
|
|
2458
|
+
if (type === 'prototype' && props?.src) {
|
|
2459
|
+
const srcPath = props.src.replace(/[?#].*$/, '').replace(/\/+$/, '')
|
|
2460
|
+
const storyId = storyRouteIndex.get(srcPath)
|
|
2461
|
+
if (storyId) {
|
|
2462
|
+
type = 'story'
|
|
2463
|
+
const parsed = pasteCtx.parseUrl(text)
|
|
2464
|
+
const searchParams = new URLSearchParams(parsed?.search || '')
|
|
2465
|
+
props = {
|
|
2466
|
+
storyId,
|
|
2467
|
+
exportName: searchParams.get('export') || '',
|
|
2468
|
+
width: 600,
|
|
2469
|
+
height: 400,
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
|
|
1978
2474
|
if (type === 'link-preview' && isGitHubEmbedUrl(props?.url || text)) {
|
|
1979
2475
|
const githubUpdates = await buildGitHubPreviewUpdates(props?.url || text)
|
|
1980
2476
|
if (githubUpdates) props = { ...props, ...githubUpdates }
|
|
@@ -1998,8 +2494,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1998
2494
|
}
|
|
1999
2495
|
}
|
|
2000
2496
|
|
|
2497
|
+
// Listen for programmatic paste-url events from the command palette
|
|
2498
|
+
function handlePasteUrl(e) {
|
|
2499
|
+
const text = e.detail?.url?.trim()
|
|
2500
|
+
if (!text) return
|
|
2501
|
+
pasteTextAsWidget(text, pasteCtx)
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2001
2504
|
document.addEventListener('paste', handlePaste)
|
|
2002
|
-
|
|
2505
|
+
document.addEventListener('storyboard:canvas:paste-url', handlePasteUrl)
|
|
2506
|
+
return () => {
|
|
2507
|
+
document.removeEventListener('paste', handlePaste)
|
|
2508
|
+
document.removeEventListener('storyboard:canvas:paste-url', handlePasteUrl)
|
|
2509
|
+
}
|
|
2003
2510
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2004
2511
|
}, [canvasId, undoRedo, localWidgets])
|
|
2005
2512
|
|
|
@@ -2073,13 +2580,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2073
2580
|
if (!previous) return
|
|
2074
2581
|
debouncedSave.cancel()
|
|
2075
2582
|
debouncedSourceSave.cancel()
|
|
2583
|
+
dirtyRef.current = true
|
|
2076
2584
|
setLocalWidgets(previous.widgets)
|
|
2077
2585
|
setLocalSources(previous.sources)
|
|
2078
2586
|
setLocalConnectors(previous.connectors ?? [])
|
|
2079
2587
|
queueWrite(() =>
|
|
2080
|
-
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors })
|
|
2081
|
-
console.error('[canvas] Failed to persist undo:', err)
|
|
2082
|
-
)
|
|
2588
|
+
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors })
|
|
2589
|
+
.catch((err) => console.error('[canvas] Failed to persist undo:', err))
|
|
2083
2590
|
)
|
|
2084
2591
|
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
2085
2592
|
|
|
@@ -2088,22 +2595,24 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2088
2595
|
if (!next) return
|
|
2089
2596
|
debouncedSave.cancel()
|
|
2090
2597
|
debouncedSourceSave.cancel()
|
|
2598
|
+
dirtyRef.current = true
|
|
2091
2599
|
setLocalWidgets(next.widgets)
|
|
2092
2600
|
setLocalSources(next.sources)
|
|
2093
2601
|
setLocalConnectors(next.connectors ?? [])
|
|
2094
2602
|
queueWrite(() =>
|
|
2095
|
-
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors })
|
|
2096
|
-
console.error('[canvas] Failed to persist redo:', err)
|
|
2097
|
-
)
|
|
2603
|
+
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors })
|
|
2604
|
+
.catch((err) => console.error('[canvas] Failed to persist redo:', err))
|
|
2098
2605
|
)
|
|
2099
2606
|
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
2100
2607
|
|
|
2101
|
-
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
|
|
2608
|
+
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z / Cmd+D / Cmd+A)
|
|
2102
2609
|
useEffect(() => {
|
|
2103
2610
|
if (!import.meta.hot) return
|
|
2104
2611
|
function handleKeyDown(e) {
|
|
2105
2612
|
const tag = e.target.tagName
|
|
2106
2613
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
2614
|
+
// Don't intercept shortcuts when the command palette is open
|
|
2615
|
+
if (e.target.closest?.('[cmdk-root]')) return
|
|
2107
2616
|
const mod = e.metaKey || e.ctrlKey
|
|
2108
2617
|
if (mod && e.key === 'z' && !e.shiftKey) {
|
|
2109
2618
|
e.preventDefault()
|
|
@@ -2113,10 +2622,21 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2113
2622
|
e.preventDefault()
|
|
2114
2623
|
handleRedo()
|
|
2115
2624
|
}
|
|
2625
|
+
if (mod && e.key.toLowerCase() === 'd' && e.shiftKey) {
|
|
2626
|
+
e.preventDefault()
|
|
2627
|
+
handleDuplicateWithConnectors()
|
|
2628
|
+
} else if (mod && e.key.toLowerCase() === 'd' && !e.shiftKey) {
|
|
2629
|
+
e.preventDefault()
|
|
2630
|
+
handleDuplicateSelected()
|
|
2631
|
+
}
|
|
2632
|
+
if (mod && e.key === 'a') {
|
|
2633
|
+
e.preventDefault()
|
|
2634
|
+
handleSelectAll()
|
|
2635
|
+
}
|
|
2116
2636
|
}
|
|
2117
2637
|
document.addEventListener('keydown', handleKeyDown)
|
|
2118
2638
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
2119
|
-
}, [handleUndo, handleRedo])
|
|
2639
|
+
}, [handleUndo, handleRedo, handleDuplicateSelected, handleDuplicateWithConnectors, handleSelectAll])
|
|
2120
2640
|
|
|
2121
2641
|
// Listen for undo/redo from CoreUIBar (Svelte toolbar)
|
|
2122
2642
|
useEffect(() => {
|
|
@@ -2286,6 +2806,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2286
2806
|
zoomRef: zoomRef,
|
|
2287
2807
|
setSelectedWidgetIds,
|
|
2288
2808
|
widgets: localWidgets,
|
|
2809
|
+
connectors: localConnectors,
|
|
2289
2810
|
componentEntries,
|
|
2290
2811
|
fallbackSizes: WIDGET_FALLBACK_SIZES,
|
|
2291
2812
|
spaceHeld,
|
|
@@ -2343,6 +2864,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2343
2864
|
id={`jsx-${exportName}`}
|
|
2344
2865
|
data-tc-x={sourcePosition.x}
|
|
2345
2866
|
data-tc-y={sourcePosition.y}
|
|
2867
|
+
data-widget-raised={selectedWidgetIds.has(`jsx-${exportName}`) || undefined}
|
|
2346
2868
|
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
2347
2869
|
{...canvasPrimerAttrs}
|
|
2348
2870
|
style={canvasThemeVars}
|
|
@@ -2379,38 +2901,44 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2379
2901
|
}
|
|
2380
2902
|
|
|
2381
2903
|
// 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
|
|
2382
|
-
//
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2904
|
+
// Stable DOM order — visual stacking is controlled by z-index on the
|
|
2905
|
+
// wrapper div (data-widget-raised), NOT by re-sorting the array.
|
|
2906
|
+
// Re-sorting caused iframe widgets (stories, embeds) to remount and
|
|
2907
|
+
// reload every time selection changed, because moving an iframe node
|
|
2908
|
+
// in the DOM destroys its browsing context.
|
|
2909
|
+
for (const widget of (localWidgets ?? [])) {
|
|
2910
|
+
// In production, render terminal widgets as read-only instead of hiding them
|
|
2911
|
+
const effectiveWidget = (!isLocalDev && (widget.type === 'terminal' || widget.type === 'agent'))
|
|
2912
|
+
? { ...widget, type: 'terminal-read' }
|
|
2913
|
+
: widget
|
|
2390
2914
|
allChildren.push(
|
|
2391
2915
|
<div
|
|
2392
|
-
key={
|
|
2393
|
-
id={
|
|
2394
|
-
data-tc-x={
|
|
2395
|
-
data-tc-y={
|
|
2916
|
+
key={effectiveWidget.id}
|
|
2917
|
+
id={effectiveWidget.id}
|
|
2918
|
+
data-tc-x={effectiveWidget?.position?.x ?? 0}
|
|
2919
|
+
data-tc-y={effectiveWidget?.position?.y ?? 0}
|
|
2920
|
+
data-widget-raised={selectedWidgetIds.has(widget.id) || undefined}
|
|
2396
2921
|
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
2397
2922
|
{...canvasPrimerAttrs}
|
|
2398
2923
|
style={canvasThemeVars}
|
|
2399
2924
|
onClick={isLocalDev ? (e) => {
|
|
2400
2925
|
e.stopPropagation()
|
|
2401
2926
|
if (!e.target.closest('.tc-drag-handle')) {
|
|
2402
|
-
handleWidgetSelect(
|
|
2927
|
+
handleWidgetSelect(effectiveWidget.id, e.shiftKey)
|
|
2403
2928
|
}
|
|
2404
2929
|
} : undefined}
|
|
2405
2930
|
>
|
|
2406
2931
|
<ChromeWrappedWidget
|
|
2407
|
-
widget={
|
|
2932
|
+
widget={effectiveWidget}
|
|
2408
2933
|
selected={selectedWidgetIds.has(widget.id)}
|
|
2409
2934
|
multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
|
|
2935
|
+
connectorCount={localConnectors.filter((c) => c.start?.widgetId === widget.id || c.end?.widgetId === widget.id)}
|
|
2936
|
+
allWidgets={localWidgets}
|
|
2410
2937
|
onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
|
|
2411
2938
|
onDeselect={handleDeselectAll}
|
|
2412
2939
|
onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
|
|
2413
2940
|
onCopy={isLocalDev ? handleWidgetCopy : undefined}
|
|
2941
|
+
onCopyWithConnectors={isLocalDev ? handleWidgetCopyWithConnectors : undefined}
|
|
2414
2942
|
onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
|
|
2415
2943
|
onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
|
|
2416
2944
|
canRefreshGitHub={isLocalDev}
|
|
@@ -2423,19 +2951,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2423
2951
|
|
|
2424
2952
|
const scale = zoom / 100
|
|
2425
2953
|
|
|
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
|
|
2954
|
+
const filteredConnectors = localConnectors
|
|
2433
2955
|
|
|
2434
2956
|
return (
|
|
2435
2957
|
<>
|
|
2436
2958
|
<div className={styles.canvasTitle}>
|
|
2437
2959
|
<a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
|
|
2438
|
-
<Icon name="
|
|
2960
|
+
<Icon name="home" size={16} color="#fff" />
|
|
2439
2961
|
</a>
|
|
2440
2962
|
<CanvasTitleEditable
|
|
2441
2963
|
canvasId={canvasId}
|
|
@@ -2444,9 +2966,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2444
2966
|
isLocalDev={isLocalDev}
|
|
2445
2967
|
/>
|
|
2446
2968
|
<PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
|
|
2447
|
-
{isLocalDev && (
|
|
2448
|
-
<span className={styles.localEditingLabel}>Local editing</span>
|
|
2449
|
-
)}
|
|
2450
2969
|
</div>
|
|
2451
2970
|
<div
|
|
2452
2971
|
ref={scrollRef}
|
|
@@ -2477,8 +2996,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2477
2996
|
<ConnectorLayer
|
|
2478
2997
|
connectors={filteredConnectors}
|
|
2479
2998
|
widgets={localWidgets ?? []}
|
|
2999
|
+
selectedWidgetIds={selectedWidgetIds}
|
|
2480
3000
|
onRemove={isLocalDev ? handleConnectorRemove : undefined}
|
|
2481
|
-
onEndpointDrag={
|
|
3001
|
+
onEndpointDrag={undefined}
|
|
2482
3002
|
dragPreview={connectorDrag}
|
|
2483
3003
|
hidden={widgetDragging}
|
|
2484
3004
|
/>
|