@dfosco/storyboard-react 4.1.0-beta.3 → 4.2.0-alpha.10

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.
@@ -6,7 +6,7 @@ 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 } from './widgets/widgetConfig.js'
9
+ import { getFeatures, isResizable, getAnchorState, canAcceptConnection } from './widgets/widgetConfig.js'
10
10
  import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
11
11
  import { getPasteRules } from '@dfosco/storyboard-core'
12
12
  import { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
@@ -23,12 +23,16 @@ import {
23
23
  getCanvas as getCanvasApi,
24
24
  removeWidget as removeWidgetApi,
25
25
  updateCanvas,
26
+ updateFolderMeta,
26
27
  uploadImage,
28
+ addConnector as addConnectorApi,
29
+ removeConnector as removeConnectorApi,
27
30
  } from './canvasApi.js'
28
31
  import PageSelector from './PageSelector.jsx'
29
32
  import Icon from '../Icon.jsx'
30
33
  import { stories as storyIndex } from 'virtual:storyboard-data-index'
31
34
  import styles from './CanvasPage.module.css'
35
+ import ConnectorLayer from './ConnectorLayer.jsx'
32
36
 
33
37
  const ZOOM_MIN = 25
34
38
  const ZOOM_MAX = 200
@@ -128,13 +132,16 @@ function getViewportStorageKey(canvasId) {
128
132
  function loadViewportState(canvasId) {
129
133
  try {
130
134
  const raw = localStorage.getItem(getViewportStorageKey(canvasId))
131
- if (!raw) return null
135
+ if (!raw) { console.log('[viewport] no saved state for', canvasId); return null }
132
136
  const state = JSON.parse(raw)
133
137
  const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
134
- if (Date.now() - timestamp > VIEWPORT_TTL_MS) {
138
+ const age = Date.now() - timestamp
139
+ if (age > VIEWPORT_TTL_MS) {
140
+ console.log('[viewport] stale state for', canvasId, '— age:', Math.round(age / 1000), 's')
135
141
  localStorage.removeItem(getViewportStorageKey(canvasId))
136
142
  return null
137
143
  }
144
+ console.log('[viewport] loaded state for', canvasId, '— age:', Math.round(age / 1000), 's, zoom:', state.zoom, 'scroll:', state.scrollLeft, state.scrollTop)
138
145
  return {
139
146
  zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
140
147
  scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
@@ -303,6 +310,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
303
310
  onCopy,
304
311
  onRefreshGitHub,
305
312
  canRefreshGitHub,
313
+ onConnectorDragStart,
306
314
  readOnly,
307
315
  }) {
308
316
  const widgetRef = useRef(null)
@@ -314,7 +322,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
314
322
  return rawFeatures.map((f) => {
315
323
  // Toggle collapse label and hide when content is short (no github = no collapse)
316
324
  if (f.action === 'toggle-collapse') {
317
- if (!isGitHub) return null
325
+ if (widget.type === 'link-preview' && !isGitHub) return null
318
326
  return {
319
327
  ...f,
320
328
  label: widget.props?.collapsed ? 'Expand height' : 'Collapse height',
@@ -373,6 +381,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
373
381
  onDeselect={onDeselect}
374
382
  onAction={handleAction}
375
383
  onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
384
+ onConnectorDragStart={onConnectorDragStart}
376
385
  readOnly={readOnly}
377
386
  >
378
387
  <WidgetRenderer
@@ -394,10 +403,101 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
394
403
  prev.onDeselect === next.onDeselect &&
395
404
  prev.onUpdate === next.onUpdate &&
396
405
  prev.onRemove === next.onRemove &&
397
- prev.onCopy === next.onCopy
406
+ prev.onCopy === next.onCopy &&
407
+ prev.onConnectorDragStart === next.onConnectorDragStart
398
408
  )
399
409
  })
400
410
 
411
+ /**
412
+ * Editable canvas/folder title — always visible, double-click to edit in dev mode.
413
+ */
414
+ function CanvasTitleEditable({ canvasId, canvasMeta, canvas, isLocalDev }) {
415
+ const [editing, setEditing] = useState(false)
416
+ const [titleValue, setTitleValue] = useState('')
417
+ const inputRef = useRef(null)
418
+ const displayTitle = canvasMeta?.title || canvas?.title || canvasId.split('/').pop()
419
+
420
+ useEffect(() => {
421
+ if (editing && inputRef.current) {
422
+ inputRef.current.focus()
423
+ inputRef.current.select()
424
+ }
425
+ }, [editing])
426
+
427
+ const handleCommit = useCallback(async () => {
428
+ const trimmed = titleValue.trim()
429
+ setEditing(false)
430
+ if (!trimmed || trimmed === displayTitle) return
431
+ try {
432
+ if (canvasId.includes('/')) {
433
+ const folder = canvasId.split('/')[0]
434
+ const result = await updateFolderMeta(folder, trimmed)
435
+ if (result?.renamed && result?.folder) {
436
+ // Folder was renamed on disk — navigate to new route
437
+ const pageName = canvasId.split('/').slice(1).join('/')
438
+ const newCanvasId = `${result.folder}/${pageName}`
439
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
440
+ const targetUrl = `${base}/canvas/${newCanvasId}`
441
+ if (import.meta.hot) {
442
+ const timer = setTimeout(() => { window.location.href = targetUrl }, 3000)
443
+ import.meta.hot.on('vite:beforeFullReload', () => {
444
+ clearTimeout(timer)
445
+ sessionStorage.setItem('sb-pending-navigate', targetUrl)
446
+ })
447
+ } else {
448
+ setTimeout(() => { window.location.href = targetUrl }, 1000)
449
+ }
450
+ return
451
+ }
452
+ } else {
453
+ await updateCanvas(canvasId, { settings: { title: trimmed } })
454
+ }
455
+ // Reload to pick up the updated metadata from the data plugin
456
+ if (import.meta.hot) {
457
+ const timer = setTimeout(() => { window.location.reload() }, 2000)
458
+ import.meta.hot.on('vite:beforeFullReload', () => clearTimeout(timer))
459
+ } else {
460
+ setTimeout(() => { window.location.reload() }, 1000)
461
+ }
462
+ } catch (err) {
463
+ console.error('Failed to update title:', err)
464
+ }
465
+ }, [titleValue, displayTitle, canvasId])
466
+
467
+ const handleDblClick = useCallback(() => {
468
+ if (!isLocalDev) return
469
+ setTitleValue(displayTitle)
470
+ setEditing(true)
471
+ }, [isLocalDev, displayTitle])
472
+
473
+ if (editing) {
474
+ return (
475
+ <input
476
+ ref={inputRef}
477
+ className={styles.canvasTitleEditing}
478
+ type="text"
479
+ value={titleValue}
480
+ onChange={(e) => setTitleValue(e.target.value)}
481
+ onKeyDown={(e) => {
482
+ if (e.key === 'Enter') { e.preventDefault(); handleCommit() }
483
+ if (e.key === 'Escape') { e.preventDefault(); setEditing(false) }
484
+ }}
485
+ onBlur={handleCommit}
486
+ />
487
+ )
488
+ }
489
+
490
+ return (
491
+ <h1
492
+ className={styles.canvasTitleStatic}
493
+ onDoubleClick={handleDblClick}
494
+ style={isLocalDev ? { cursor: 'default' } : undefined}
495
+ >
496
+ {displayTitle}
497
+ </h1>
498
+ )
499
+ }
500
+
401
501
  /**
402
502
  * Generic canvas page component.
403
503
  * Reads canvas data from the index and renders all widgets on a draggable surface.
@@ -411,6 +511,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
411
511
 
412
512
  // Local mutable copy of widgets for instant UI updates
413
513
  const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
514
+ const [localConnectors, setLocalConnectors] = useState(canvas?.connectors ?? [])
414
515
  const [trackedCanvas, setTrackedCanvas] = useState(canvas)
415
516
  const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
416
517
  const initialViewport = loadViewportState(canvasId)
@@ -465,10 +566,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
465
566
 
466
567
  // Undo/redo history — tracks both widgets and sources as a combined snapshot
467
568
  const undoRedo = useUndoRedo()
468
- const stateRef = useRef({ widgets: localWidgets, sources: localSources })
569
+ const stateRef = useRef({ widgets: localWidgets, sources: localSources, connectors: localConnectors })
469
570
  useEffect(() => {
470
- stateRef.current = { widgets: localWidgets, sources: localSources }
471
- }, [localWidgets, localSources])
571
+ stateRef.current = { widgets: localWidgets, sources: localSources, connectors: localConnectors }
572
+ }, [localWidgets, localSources, localConnectors])
472
573
 
473
574
  // Serialized write queue — ensures JSONL events land in the right order
474
575
  const writeQueueRef = useRef(Promise.resolve())
@@ -519,6 +620,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
519
620
  const justDraggedRef = useRef(false)
520
621
 
521
622
  const handleItemDragStart = useCallback((dragId) => {
623
+ setWidgetDragging(true)
522
624
  const ids = selectedIdsRef.current
523
625
  peerArticlesRef.current.clear()
524
626
  if (ids.size <= 1 || !ids.has(dragId)) return
@@ -560,20 +662,25 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
560
662
  }, [])
561
663
 
562
664
  if (canvas !== trackedCanvas) {
665
+ const isCanvasSwitch = trackedCanvas && canvas && trackedCanvas._route !== canvas._route
666
+ console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
563
667
  setTrackedCanvas(canvas)
564
668
  setLocalWidgets(canvas?.widgets ?? null)
669
+ setLocalConnectors(canvas?.connectors ?? [])
565
670
  setLocalSources(canvas?.sources ?? [])
566
671
  setSnapEnabled(canvas?.snapToGrid ?? false)
567
672
  setSnapGridSize(canvas?.gridSize || 40)
568
673
  undoRedo.reset()
569
- // Block saves until the new canvas's viewport is fully restored.
570
- viewportInitName.current = null
571
- const newViewport = loadViewportState(canvasId)
572
- pendingScrollRestore.current = newViewport
573
- // Restore zoom from the new canvas's saved state
574
- const newZoom = newViewport?.zoom ?? 100
575
- zoomRef.current = newZoom
576
- setZoom(newZoom)
674
+ // Only reset viewport state when switching to a different canvas,
675
+ // not when the same canvas refreshes with server data.
676
+ if (isCanvasSwitch) {
677
+ viewportInitName.current = null
678
+ const newViewport = loadViewportState(canvasId)
679
+ pendingScrollRestore.current = newViewport
680
+ const newZoom = newViewport?.zoom ?? 100
681
+ zoomRef.current = newZoom
682
+ setZoom(newZoom)
683
+ }
577
684
  }
578
685
 
579
686
  // Debounced save to server
@@ -606,6 +713,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
606
713
  const handleWidgetRemove = useCallback((widgetId) => {
607
714
  undoRedo.snapshot(stateRef.current, 'remove', widgetId)
608
715
  setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
716
+ // Cascade: remove connectors referencing this widget
717
+ setLocalConnectors((prev) => {
718
+ const orphaned = prev.filter((c) => c.start.widgetId === widgetId || c.end.widgetId === widgetId)
719
+ if (orphaned.length === 0) return prev
720
+ for (const c of orphaned) {
721
+ queueWrite(() =>
722
+ removeConnectorApi(canvasId, c.id).catch((err) =>
723
+ console.error('[canvas] Failed to remove orphaned connector:', err)
724
+ )
725
+ )
726
+ }
727
+ return prev.filter((c) => c.start.widgetId !== widgetId && c.end.widgetId !== widgetId)
728
+ })
609
729
  queueWrite(() =>
610
730
  removeWidgetApi(canvasId, widgetId).catch((err) =>
611
731
  console.error('[canvas] Failed to remove widget:', err)
@@ -613,6 +733,180 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
613
733
  )
614
734
  }, [canvasId, undoRedo])
615
735
 
736
+ const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
737
+ try {
738
+ undoRedo.snapshot(stateRef.current, 'connector-add')
739
+ const result = await addConnectorApi(canvasId, { startWidgetId, startAnchor, endWidgetId, endAnchor })
740
+ if (result.success && result.connector) {
741
+ setLocalConnectors((prev) => [...prev, result.connector])
742
+ }
743
+ } catch (err) {
744
+ console.error('[canvas] Failed to add connector:', err)
745
+ }
746
+ }, [canvasId, undoRedo])
747
+
748
+ const handleConnectorRemove = useCallback((connectorId) => {
749
+ undoRedo.snapshot(stateRef.current, 'connector-remove')
750
+ setLocalConnectors((prev) => prev.filter((c) => c.id !== connectorId))
751
+ queueWrite(() =>
752
+ removeConnectorApi(canvasId, connectorId).catch((err) =>
753
+ console.error('[canvas] Failed to remove connector:', err)
754
+ )
755
+ )
756
+ }, [canvasId, undoRedo])
757
+
758
+ // Connector drag state
759
+ const [connectorDrag, setConnectorDrag] = useState(null)
760
+ const [widgetDragging, setWidgetDragging] = useState(false)
761
+
762
+ const handleConnectorDragStart = useCallback((widgetId, anchor, e) => {
763
+ e.stopPropagation()
764
+ e.preventDefault()
765
+ const scrollEl = scrollRef.current
766
+ if (!scrollEl) return
767
+ const scale = zoomRef.current / 100
768
+ const rect = scrollEl.getBoundingClientRect()
769
+
770
+ const widgets = stateRef.current.widgets ?? []
771
+ const startWidget = widgets.find((w) => w.id === widgetId)
772
+ if (!startWidget) return
773
+
774
+ // Don't start drag from a disabled/unavailable anchor
775
+ const srcAnchorState = getAnchorState(startWidget.type, anchor)
776
+ if (srcAnchorState !== 'available') return
777
+
778
+ const computeAnchorPt = (widget, anch) => {
779
+ let ww, wh
780
+ const el = document.getElementById(widget.id)
781
+ if (el) {
782
+ const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
783
+ if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
784
+ }
785
+ if (!ww) ww = widget.props?.width ?? widget.bounds?.width ?? 270
786
+ if (!wh) wh = widget.props?.height ?? widget.bounds?.height ?? 170
787
+ const px = widget.position?.x ?? 0
788
+ const py = widget.position?.y ?? 0
789
+ switch (anch) {
790
+ case 'top': return { x: px + ww / 2, y: py }
791
+ case 'bottom': return { x: px + ww / 2, y: py + wh }
792
+ case 'left': return { x: px, y: py + wh / 2 }
793
+ case 'right': return { x: px + ww, y: py + wh / 2 }
794
+ default: return { x: px + ww / 2, y: py + wh / 2 }
795
+ }
796
+ }
797
+
798
+ const startPt = computeAnchorPt(startWidget, anchor)
799
+
800
+ const toCanvasPoint = (clientX, clientY) => ({
801
+ x: (scrollEl.scrollLeft + clientX - rect.left) / scale,
802
+ y: (scrollEl.scrollTop + clientY - rect.top) / scale,
803
+ })
804
+
805
+ // Find nearest anchor on any other widget within a rectangular snap zone.
806
+ // Each anchor has a 30px-wide strip (15px each side) extending from the widget edge.
807
+ const SNAP_EXTEND = 15
808
+ const SNAP_DEPTH = 40
809
+ const SNAP_CROSS = 20 // perpendicular expansion so you can approach from any direction
810
+ const sourceType = startWidget.type
811
+ const findNearestAnchor = (canvasPt) => {
812
+ const currentWidgets = stateRef.current.widgets ?? []
813
+ let best = null
814
+ let bestDist = Infinity
815
+ for (const w of currentWidgets) {
816
+ if (w.id === widgetId) continue
817
+ if (!canAcceptConnection(w.type, sourceType)) continue
818
+
819
+ let ww, wh
820
+ const el = document.getElementById(w.id)
821
+ if (el) {
822
+ const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
823
+ if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
824
+ }
825
+ if (!ww) ww = w.props?.width ?? w.bounds?.width ?? 270
826
+ if (!wh) wh = w.props?.height ?? w.bounds?.height ?? 170
827
+ const wx = w.position?.x ?? 0
828
+ const wy = w.position?.y ?? 0
829
+
830
+ for (const anch of ['top', 'bottom', 'left', 'right']) {
831
+ const anchorState = getAnchorState(w.type, anch)
832
+ if (anchorState !== 'available') continue
833
+
834
+ // Build a rectangular hit zone for this anchor
835
+ let inZone = false
836
+ if (anch === 'top') {
837
+ inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
838
+ canvasPt.y >= wy - SNAP_DEPTH && canvasPt.y <= wy + SNAP_EXTEND
839
+ } else if (anch === 'bottom') {
840
+ inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
841
+ canvasPt.y >= wy + wh - SNAP_EXTEND && canvasPt.y <= wy + wh + SNAP_DEPTH
842
+ } else if (anch === 'left') {
843
+ inZone = canvasPt.x >= wx - SNAP_DEPTH && canvasPt.x <= wx + SNAP_EXTEND &&
844
+ canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
845
+ } else if (anch === 'right') {
846
+ inZone = canvasPt.x >= wx + ww - SNAP_EXTEND && canvasPt.x <= wx + ww + SNAP_DEPTH &&
847
+ canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
848
+ }
849
+ if (!inZone) continue
850
+
851
+ const pt = computeAnchorPt(w, anch)
852
+ const dist = Math.hypot(pt.x - canvasPt.x, pt.y - canvasPt.y)
853
+ if (dist < bestDist) {
854
+ bestDist = dist
855
+ best = { widgetId: w.id, anchor: anch, pt }
856
+ }
857
+ }
858
+ }
859
+ return best
860
+ }
861
+
862
+ const cursorPt = toCanvasPoint(e.clientX, e.clientY)
863
+ const snap = findNearestAnchor(cursorPt)
864
+ setConnectorDrag({
865
+ startWidgetId: widgetId,
866
+ startAnchor: anchor,
867
+ startPt,
868
+ endPt: snap ? snap.pt : cursorPt,
869
+ endAnchor: snap ? snap.anchor : anchor,
870
+ snapTarget: snap,
871
+ })
872
+
873
+ const handlePointerMove = (moveE) => {
874
+ const pt = toCanvasPoint(moveE.clientX, moveE.clientY)
875
+ const nearSnap = findNearestAnchor(pt)
876
+ setConnectorDrag((prev) => prev ? {
877
+ ...prev,
878
+ endPt: nearSnap ? nearSnap.pt : pt,
879
+ endAnchor: nearSnap ? nearSnap.anchor : prev.startAnchor,
880
+ snapTarget: nearSnap,
881
+ } : null)
882
+ }
883
+
884
+ const handlePointerUp = (upE) => {
885
+ document.removeEventListener('pointermove', handlePointerMove)
886
+ document.removeEventListener('pointerup', handlePointerUp)
887
+
888
+ const pt = toCanvasPoint(upE.clientX, upE.clientY)
889
+ const nearSnap = findNearestAnchor(pt)
890
+
891
+ if (nearSnap) {
892
+ handleConnectorAdd({
893
+ startWidgetId: widgetId,
894
+ startAnchor: anchor,
895
+ endWidgetId: nearSnap.widgetId,
896
+ endAnchor: nearSnap.anchor,
897
+ })
898
+ }
899
+ setConnectorDrag(null)
900
+ }
901
+
902
+ document.addEventListener('pointermove', handlePointerMove)
903
+ document.addEventListener('pointerup', handlePointerUp)
904
+ }, [handleConnectorAdd])
905
+
906
+ // Endpoint drag removed — dragging from a filled anchor now always
907
+ // creates a new connection via handleConnectorDragStart instead of
908
+ // repositioning the existing one.
909
+
616
910
  const handleWidgetCopy = useCallback(async (widget) => {
617
911
  // Find the next free offset — check how many copies already exist at +n*40
618
912
  const baseX = widget.position?.x ?? 0
@@ -634,6 +928,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
634
928
  })
635
929
  if (result.success && result.widget) {
636
930
  setLocalWidgets((prev) => [...(prev || []), result.widget])
931
+ setSelectedWidgetIds(new Set([result.widget.id]))
637
932
  }
638
933
  } catch (err) {
639
934
  console.error('[canvas] Failed to copy widget:', err)
@@ -718,6 +1013,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
718
1013
  }, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
719
1014
 
720
1015
  const handleItemDragEnd = useCallback((dragId, position) => {
1016
+ setWidgetDragging(false)
721
1017
  if (!dragId || !position) {
722
1018
  clearDragPreview()
723
1019
  return
@@ -861,11 +1157,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
861
1157
  if (!el || loading) return
862
1158
  const saved = pendingScrollRestore.current
863
1159
  if (saved) {
1160
+ console.log('[viewport] restoring saved viewport — zoom:', saved.zoom, 'scroll:', saved.scrollLeft, saved.scrollTop)
864
1161
  // Fresh saved viewport — restore exactly
865
1162
  if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
866
1163
  if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
867
1164
  pendingScrollRestore.current = null
868
1165
  } else {
1166
+ console.log('[viewport] no saved viewport — fitting to objects')
869
1167
  // No saved state or stale — zoom-to-fit all objects
870
1168
  const bounds = computeCanvasBounds(localWidgets, componentEntries)
871
1169
  if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
@@ -949,6 +1247,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
949
1247
  useEffect(() => {
950
1248
  if (viewportInitName.current !== canvasId) return
951
1249
  const el = scrollRef.current
1250
+ console.log('[viewport] saving — zoom:', zoom, 'scroll:', el?.scrollLeft, el?.scrollTop)
952
1251
  // Read current scroll so the zoom entry doesn't zero-out position,
953
1252
  // but the authoritative scroll save comes from the scroll handler.
954
1253
  saveViewportState(canvasId, {
@@ -1179,6 +1478,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1179
1478
  if (result.success && result.widget) {
1180
1479
  undoRedo.snapshot(stateRef.current, 'add')
1181
1480
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1481
+ setSelectedWidgetIds(new Set([result.widget.id]))
1182
1482
  }
1183
1483
  } catch (err) {
1184
1484
  console.error('[canvas] Failed to add widget:', err)
@@ -1199,6 +1499,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1199
1499
  if (result.success && result.widget) {
1200
1500
  undoRedo.snapshot(stateRef.current, 'add')
1201
1501
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1502
+ setSelectedWidgetIds(new Set([result.widget.id]))
1202
1503
  }
1203
1504
  } catch (err) {
1204
1505
  console.error('[canvas] Failed to add story widget:', err)
@@ -1476,6 +1777,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1476
1777
  if (result.success && result.widget) {
1477
1778
  undoRedo.snapshot(stateRef.current, 'add')
1478
1779
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1780
+ setSelectedWidgetIds(new Set([result.widget.id]))
1479
1781
  }
1480
1782
  return true
1481
1783
  } catch (err) {
@@ -1694,8 +1996,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1694
1996
  debouncedSourceSave.cancel()
1695
1997
  setLocalWidgets(previous.widgets)
1696
1998
  setLocalSources(previous.sources)
1999
+ setLocalConnectors(previous.connectors ?? [])
1697
2000
  queueWrite(() =>
1698
- updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
2001
+ updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors }).catch((err) =>
1699
2002
  console.error('[canvas] Failed to persist undo:', err)
1700
2003
  )
1701
2004
  )
@@ -1708,8 +2011,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1708
2011
  debouncedSourceSave.cancel()
1709
2012
  setLocalWidgets(next.widgets)
1710
2013
  setLocalSources(next.sources)
2014
+ setLocalConnectors(next.connectors ?? [])
1711
2015
  queueWrite(() =>
1712
- updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
2016
+ updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors }).catch((err) =>
1713
2017
  console.error('[canvas] Failed to persist redo:', err)
1714
2018
  )
1715
2019
  )
@@ -1996,7 +2300,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1996
2300
  }
1997
2301
 
1998
2302
  // 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
1999
- for (const widget of (localWidgets ?? [])) {
2303
+ // Sort so selected widgets render last (visually on top via DOM order)
2304
+ const sortedWidgets = (localWidgets ?? []).slice().sort((a, b) => {
2305
+ const aSelected = selectedWidgetIds.has(a.id) ? 1 : 0
2306
+ const bSelected = selectedWidgetIds.has(b.id) ? 1 : 0
2307
+ return aSelected - bSelected
2308
+ })
2309
+ for (const widget of sortedWidgets) {
2310
+ if (!isLocalDev && widget.type === 'terminal') continue
2000
2311
  allChildren.push(
2001
2312
  <div
2002
2313
  key={widget.id}
@@ -2024,6 +2335,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2024
2335
  onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
2025
2336
  onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
2026
2337
  canRefreshGitHub={isLocalDev}
2338
+ onConnectorDragStart={isLocalDev ? handleConnectorDragStart : undefined}
2027
2339
  readOnly={!isLocalDev}
2028
2340
  />
2029
2341
  </div>
@@ -2032,13 +2344,26 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2032
2344
 
2033
2345
  const scale = zoom / 100
2034
2346
 
2347
+ const terminalWidgetIds = !isLocalDev
2348
+ ? new Set((localWidgets ?? []).filter(w => w.type === 'terminal').map(w => w.id))
2349
+ : null
2350
+
2351
+ const filteredConnectors = terminalWidgetIds?.size
2352
+ ? localConnectors.filter(c => !terminalWidgetIds.has(c.startWidgetId) && !terminalWidgetIds.has(c.endWidgetId))
2353
+ : localConnectors
2354
+
2035
2355
  return (
2036
2356
  <>
2037
2357
  <div className={styles.canvasTitle}>
2038
2358
  <a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
2039
2359
  <Icon name="iconoir/key-command" size={16} color="#fff" />
2040
2360
  </a>
2041
- <h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || canvasId.split('/').pop()}</h1>
2361
+ <CanvasTitleEditable
2362
+ canvasId={canvasId}
2363
+ canvasMeta={canvasMeta}
2364
+ canvas={canvas}
2365
+ isLocalDev={isLocalDev}
2366
+ />
2042
2367
  <PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
2043
2368
  {isLocalDev && (
2044
2369
  <span className={styles.localEditingLabel}>Local editing</span>
@@ -2070,6 +2395,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2070
2395
  ...(spaceHeld ? { pointerEvents: 'none' } : {}),
2071
2396
  }}
2072
2397
  >
2398
+ <ConnectorLayer
2399
+ connectors={filteredConnectors}
2400
+ widgets={localWidgets ?? []}
2401
+ onRemove={isLocalDev ? handleConnectorRemove : undefined}
2402
+ onEndpointDrag={undefined}
2403
+ dragPreview={connectorDrag}
2404
+ hidden={widgetDragging}
2405
+ />
2073
2406
  <Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
2074
2407
  {allChildren}
2075
2408
  </Canvas>
@@ -29,6 +29,7 @@
29
29
  }
30
30
 
31
31
  .canvasZoom {
32
+ position: relative;
32
33
  min-width: 100%;
33
34
  min-height: 100%;
34
35
  }
@@ -76,15 +77,36 @@
76
77
  white-space: nowrap;
77
78
  }
78
79
 
80
+ .canvasTitleEditing {
81
+ font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
82
+ font-size: 14px;
83
+ font-weight: 600;
84
+ color: var(--fgColor-default, #1f2328);
85
+ margin: 0;
86
+ padding: 3px 7px;
87
+ white-space: nowrap;
88
+ border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
89
+ border-radius: 4px;
90
+ background: var(--bgColor-default, #fff);
91
+ outline: none;
92
+ }
93
+
94
+ .canvasTitleEditing:focus {
95
+ border-color: var(--focus-outlineColor, #0969da);
96
+ box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.3);
97
+ }
98
+
79
99
  /* Remove tiny-canvas wrapper clipping — widgets handle their own overflow/radius */
80
100
  :global(.tc-draggable-inner) {
81
101
  overflow: visible;
82
102
  }
83
103
 
84
- /* Elevate stacking context for hovered/selected widgets so their chrome
85
- (toolbar, menus, selection outline) renders above sibling widgets. */
86
- :global(.tc-drag:has([data-tc-elevated])) {
87
- z-index: 1;
104
+ /* Each widget gets its own stacking context so internal z-index
105
+ (chrome toolbars, resize handles, overlays) never leaks to siblings.
106
+ Visual stacking between widgets is controlled by DOM order — selected
107
+ widgets are rendered last so they appear on top. */
108
+ :global(.tc-drag) {
109
+ isolation: isolate;
88
110
  }
89
111
 
90
112
  .localEditingLabel {