@dfosco/storyboard-react 4.1.0 → 4.2.0-beta.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.
@@ -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
@@ -306,6 +310,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
306
310
  onCopy,
307
311
  onRefreshGitHub,
308
312
  canRefreshGitHub,
313
+ onConnectorDragStart,
309
314
  readOnly,
310
315
  }) {
311
316
  const widgetRef = useRef(null)
@@ -317,7 +322,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
317
322
  return rawFeatures.map((f) => {
318
323
  // Toggle collapse label and hide when content is short (no github = no collapse)
319
324
  if (f.action === 'toggle-collapse') {
320
- if (!isGitHub) return null
325
+ if (widget.type === 'link-preview' && !isGitHub) return null
321
326
  return {
322
327
  ...f,
323
328
  label: widget.props?.collapsed ? 'Expand height' : 'Collapse height',
@@ -376,6 +381,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
376
381
  onDeselect={onDeselect}
377
382
  onAction={handleAction}
378
383
  onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
384
+ onConnectorDragStart={onConnectorDragStart}
379
385
  readOnly={readOnly}
380
386
  >
381
387
  <WidgetRenderer
@@ -397,10 +403,101 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
397
403
  prev.onDeselect === next.onDeselect &&
398
404
  prev.onUpdate === next.onUpdate &&
399
405
  prev.onRemove === next.onRemove &&
400
- prev.onCopy === next.onCopy
406
+ prev.onCopy === next.onCopy &&
407
+ prev.onConnectorDragStart === next.onConnectorDragStart
401
408
  )
402
409
  })
403
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
+
404
501
  /**
405
502
  * Generic canvas page component.
406
503
  * Reads canvas data from the index and renders all widgets on a draggable surface.
@@ -414,6 +511,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
414
511
 
415
512
  // Local mutable copy of widgets for instant UI updates
416
513
  const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
514
+ const [localConnectors, setLocalConnectors] = useState(canvas?.connectors ?? [])
417
515
  const [trackedCanvas, setTrackedCanvas] = useState(canvas)
418
516
  const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
419
517
  const initialViewport = loadViewportState(canvasId)
@@ -468,10 +566,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
468
566
 
469
567
  // Undo/redo history — tracks both widgets and sources as a combined snapshot
470
568
  const undoRedo = useUndoRedo()
471
- const stateRef = useRef({ widgets: localWidgets, sources: localSources })
569
+ const stateRef = useRef({ widgets: localWidgets, sources: localSources, connectors: localConnectors })
472
570
  useEffect(() => {
473
- stateRef.current = { widgets: localWidgets, sources: localSources }
474
- }, [localWidgets, localSources])
571
+ stateRef.current = { widgets: localWidgets, sources: localSources, connectors: localConnectors }
572
+ }, [localWidgets, localSources, localConnectors])
475
573
 
476
574
  // Serialized write queue — ensures JSONL events land in the right order
477
575
  const writeQueueRef = useRef(Promise.resolve())
@@ -522,6 +620,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
522
620
  const justDraggedRef = useRef(false)
523
621
 
524
622
  const handleItemDragStart = useCallback((dragId) => {
623
+ setWidgetDragging(true)
525
624
  const ids = selectedIdsRef.current
526
625
  peerArticlesRef.current.clear()
527
626
  if (ids.size <= 1 || !ids.has(dragId)) return
@@ -567,6 +666,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
567
666
  console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
568
667
  setTrackedCanvas(canvas)
569
668
  setLocalWidgets(canvas?.widgets ?? null)
669
+ setLocalConnectors(canvas?.connectors ?? [])
570
670
  setLocalSources(canvas?.sources ?? [])
571
671
  setSnapEnabled(canvas?.snapToGrid ?? false)
572
672
  setSnapGridSize(canvas?.gridSize || 40)
@@ -613,6 +713,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
613
713
  const handleWidgetRemove = useCallback((widgetId) => {
614
714
  undoRedo.snapshot(stateRef.current, 'remove', widgetId)
615
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
+ })
616
729
  queueWrite(() =>
617
730
  removeWidgetApi(canvasId, widgetId).catch((err) =>
618
731
  console.error('[canvas] Failed to remove widget:', err)
@@ -620,6 +733,259 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
620
733
  )
621
734
  }, [canvasId, undoRedo])
622
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 snap distance
806
+ const SNAP_DIST = 40
807
+ const sourceType = startWidget.type
808
+ const findNearestAnchor = (canvasPt) => {
809
+ const currentWidgets = stateRef.current.widgets ?? []
810
+ let best = null
811
+ let bestDist = SNAP_DIST
812
+ for (const w of currentWidgets) {
813
+ if (w.id === widgetId) continue
814
+ // Check if this widget type accepts connections from the source type
815
+ if (!canAcceptConnection(w.type, sourceType)) continue
816
+ for (const anch of ['top', 'bottom', 'left', 'right']) {
817
+ // Skip unavailable or disabled anchors
818
+ const anchorState = getAnchorState(w.type, anch)
819
+ if (anchorState !== 'available') continue
820
+ const pt = computeAnchorPt(w, anch)
821
+ const dist = Math.hypot(pt.x - canvasPt.x, pt.y - canvasPt.y)
822
+ if (dist < bestDist) {
823
+ bestDist = dist
824
+ best = { widgetId: w.id, anchor: anch, pt }
825
+ }
826
+ }
827
+ }
828
+ return best
829
+ }
830
+
831
+ const cursorPt = toCanvasPoint(e.clientX, e.clientY)
832
+ const snap = findNearestAnchor(cursorPt)
833
+ setConnectorDrag({
834
+ startWidgetId: widgetId,
835
+ startAnchor: anchor,
836
+ startPt,
837
+ endPt: snap ? snap.pt : cursorPt,
838
+ endAnchor: snap ? snap.anchor : anchor,
839
+ snapTarget: snap,
840
+ })
841
+
842
+ const handlePointerMove = (moveE) => {
843
+ const pt = toCanvasPoint(moveE.clientX, moveE.clientY)
844
+ const nearSnap = findNearestAnchor(pt)
845
+ setConnectorDrag((prev) => prev ? {
846
+ ...prev,
847
+ endPt: nearSnap ? nearSnap.pt : pt,
848
+ endAnchor: nearSnap ? nearSnap.anchor : prev.startAnchor,
849
+ snapTarget: nearSnap,
850
+ } : null)
851
+ }
852
+
853
+ const handlePointerUp = (upE) => {
854
+ document.removeEventListener('pointermove', handlePointerMove)
855
+ document.removeEventListener('pointerup', handlePointerUp)
856
+
857
+ const pt = toCanvasPoint(upE.clientX, upE.clientY)
858
+ const nearSnap = findNearestAnchor(pt)
859
+
860
+ if (nearSnap) {
861
+ handleConnectorAdd({
862
+ startWidgetId: widgetId,
863
+ startAnchor: anchor,
864
+ endWidgetId: nearSnap.widgetId,
865
+ endAnchor: nearSnap.anchor,
866
+ })
867
+ }
868
+ setConnectorDrag(null)
869
+ }
870
+
871
+ document.addEventListener('pointermove', handlePointerMove)
872
+ document.addEventListener('pointerup', handlePointerUp)
873
+ }, [handleConnectorAdd])
874
+
875
+ // Drag an existing connector endpoint to reconnect or remove
876
+ const handleEndpointDrag = useCallback((connector, endpoint, e) => {
877
+ e.stopPropagation()
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
889
+
890
+ const computeAnchorPtLocal = (widget, anch) => {
891
+ let ww, wh
892
+ const el = document.getElementById(widget.id)
893
+ if (el) {
894
+ const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
895
+ if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
896
+ }
897
+ if (!ww) ww = widget.props?.width ?? widget.bounds?.width ?? 270
898
+ if (!wh) wh = widget.props?.height ?? widget.bounds?.height ?? 170
899
+ const px = widget.position?.x ?? 0
900
+ const py = widget.position?.y ?? 0
901
+ switch (anch) {
902
+ case 'top': return { x: px + ww / 2, y: py }
903
+ case 'bottom': return { x: px + ww / 2, y: py + wh }
904
+ case 'left': return { x: px, y: py + wh / 2 }
905
+ case 'right': return { x: px + ww, y: py + wh / 2 }
906
+ default: return { x: px + ww / 2, y: py + wh / 2 }
907
+ }
908
+ }
909
+
910
+ const fixedPt = computeAnchorPtLocal(fixedWidget, fixedSide.anchor)
911
+ const fixedWidgetId = fixedSide.widgetId
912
+
913
+ const toCanvasPoint = (clientX, clientY) => ({
914
+ x: (scrollEl.scrollLeft + clientX - rect.left) / scale,
915
+ y: (scrollEl.scrollTop + clientY - rect.top) / scale,
916
+ })
917
+
918
+ const SNAP_DIST = 40
919
+ const sourceType = fixedWidget.type
920
+ const findNearestAnchorLocal = (canvasPt) => {
921
+ const currentWidgets = stateRef.current.widgets ?? []
922
+ let best = null
923
+ let bestDist = SNAP_DIST
924
+ for (const w of currentWidgets) {
925
+ if (w.id === fixedWidgetId) continue
926
+ if (!canAcceptConnection(w.type, sourceType)) continue
927
+ for (const anch of ['top', 'bottom', 'left', 'right']) {
928
+ const anchorState = getAnchorState(w.type, anch)
929
+ if (anchorState !== 'available') continue
930
+ const pt = computeAnchorPtLocal(w, anch)
931
+ const dist = Math.hypot(pt.x - canvasPt.x, pt.y - canvasPt.y)
932
+ if (dist < bestDist) {
933
+ bestDist = dist
934
+ best = { widgetId: w.id, anchor: anch, pt }
935
+ }
936
+ }
937
+ }
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
+
952
+ const handlePointerMove = (moveE) => {
953
+ const pt = toCanvasPoint(moveE.clientX, moveE.clientY)
954
+ const nearSnap = findNearestAnchorLocal(pt)
955
+ setConnectorDrag((prev) => prev ? {
956
+ ...prev,
957
+ endPt: nearSnap ? nearSnap.pt : pt,
958
+ endAnchor: nearSnap ? nearSnap.anchor : prev.startAnchor,
959
+ snapTarget: nearSnap,
960
+ } : null)
961
+ }
962
+
963
+ const handlePointerUp = (upE) => {
964
+ document.removeEventListener('pointermove', handlePointerMove)
965
+ document.removeEventListener('pointerup', handlePointerUp)
966
+
967
+ const pt = toCanvasPoint(upE.clientX, upE.clientY)
968
+ const nearSnap = findNearestAnchorLocal(pt)
969
+
970
+ // Always remove the old connector
971
+ handleConnectorRemove(connector.id)
972
+
973
+ // If snapped to a new anchor, create a new connector
974
+ if (nearSnap) {
975
+ handleConnectorAdd({
976
+ startWidgetId: fixedWidgetId,
977
+ startAnchor: fixedSide.anchor,
978
+ endWidgetId: nearSnap.widgetId,
979
+ endAnchor: nearSnap.anchor,
980
+ })
981
+ }
982
+ setConnectorDrag(null)
983
+ }
984
+
985
+ document.addEventListener('pointermove', handlePointerMove)
986
+ document.addEventListener('pointerup', handlePointerUp)
987
+ }, [handleConnectorAdd, handleConnectorRemove])
988
+
623
989
  const handleWidgetCopy = useCallback(async (widget) => {
624
990
  // Find the next free offset — check how many copies already exist at +n*40
625
991
  const baseX = widget.position?.x ?? 0
@@ -641,6 +1007,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
641
1007
  })
642
1008
  if (result.success && result.widget) {
643
1009
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1010
+ setSelectedWidgetIds(new Set([result.widget.id]))
644
1011
  }
645
1012
  } catch (err) {
646
1013
  console.error('[canvas] Failed to copy widget:', err)
@@ -725,6 +1092,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
725
1092
  }, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
726
1093
 
727
1094
  const handleItemDragEnd = useCallback((dragId, position) => {
1095
+ setWidgetDragging(false)
728
1096
  if (!dragId || !position) {
729
1097
  clearDragPreview()
730
1098
  return
@@ -1189,6 +1557,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1189
1557
  if (result.success && result.widget) {
1190
1558
  undoRedo.snapshot(stateRef.current, 'add')
1191
1559
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1560
+ setSelectedWidgetIds(new Set([result.widget.id]))
1192
1561
  }
1193
1562
  } catch (err) {
1194
1563
  console.error('[canvas] Failed to add widget:', err)
@@ -1209,6 +1578,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1209
1578
  if (result.success && result.widget) {
1210
1579
  undoRedo.snapshot(stateRef.current, 'add')
1211
1580
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1581
+ setSelectedWidgetIds(new Set([result.widget.id]))
1212
1582
  }
1213
1583
  } catch (err) {
1214
1584
  console.error('[canvas] Failed to add story widget:', err)
@@ -1486,6 +1856,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1486
1856
  if (result.success && result.widget) {
1487
1857
  undoRedo.snapshot(stateRef.current, 'add')
1488
1858
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1859
+ setSelectedWidgetIds(new Set([result.widget.id]))
1489
1860
  }
1490
1861
  return true
1491
1862
  } catch (err) {
@@ -1704,8 +2075,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1704
2075
  debouncedSourceSave.cancel()
1705
2076
  setLocalWidgets(previous.widgets)
1706
2077
  setLocalSources(previous.sources)
2078
+ setLocalConnectors(previous.connectors ?? [])
1707
2079
  queueWrite(() =>
1708
- updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
2080
+ updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors }).catch((err) =>
1709
2081
  console.error('[canvas] Failed to persist undo:', err)
1710
2082
  )
1711
2083
  )
@@ -1718,8 +2090,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1718
2090
  debouncedSourceSave.cancel()
1719
2091
  setLocalWidgets(next.widgets)
1720
2092
  setLocalSources(next.sources)
2093
+ setLocalConnectors(next.connectors ?? [])
1721
2094
  queueWrite(() =>
1722
- updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
2095
+ updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors }).catch((err) =>
1723
2096
  console.error('[canvas] Failed to persist redo:', err)
1724
2097
  )
1725
2098
  )
@@ -2006,7 +2379,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2006
2379
  }
2007
2380
 
2008
2381
  // 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
2009
- for (const widget of (localWidgets ?? [])) {
2382
+ // Sort so selected widgets render last (visually on top via DOM order)
2383
+ const sortedWidgets = (localWidgets ?? []).slice().sort((a, b) => {
2384
+ const aSelected = selectedWidgetIds.has(a.id) ? 1 : 0
2385
+ const bSelected = selectedWidgetIds.has(b.id) ? 1 : 0
2386
+ return aSelected - bSelected
2387
+ })
2388
+ for (const widget of sortedWidgets) {
2389
+ if (!isLocalDev && widget.type === 'terminal') continue
2010
2390
  allChildren.push(
2011
2391
  <div
2012
2392
  key={widget.id}
@@ -2034,6 +2414,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2034
2414
  onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
2035
2415
  onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
2036
2416
  canRefreshGitHub={isLocalDev}
2417
+ onConnectorDragStart={isLocalDev ? handleConnectorDragStart : undefined}
2037
2418
  readOnly={!isLocalDev}
2038
2419
  />
2039
2420
  </div>
@@ -2042,13 +2423,26 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2042
2423
 
2043
2424
  const scale = zoom / 100
2044
2425
 
2426
+ const terminalWidgetIds = !isLocalDev
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
2433
+
2045
2434
  return (
2046
2435
  <>
2047
2436
  <div className={styles.canvasTitle}>
2048
2437
  <a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
2049
2438
  <Icon name="iconoir/key-command" size={16} color="#fff" />
2050
2439
  </a>
2051
- <h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || canvasId.split('/').pop()}</h1>
2440
+ <CanvasTitleEditable
2441
+ canvasId={canvasId}
2442
+ canvasMeta={canvasMeta}
2443
+ canvas={canvas}
2444
+ isLocalDev={isLocalDev}
2445
+ />
2052
2446
  <PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
2053
2447
  {isLocalDev && (
2054
2448
  <span className={styles.localEditingLabel}>Local editing</span>
@@ -2080,6 +2474,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2080
2474
  ...(spaceHeld ? { pointerEvents: 'none' } : {}),
2081
2475
  }}
2082
2476
  >
2477
+ <ConnectorLayer
2478
+ connectors={filteredConnectors}
2479
+ widgets={localWidgets ?? []}
2480
+ onRemove={isLocalDev ? handleConnectorRemove : undefined}
2481
+ onEndpointDrag={isLocalDev ? handleEndpointDrag : undefined}
2482
+ dragPreview={connectorDrag}
2483
+ hidden={widgetDragging}
2484
+ />
2083
2485
  <Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
2084
2486
  {allChildren}
2085
2487
  </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 {