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

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,259 @@ 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 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
+
616
989
  const handleWidgetCopy = useCallback(async (widget) => {
617
990
  // Find the next free offset — check how many copies already exist at +n*40
618
991
  const baseX = widget.position?.x ?? 0
@@ -634,6 +1007,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
634
1007
  })
635
1008
  if (result.success && result.widget) {
636
1009
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1010
+ setSelectedWidgetIds(new Set([result.widget.id]))
637
1011
  }
638
1012
  } catch (err) {
639
1013
  console.error('[canvas] Failed to copy widget:', err)
@@ -718,6 +1092,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
718
1092
  }, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
719
1093
 
720
1094
  const handleItemDragEnd = useCallback((dragId, position) => {
1095
+ setWidgetDragging(false)
721
1096
  if (!dragId || !position) {
722
1097
  clearDragPreview()
723
1098
  return
@@ -861,11 +1236,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
861
1236
  if (!el || loading) return
862
1237
  const saved = pendingScrollRestore.current
863
1238
  if (saved) {
1239
+ console.log('[viewport] restoring saved viewport — zoom:', saved.zoom, 'scroll:', saved.scrollLeft, saved.scrollTop)
864
1240
  // Fresh saved viewport — restore exactly
865
1241
  if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
866
1242
  if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
867
1243
  pendingScrollRestore.current = null
868
1244
  } else {
1245
+ console.log('[viewport] no saved viewport — fitting to objects')
869
1246
  // No saved state or stale — zoom-to-fit all objects
870
1247
  const bounds = computeCanvasBounds(localWidgets, componentEntries)
871
1248
  if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
@@ -949,6 +1326,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
949
1326
  useEffect(() => {
950
1327
  if (viewportInitName.current !== canvasId) return
951
1328
  const el = scrollRef.current
1329
+ console.log('[viewport] saving — zoom:', zoom, 'scroll:', el?.scrollLeft, el?.scrollTop)
952
1330
  // Read current scroll so the zoom entry doesn't zero-out position,
953
1331
  // but the authoritative scroll save comes from the scroll handler.
954
1332
  saveViewportState(canvasId, {
@@ -1179,6 +1557,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1179
1557
  if (result.success && result.widget) {
1180
1558
  undoRedo.snapshot(stateRef.current, 'add')
1181
1559
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1560
+ setSelectedWidgetIds(new Set([result.widget.id]))
1182
1561
  }
1183
1562
  } catch (err) {
1184
1563
  console.error('[canvas] Failed to add widget:', err)
@@ -1199,6 +1578,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1199
1578
  if (result.success && result.widget) {
1200
1579
  undoRedo.snapshot(stateRef.current, 'add')
1201
1580
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1581
+ setSelectedWidgetIds(new Set([result.widget.id]))
1202
1582
  }
1203
1583
  } catch (err) {
1204
1584
  console.error('[canvas] Failed to add story widget:', err)
@@ -1476,6 +1856,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1476
1856
  if (result.success && result.widget) {
1477
1857
  undoRedo.snapshot(stateRef.current, 'add')
1478
1858
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1859
+ setSelectedWidgetIds(new Set([result.widget.id]))
1479
1860
  }
1480
1861
  return true
1481
1862
  } catch (err) {
@@ -1694,8 +2075,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1694
2075
  debouncedSourceSave.cancel()
1695
2076
  setLocalWidgets(previous.widgets)
1696
2077
  setLocalSources(previous.sources)
2078
+ setLocalConnectors(previous.connectors ?? [])
1697
2079
  queueWrite(() =>
1698
- updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
2080
+ updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors }).catch((err) =>
1699
2081
  console.error('[canvas] Failed to persist undo:', err)
1700
2082
  )
1701
2083
  )
@@ -1708,8 +2090,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1708
2090
  debouncedSourceSave.cancel()
1709
2091
  setLocalWidgets(next.widgets)
1710
2092
  setLocalSources(next.sources)
2093
+ setLocalConnectors(next.connectors ?? [])
1711
2094
  queueWrite(() =>
1712
- updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
2095
+ updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors }).catch((err) =>
1713
2096
  console.error('[canvas] Failed to persist redo:', err)
1714
2097
  )
1715
2098
  )
@@ -1996,7 +2379,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1996
2379
  }
1997
2380
 
1998
2381
  // 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
1999
- 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
2000
2390
  allChildren.push(
2001
2391
  <div
2002
2392
  key={widget.id}
@@ -2024,6 +2414,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2024
2414
  onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
2025
2415
  onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
2026
2416
  canRefreshGitHub={isLocalDev}
2417
+ onConnectorDragStart={isLocalDev ? handleConnectorDragStart : undefined}
2027
2418
  readOnly={!isLocalDev}
2028
2419
  />
2029
2420
  </div>
@@ -2032,13 +2423,26 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2032
2423
 
2033
2424
  const scale = zoom / 100
2034
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
+
2035
2434
  return (
2036
2435
  <>
2037
2436
  <div className={styles.canvasTitle}>
2038
2437
  <a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
2039
2438
  <Icon name="iconoir/key-command" size={16} color="#fff" />
2040
2439
  </a>
2041
- <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
+ />
2042
2446
  <PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
2043
2447
  {isLocalDev && (
2044
2448
  <span className={styles.localEditingLabel}>Local editing</span>
@@ -2070,6 +2474,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2070
2474
  ...(spaceHeld ? { pointerEvents: 'none' } : {}),
2071
2475
  }}
2072
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
+ />
2073
2485
  <Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
2074
2486
  {allChildren}
2075
2487
  </Canvas>