@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.
- package/package.json +3 -3
- package/src/CommandPalette/CommandPalette.jsx +86 -9
- package/src/CommandPalette/command-palette.css +5 -0
- package/src/canvas/CanvasPage.jsx +432 -20
- package/src/canvas/CanvasPage.module.css +26 -4
- package/src/canvas/ConnectorLayer.jsx +252 -0
- package/src/canvas/ConnectorLayer.module.css +60 -0
- package/src/canvas/PageSelector.jsx +376 -37
- package/src/canvas/PageSelector.module.css +93 -6
- package/src/canvas/canvasApi.js +35 -0
- package/src/canvas/useCanvas.js +6 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +27 -6
- package/src/canvas/widgets/MarkdownBlock.module.css +11 -1
- package/src/canvas/widgets/StickyNote.module.css +3 -0
- package/src/canvas/widgets/TerminalWidget.jsx +274 -0
- package/src/canvas/widgets/TerminalWidget.module.css +158 -0
- package/src/canvas/widgets/WidgetChrome.jsx +86 -1
- package/src/canvas/widgets/WidgetChrome.module.css +72 -0
- package/src/canvas/widgets/index.js +2 -0
- package/src/canvas/widgets/widgetConfig.js +78 -0
- package/src/canvas/widgets/widgetProps.js +1 -0
- package/src/context.jsx +15 -0
|
@@ -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
|
-
|
|
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
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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>
|