@dfosco/storyboard-react 4.1.0 → 4.2.0-alpha.11

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 CHANGED
@@ -1,18 +1,19 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.1.0",
3
+ "version": "4.2.0-alpha.11",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@base-ui/react": "^1.4.0",
7
- "@dfosco/storyboard-core": "4.1.0",
8
- "@dfosco/tiny-canvas": "4.1.0",
7
+ "@dfosco/storyboard-core": "4.2.0-alpha.11",
8
+ "@dfosco/tiny-canvas": "4.2.0-alpha.11",
9
9
  "@neodrag/react": "^2.3.1",
10
10
  "glob": "^11.0.0",
11
11
  "jsonc-parser": "^3.3.1",
12
12
  "remark": "^15.0.1",
13
13
  "remark-gfm": "^4.0.1",
14
14
  "react-cmdk": "^1.3.9",
15
- "remark-html": "^16.0.1"
15
+ "remark-html": "^16.0.1",
16
+ "ghostty-web": "^0.4.0"
16
17
  },
17
18
  "license": "MIT",
18
19
  "repository": {
@@ -20,6 +20,7 @@ import {
20
20
  getTheme,
21
21
  isExcludedByRoute,
22
22
  } from '@dfosco/storyboard-core'
23
+ import { widgetTypes } from '../canvas/widgets/widgetConfig.js'
23
24
  import CreateDialog from './CreateDialog.jsx'
24
25
  import BranchBar from '../BranchBar/BranchBar.jsx'
25
26
  import AuthModal from '../AuthModal/AuthModal.jsx'
@@ -287,15 +288,78 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
287
288
  const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
288
289
  if (!isLocalDev) return null
289
290
  const createItems = [
290
- { id: 'create:canvas', children: 'New Canvas', keywords: ['create', 'canvas', 'new', 'board'], showType: false, onClick: () => onCreateAction?.('Canvas') },
291
- { id: 'create:prototype', children: 'New Prototype', keywords: ['create', 'prototype', 'new', 'page'], showType: false, onClick: () => onCreateAction?.('Prototype') },
292
- { id: 'create:component', children: 'New Component', keywords: ['create', 'component', 'new', 'story'], showType: false, onClick: () => onCreateAction?.('Component') },
293
- { id: 'create:flow', children: 'New Prototype Flow', keywords: ['create', 'flow', 'new', 'data'], showType: false, onClick: () => onCreateAction?.('Flow') },
294
- { id: 'create:page', children: 'New Prototype Page', keywords: ['create', 'page', 'new'], showType: false, onClick: () => onCreateAction?.('Page') },
291
+ { id: 'create:canvas', children: 'Canvas', keywords: ['create', 'canvas', 'new', 'board'], showType: false, onClick: () => onCreateAction?.('Canvas') },
292
+ { id: 'create:prototype', children: 'Prototype', keywords: ['create', 'prototype', 'new', 'page'], showType: false, onClick: () => onCreateAction?.('Prototype') },
293
+ { id: 'create:component', children: 'Component', keywords: ['create', 'component', 'new', 'story'], showType: false, onClick: () => onCreateAction?.('Component') },
294
+ { id: 'create:flow', children: 'Prototype Flow', keywords: ['create', 'flow', 'new', 'data'], showType: false, onClick: () => onCreateAction?.('Flow') },
295
+ { id: 'create:page', children: 'Prototype Page', keywords: ['create', 'page', 'new'], showType: false, onClick: () => onCreateAction?.('Page') },
295
296
  ]
296
297
  return { group: { heading: section.title, id: `cfg:${section.id}`, items: createItems } }
297
298
  }
298
299
 
300
+ // --- Create widget source (all canvas widget types) ---
301
+ if (section.source === 'create-widget') {
302
+ const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
303
+ if (!isLocalDev) return null
304
+ const isCanvasRoute = typeof window !== 'undefined' && window.location.pathname.includes('/canvas/')
305
+ if (!isCanvasRoute) return null
306
+ const items = Object.entries(widgetTypes).map(([type, def]) => ({
307
+ id: `create-widget:${type}`,
308
+ children: def.label,
309
+ keywords: ['add', 'widget', 'create', type, def.label.toLowerCase()],
310
+ showType: false,
311
+ onClick: () => {
312
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:add-widget', { detail: { type } }))
313
+ },
314
+ }))
315
+ return { group: { heading: section.title, id: `cfg:${section.id}`, items } }
316
+ }
317
+
318
+ // --- Starred source (reads from viewfinder localStorage) ---
319
+ if (section.source === 'starred') {
320
+ const STARRED_KEY = 'sb-viewfinder-starred'
321
+ let starredIds = []
322
+ try { starredIds = JSON.parse(localStorage.getItem(STARRED_KEY)) || [] } catch {}
323
+ if (starredIds.length === 0) return null
324
+
325
+ const index = buildPrototypeIndex()
326
+ // Build a lookup map of all artifacts
327
+ const artifactMap = new Map()
328
+ const allProtos = [...index.prototypes]
329
+ for (const folder of index.folders) {
330
+ allProtos.push(...folder.prototypes)
331
+ if (folder.canvases) folder.canvases.forEach(c => artifactMap.set(`canvas:${c.dirName}`, { ...c, _type: 'canvas' }))
332
+ }
333
+ for (const c of index.canvases) artifactMap.set(`canvas:${c.dirName}`, { ...c, _type: 'canvas' })
334
+ for (const p of allProtos) artifactMap.set(`proto:${p.dirName}`, { ...p, _type: 'prototype' })
335
+
336
+ const items = []
337
+ for (const id of starredIds) {
338
+ const artifact = artifactMap.get(id)
339
+ if (!artifact) continue
340
+ const route = artifact._type === 'canvas'
341
+ ? `${prefix}/canvas/${artifact.dirName}`
342
+ : artifact.isExternal
343
+ ? artifact.externalUrl
344
+ : `${prefix}/${artifact.dirName}`
345
+ items.push({
346
+ id: `starred:${id}`,
347
+ children: artifact.name,
348
+ keywords: ['starred', 'star', artifact.name.toLowerCase()],
349
+ showType: false,
350
+ onClick: () => {
351
+ if (artifact.isExternal) {
352
+ window.open(route, '_blank')
353
+ } else {
354
+ window.location.href = route
355
+ }
356
+ },
357
+ })
358
+ }
359
+ if (items.length === 0) return null
360
+ return { group: { heading: section.title, id: `cfg:${section.id}`, items } }
361
+ }
362
+
299
363
  // --- Commands source (all registered toolbar actions) ---
300
364
  if (section.source === 'commands') {
301
365
  const mode = getCurrentMode() || 'default'
@@ -109,3 +109,8 @@ html[data-color-mode="dark"] .command-palette .border-b {
109
109
  .command-palette .command-palette-list-item .text-gray-500.text-sm {
110
110
  display: none !important;
111
111
  }
112
+
113
+ /* Medium weight for item text */
114
+ .command-palette .command-palette-list-item {
115
+ font-weight: 500 !important;
116
+ }
@@ -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,180 @@ 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 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
+
623
910
  const handleWidgetCopy = useCallback(async (widget) => {
624
911
  // Find the next free offset — check how many copies already exist at +n*40
625
912
  const baseX = widget.position?.x ?? 0
@@ -641,6 +928,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
641
928
  })
642
929
  if (result.success && result.widget) {
643
930
  setLocalWidgets((prev) => [...(prev || []), result.widget])
931
+ setSelectedWidgetIds(new Set([result.widget.id]))
644
932
  }
645
933
  } catch (err) {
646
934
  console.error('[canvas] Failed to copy widget:', err)
@@ -725,6 +1013,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
725
1013
  }, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
726
1014
 
727
1015
  const handleItemDragEnd = useCallback((dragId, position) => {
1016
+ setWidgetDragging(false)
728
1017
  if (!dragId || !position) {
729
1018
  clearDragPreview()
730
1019
  return
@@ -1189,6 +1478,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1189
1478
  if (result.success && result.widget) {
1190
1479
  undoRedo.snapshot(stateRef.current, 'add')
1191
1480
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1481
+ setSelectedWidgetIds(new Set([result.widget.id]))
1192
1482
  }
1193
1483
  } catch (err) {
1194
1484
  console.error('[canvas] Failed to add widget:', err)
@@ -1209,6 +1499,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1209
1499
  if (result.success && result.widget) {
1210
1500
  undoRedo.snapshot(stateRef.current, 'add')
1211
1501
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1502
+ setSelectedWidgetIds(new Set([result.widget.id]))
1212
1503
  }
1213
1504
  } catch (err) {
1214
1505
  console.error('[canvas] Failed to add story widget:', err)
@@ -1486,6 +1777,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1486
1777
  if (result.success && result.widget) {
1487
1778
  undoRedo.snapshot(stateRef.current, 'add')
1488
1779
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1780
+ setSelectedWidgetIds(new Set([result.widget.id]))
1489
1781
  }
1490
1782
  return true
1491
1783
  } catch (err) {
@@ -1704,8 +1996,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1704
1996
  debouncedSourceSave.cancel()
1705
1997
  setLocalWidgets(previous.widgets)
1706
1998
  setLocalSources(previous.sources)
1999
+ setLocalConnectors(previous.connectors ?? [])
1707
2000
  queueWrite(() =>
1708
- updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
2001
+ updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors }).catch((err) =>
1709
2002
  console.error('[canvas] Failed to persist undo:', err)
1710
2003
  )
1711
2004
  )
@@ -1718,8 +2011,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1718
2011
  debouncedSourceSave.cancel()
1719
2012
  setLocalWidgets(next.widgets)
1720
2013
  setLocalSources(next.sources)
2014
+ setLocalConnectors(next.connectors ?? [])
1721
2015
  queueWrite(() =>
1722
- updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
2016
+ updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors }).catch((err) =>
1723
2017
  console.error('[canvas] Failed to persist redo:', err)
1724
2018
  )
1725
2019
  )
@@ -2006,7 +2300,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2006
2300
  }
2007
2301
 
2008
2302
  // 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
2009
- 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
2010
2311
  allChildren.push(
2011
2312
  <div
2012
2313
  key={widget.id}
@@ -2034,6 +2335,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2034
2335
  onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
2035
2336
  onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
2036
2337
  canRefreshGitHub={isLocalDev}
2338
+ onConnectorDragStart={isLocalDev ? handleConnectorDragStart : undefined}
2037
2339
  readOnly={!isLocalDev}
2038
2340
  />
2039
2341
  </div>
@@ -2042,13 +2344,26 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2042
2344
 
2043
2345
  const scale = zoom / 100
2044
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
+
2045
2355
  return (
2046
2356
  <>
2047
2357
  <div className={styles.canvasTitle}>
2048
2358
  <a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
2049
2359
  <Icon name="iconoir/key-command" size={16} color="#fff" />
2050
2360
  </a>
2051
- <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
+ />
2052
2367
  <PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
2053
2368
  {isLocalDev && (
2054
2369
  <span className={styles.localEditingLabel}>Local editing</span>
@@ -2080,6 +2395,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2080
2395
  ...(spaceHeld ? { pointerEvents: 'none' } : {}),
2081
2396
  }}
2082
2397
  >
2398
+ <ConnectorLayer
2399
+ connectors={filteredConnectors}
2400
+ widgets={localWidgets ?? []}
2401
+ onRemove={isLocalDev ? handleConnectorRemove : undefined}
2402
+ onEndpointDrag={undefined}
2403
+ dragPreview={connectorDrag}
2404
+ hidden={widgetDragging}
2405
+ />
2083
2406
  <Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
2084
2407
  {allChildren}
2085
2408
  </Canvas>