@dfosco/storyboard-react 4.1.0 → 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 +69 -5
- package/src/CommandPalette/command-palette.css +5 -0
- package/src/canvas/CanvasPage.jsx +412 -10
- 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/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
|
|
@@ -306,6 +310,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
306
310
|
onCopy,
|
|
307
311
|
onRefreshGitHub,
|
|
308
312
|
canRefreshGitHub,
|
|
313
|
+
onConnectorDragStart,
|
|
309
314
|
readOnly,
|
|
310
315
|
}) {
|
|
311
316
|
const widgetRef = useRef(null)
|
|
@@ -317,7 +322,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
317
322
|
return rawFeatures.map((f) => {
|
|
318
323
|
// Toggle collapse label and hide when content is short (no github = no collapse)
|
|
319
324
|
if (f.action === 'toggle-collapse') {
|
|
320
|
-
if (!isGitHub) return null
|
|
325
|
+
if (widget.type === 'link-preview' && !isGitHub) return null
|
|
321
326
|
return {
|
|
322
327
|
...f,
|
|
323
328
|
label: widget.props?.collapsed ? 'Expand height' : 'Collapse height',
|
|
@@ -376,6 +381,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
376
381
|
onDeselect={onDeselect}
|
|
377
382
|
onAction={handleAction}
|
|
378
383
|
onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
|
|
384
|
+
onConnectorDragStart={onConnectorDragStart}
|
|
379
385
|
readOnly={readOnly}
|
|
380
386
|
>
|
|
381
387
|
<WidgetRenderer
|
|
@@ -397,10 +403,101 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
397
403
|
prev.onDeselect === next.onDeselect &&
|
|
398
404
|
prev.onUpdate === next.onUpdate &&
|
|
399
405
|
prev.onRemove === next.onRemove &&
|
|
400
|
-
prev.onCopy === next.onCopy
|
|
406
|
+
prev.onCopy === next.onCopy &&
|
|
407
|
+
prev.onConnectorDragStart === next.onConnectorDragStart
|
|
401
408
|
)
|
|
402
409
|
})
|
|
403
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Editable canvas/folder title — always visible, double-click to edit in dev mode.
|
|
413
|
+
*/
|
|
414
|
+
function CanvasTitleEditable({ canvasId, canvasMeta, canvas, isLocalDev }) {
|
|
415
|
+
const [editing, setEditing] = useState(false)
|
|
416
|
+
const [titleValue, setTitleValue] = useState('')
|
|
417
|
+
const inputRef = useRef(null)
|
|
418
|
+
const displayTitle = canvasMeta?.title || canvas?.title || canvasId.split('/').pop()
|
|
419
|
+
|
|
420
|
+
useEffect(() => {
|
|
421
|
+
if (editing && inputRef.current) {
|
|
422
|
+
inputRef.current.focus()
|
|
423
|
+
inputRef.current.select()
|
|
424
|
+
}
|
|
425
|
+
}, [editing])
|
|
426
|
+
|
|
427
|
+
const handleCommit = useCallback(async () => {
|
|
428
|
+
const trimmed = titleValue.trim()
|
|
429
|
+
setEditing(false)
|
|
430
|
+
if (!trimmed || trimmed === displayTitle) return
|
|
431
|
+
try {
|
|
432
|
+
if (canvasId.includes('/')) {
|
|
433
|
+
const folder = canvasId.split('/')[0]
|
|
434
|
+
const result = await updateFolderMeta(folder, trimmed)
|
|
435
|
+
if (result?.renamed && result?.folder) {
|
|
436
|
+
// Folder was renamed on disk — navigate to new route
|
|
437
|
+
const pageName = canvasId.split('/').slice(1).join('/')
|
|
438
|
+
const newCanvasId = `${result.folder}/${pageName}`
|
|
439
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
440
|
+
const targetUrl = `${base}/canvas/${newCanvasId}`
|
|
441
|
+
if (import.meta.hot) {
|
|
442
|
+
const timer = setTimeout(() => { window.location.href = targetUrl }, 3000)
|
|
443
|
+
import.meta.hot.on('vite:beforeFullReload', () => {
|
|
444
|
+
clearTimeout(timer)
|
|
445
|
+
sessionStorage.setItem('sb-pending-navigate', targetUrl)
|
|
446
|
+
})
|
|
447
|
+
} else {
|
|
448
|
+
setTimeout(() => { window.location.href = targetUrl }, 1000)
|
|
449
|
+
}
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
await updateCanvas(canvasId, { settings: { title: trimmed } })
|
|
454
|
+
}
|
|
455
|
+
// Reload to pick up the updated metadata from the data plugin
|
|
456
|
+
if (import.meta.hot) {
|
|
457
|
+
const timer = setTimeout(() => { window.location.reload() }, 2000)
|
|
458
|
+
import.meta.hot.on('vite:beforeFullReload', () => clearTimeout(timer))
|
|
459
|
+
} else {
|
|
460
|
+
setTimeout(() => { window.location.reload() }, 1000)
|
|
461
|
+
}
|
|
462
|
+
} catch (err) {
|
|
463
|
+
console.error('Failed to update title:', err)
|
|
464
|
+
}
|
|
465
|
+
}, [titleValue, displayTitle, canvasId])
|
|
466
|
+
|
|
467
|
+
const handleDblClick = useCallback(() => {
|
|
468
|
+
if (!isLocalDev) return
|
|
469
|
+
setTitleValue(displayTitle)
|
|
470
|
+
setEditing(true)
|
|
471
|
+
}, [isLocalDev, displayTitle])
|
|
472
|
+
|
|
473
|
+
if (editing) {
|
|
474
|
+
return (
|
|
475
|
+
<input
|
|
476
|
+
ref={inputRef}
|
|
477
|
+
className={styles.canvasTitleEditing}
|
|
478
|
+
type="text"
|
|
479
|
+
value={titleValue}
|
|
480
|
+
onChange={(e) => setTitleValue(e.target.value)}
|
|
481
|
+
onKeyDown={(e) => {
|
|
482
|
+
if (e.key === 'Enter') { e.preventDefault(); handleCommit() }
|
|
483
|
+
if (e.key === 'Escape') { e.preventDefault(); setEditing(false) }
|
|
484
|
+
}}
|
|
485
|
+
onBlur={handleCommit}
|
|
486
|
+
/>
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return (
|
|
491
|
+
<h1
|
|
492
|
+
className={styles.canvasTitleStatic}
|
|
493
|
+
onDoubleClick={handleDblClick}
|
|
494
|
+
style={isLocalDev ? { cursor: 'default' } : undefined}
|
|
495
|
+
>
|
|
496
|
+
{displayTitle}
|
|
497
|
+
</h1>
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
404
501
|
/**
|
|
405
502
|
* Generic canvas page component.
|
|
406
503
|
* Reads canvas data from the index and renders all widgets on a draggable surface.
|
|
@@ -414,6 +511,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
414
511
|
|
|
415
512
|
// Local mutable copy of widgets for instant UI updates
|
|
416
513
|
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
514
|
+
const [localConnectors, setLocalConnectors] = useState(canvas?.connectors ?? [])
|
|
417
515
|
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
418
516
|
const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
|
|
419
517
|
const initialViewport = loadViewportState(canvasId)
|
|
@@ -468,10 +566,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
468
566
|
|
|
469
567
|
// Undo/redo history — tracks both widgets and sources as a combined snapshot
|
|
470
568
|
const undoRedo = useUndoRedo()
|
|
471
|
-
const stateRef = useRef({ widgets: localWidgets, sources: localSources })
|
|
569
|
+
const stateRef = useRef({ widgets: localWidgets, sources: localSources, connectors: localConnectors })
|
|
472
570
|
useEffect(() => {
|
|
473
|
-
stateRef.current = { widgets: localWidgets, sources: localSources }
|
|
474
|
-
}, [localWidgets, localSources])
|
|
571
|
+
stateRef.current = { widgets: localWidgets, sources: localSources, connectors: localConnectors }
|
|
572
|
+
}, [localWidgets, localSources, localConnectors])
|
|
475
573
|
|
|
476
574
|
// Serialized write queue — ensures JSONL events land in the right order
|
|
477
575
|
const writeQueueRef = useRef(Promise.resolve())
|
|
@@ -522,6 +620,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
522
620
|
const justDraggedRef = useRef(false)
|
|
523
621
|
|
|
524
622
|
const handleItemDragStart = useCallback((dragId) => {
|
|
623
|
+
setWidgetDragging(true)
|
|
525
624
|
const ids = selectedIdsRef.current
|
|
526
625
|
peerArticlesRef.current.clear()
|
|
527
626
|
if (ids.size <= 1 || !ids.has(dragId)) return
|
|
@@ -567,6 +666,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
567
666
|
console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
|
|
568
667
|
setTrackedCanvas(canvas)
|
|
569
668
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
669
|
+
setLocalConnectors(canvas?.connectors ?? [])
|
|
570
670
|
setLocalSources(canvas?.sources ?? [])
|
|
571
671
|
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
572
672
|
setSnapGridSize(canvas?.gridSize || 40)
|
|
@@ -613,6 +713,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
613
713
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
614
714
|
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
615
715
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
716
|
+
// Cascade: remove connectors referencing this widget
|
|
717
|
+
setLocalConnectors((prev) => {
|
|
718
|
+
const orphaned = prev.filter((c) => c.start.widgetId === widgetId || c.end.widgetId === widgetId)
|
|
719
|
+
if (orphaned.length === 0) return prev
|
|
720
|
+
for (const c of orphaned) {
|
|
721
|
+
queueWrite(() =>
|
|
722
|
+
removeConnectorApi(canvasId, c.id).catch((err) =>
|
|
723
|
+
console.error('[canvas] Failed to remove orphaned connector:', err)
|
|
724
|
+
)
|
|
725
|
+
)
|
|
726
|
+
}
|
|
727
|
+
return prev.filter((c) => c.start.widgetId !== widgetId && c.end.widgetId !== widgetId)
|
|
728
|
+
})
|
|
616
729
|
queueWrite(() =>
|
|
617
730
|
removeWidgetApi(canvasId, widgetId).catch((err) =>
|
|
618
731
|
console.error('[canvas] Failed to remove widget:', err)
|
|
@@ -620,6 +733,259 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
620
733
|
)
|
|
621
734
|
}, [canvasId, undoRedo])
|
|
622
735
|
|
|
736
|
+
const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
|
|
737
|
+
try {
|
|
738
|
+
undoRedo.snapshot(stateRef.current, 'connector-add')
|
|
739
|
+
const result = await addConnectorApi(canvasId, { startWidgetId, startAnchor, endWidgetId, endAnchor })
|
|
740
|
+
if (result.success && result.connector) {
|
|
741
|
+
setLocalConnectors((prev) => [...prev, result.connector])
|
|
742
|
+
}
|
|
743
|
+
} catch (err) {
|
|
744
|
+
console.error('[canvas] Failed to add connector:', err)
|
|
745
|
+
}
|
|
746
|
+
}, [canvasId, undoRedo])
|
|
747
|
+
|
|
748
|
+
const handleConnectorRemove = useCallback((connectorId) => {
|
|
749
|
+
undoRedo.snapshot(stateRef.current, 'connector-remove')
|
|
750
|
+
setLocalConnectors((prev) => prev.filter((c) => c.id !== connectorId))
|
|
751
|
+
queueWrite(() =>
|
|
752
|
+
removeConnectorApi(canvasId, connectorId).catch((err) =>
|
|
753
|
+
console.error('[canvas] Failed to remove connector:', err)
|
|
754
|
+
)
|
|
755
|
+
)
|
|
756
|
+
}, [canvasId, undoRedo])
|
|
757
|
+
|
|
758
|
+
// Connector drag state
|
|
759
|
+
const [connectorDrag, setConnectorDrag] = useState(null)
|
|
760
|
+
const [widgetDragging, setWidgetDragging] = useState(false)
|
|
761
|
+
|
|
762
|
+
const handleConnectorDragStart = useCallback((widgetId, anchor, e) => {
|
|
763
|
+
e.stopPropagation()
|
|
764
|
+
e.preventDefault()
|
|
765
|
+
const scrollEl = scrollRef.current
|
|
766
|
+
if (!scrollEl) return
|
|
767
|
+
const scale = zoomRef.current / 100
|
|
768
|
+
const rect = scrollEl.getBoundingClientRect()
|
|
769
|
+
|
|
770
|
+
const widgets = stateRef.current.widgets ?? []
|
|
771
|
+
const startWidget = widgets.find((w) => w.id === widgetId)
|
|
772
|
+
if (!startWidget) return
|
|
773
|
+
|
|
774
|
+
// Don't start drag from a disabled/unavailable anchor
|
|
775
|
+
const srcAnchorState = getAnchorState(startWidget.type, anchor)
|
|
776
|
+
if (srcAnchorState !== 'available') return
|
|
777
|
+
|
|
778
|
+
const computeAnchorPt = (widget, anch) => {
|
|
779
|
+
let ww, wh
|
|
780
|
+
const el = document.getElementById(widget.id)
|
|
781
|
+
if (el) {
|
|
782
|
+
const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
|
|
783
|
+
if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
|
|
784
|
+
}
|
|
785
|
+
if (!ww) ww = widget.props?.width ?? widget.bounds?.width ?? 270
|
|
786
|
+
if (!wh) wh = widget.props?.height ?? widget.bounds?.height ?? 170
|
|
787
|
+
const px = widget.position?.x ?? 0
|
|
788
|
+
const py = widget.position?.y ?? 0
|
|
789
|
+
switch (anch) {
|
|
790
|
+
case 'top': return { x: px + ww / 2, y: py }
|
|
791
|
+
case 'bottom': return { x: px + ww / 2, y: py + wh }
|
|
792
|
+
case 'left': return { x: px, y: py + wh / 2 }
|
|
793
|
+
case 'right': return { x: px + ww, y: py + wh / 2 }
|
|
794
|
+
default: return { x: px + ww / 2, y: py + wh / 2 }
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const startPt = computeAnchorPt(startWidget, anchor)
|
|
799
|
+
|
|
800
|
+
const toCanvasPoint = (clientX, clientY) => ({
|
|
801
|
+
x: (scrollEl.scrollLeft + clientX - rect.left) / scale,
|
|
802
|
+
y: (scrollEl.scrollTop + clientY - rect.top) / scale,
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
// Find nearest anchor on any other widget within snap distance
|
|
806
|
+
const SNAP_DIST = 40
|
|
807
|
+
const sourceType = startWidget.type
|
|
808
|
+
const findNearestAnchor = (canvasPt) => {
|
|
809
|
+
const currentWidgets = stateRef.current.widgets ?? []
|
|
810
|
+
let best = null
|
|
811
|
+
let bestDist = SNAP_DIST
|
|
812
|
+
for (const w of currentWidgets) {
|
|
813
|
+
if (w.id === widgetId) continue
|
|
814
|
+
// Check if this widget type accepts connections from the source type
|
|
815
|
+
if (!canAcceptConnection(w.type, sourceType)) continue
|
|
816
|
+
for (const anch of ['top', 'bottom', 'left', 'right']) {
|
|
817
|
+
// Skip unavailable or disabled anchors
|
|
818
|
+
const anchorState = getAnchorState(w.type, anch)
|
|
819
|
+
if (anchorState !== 'available') continue
|
|
820
|
+
const pt = computeAnchorPt(w, anch)
|
|
821
|
+
const dist = Math.hypot(pt.x - canvasPt.x, pt.y - canvasPt.y)
|
|
822
|
+
if (dist < bestDist) {
|
|
823
|
+
bestDist = dist
|
|
824
|
+
best = { widgetId: w.id, anchor: anch, pt }
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return best
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const cursorPt = toCanvasPoint(e.clientX, e.clientY)
|
|
832
|
+
const snap = findNearestAnchor(cursorPt)
|
|
833
|
+
setConnectorDrag({
|
|
834
|
+
startWidgetId: widgetId,
|
|
835
|
+
startAnchor: anchor,
|
|
836
|
+
startPt,
|
|
837
|
+
endPt: snap ? snap.pt : cursorPt,
|
|
838
|
+
endAnchor: snap ? snap.anchor : anchor,
|
|
839
|
+
snapTarget: snap,
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
const handlePointerMove = (moveE) => {
|
|
843
|
+
const pt = toCanvasPoint(moveE.clientX, moveE.clientY)
|
|
844
|
+
const nearSnap = findNearestAnchor(pt)
|
|
845
|
+
setConnectorDrag((prev) => prev ? {
|
|
846
|
+
...prev,
|
|
847
|
+
endPt: nearSnap ? nearSnap.pt : pt,
|
|
848
|
+
endAnchor: nearSnap ? nearSnap.anchor : prev.startAnchor,
|
|
849
|
+
snapTarget: nearSnap,
|
|
850
|
+
} : null)
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const handlePointerUp = (upE) => {
|
|
854
|
+
document.removeEventListener('pointermove', handlePointerMove)
|
|
855
|
+
document.removeEventListener('pointerup', handlePointerUp)
|
|
856
|
+
|
|
857
|
+
const pt = toCanvasPoint(upE.clientX, upE.clientY)
|
|
858
|
+
const nearSnap = findNearestAnchor(pt)
|
|
859
|
+
|
|
860
|
+
if (nearSnap) {
|
|
861
|
+
handleConnectorAdd({
|
|
862
|
+
startWidgetId: widgetId,
|
|
863
|
+
startAnchor: anchor,
|
|
864
|
+
endWidgetId: nearSnap.widgetId,
|
|
865
|
+
endAnchor: nearSnap.anchor,
|
|
866
|
+
})
|
|
867
|
+
}
|
|
868
|
+
setConnectorDrag(null)
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
document.addEventListener('pointermove', handlePointerMove)
|
|
872
|
+
document.addEventListener('pointerup', handlePointerUp)
|
|
873
|
+
}, [handleConnectorAdd])
|
|
874
|
+
|
|
875
|
+
// Drag an existing connector endpoint to reconnect or remove
|
|
876
|
+
const handleEndpointDrag = useCallback((connector, endpoint, e) => {
|
|
877
|
+
e.stopPropagation()
|
|
878
|
+
e.preventDefault()
|
|
879
|
+
const scrollEl = scrollRef.current
|
|
880
|
+
if (!scrollEl) return
|
|
881
|
+
const scale = zoomRef.current / 100
|
|
882
|
+
const rect = scrollEl.getBoundingClientRect()
|
|
883
|
+
|
|
884
|
+
// The fixed end stays put; the dragged end follows cursor
|
|
885
|
+
const fixedEnd = endpoint === 'start' ? 'end' : 'start'
|
|
886
|
+
const fixedSide = connector[fixedEnd]
|
|
887
|
+
const fixedWidget = (stateRef.current.widgets ?? []).find((w) => w.id === fixedSide.widgetId)
|
|
888
|
+
if (!fixedWidget) return
|
|
889
|
+
|
|
890
|
+
const computeAnchorPtLocal = (widget, anch) => {
|
|
891
|
+
let ww, wh
|
|
892
|
+
const el = document.getElementById(widget.id)
|
|
893
|
+
if (el) {
|
|
894
|
+
const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
|
|
895
|
+
if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
|
|
896
|
+
}
|
|
897
|
+
if (!ww) ww = widget.props?.width ?? widget.bounds?.width ?? 270
|
|
898
|
+
if (!wh) wh = widget.props?.height ?? widget.bounds?.height ?? 170
|
|
899
|
+
const px = widget.position?.x ?? 0
|
|
900
|
+
const py = widget.position?.y ?? 0
|
|
901
|
+
switch (anch) {
|
|
902
|
+
case 'top': return { x: px + ww / 2, y: py }
|
|
903
|
+
case 'bottom': return { x: px + ww / 2, y: py + wh }
|
|
904
|
+
case 'left': return { x: px, y: py + wh / 2 }
|
|
905
|
+
case 'right': return { x: px + ww, y: py + wh / 2 }
|
|
906
|
+
default: return { x: px + ww / 2, y: py + wh / 2 }
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const fixedPt = computeAnchorPtLocal(fixedWidget, fixedSide.anchor)
|
|
911
|
+
const fixedWidgetId = fixedSide.widgetId
|
|
912
|
+
|
|
913
|
+
const toCanvasPoint = (clientX, clientY) => ({
|
|
914
|
+
x: (scrollEl.scrollLeft + clientX - rect.left) / scale,
|
|
915
|
+
y: (scrollEl.scrollTop + clientY - rect.top) / scale,
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
const SNAP_DIST = 40
|
|
919
|
+
const sourceType = fixedWidget.type
|
|
920
|
+
const findNearestAnchorLocal = (canvasPt) => {
|
|
921
|
+
const currentWidgets = stateRef.current.widgets ?? []
|
|
922
|
+
let best = null
|
|
923
|
+
let bestDist = SNAP_DIST
|
|
924
|
+
for (const w of currentWidgets) {
|
|
925
|
+
if (w.id === fixedWidgetId) continue
|
|
926
|
+
if (!canAcceptConnection(w.type, sourceType)) continue
|
|
927
|
+
for (const anch of ['top', 'bottom', 'left', 'right']) {
|
|
928
|
+
const anchorState = getAnchorState(w.type, anch)
|
|
929
|
+
if (anchorState !== 'available') continue
|
|
930
|
+
const pt = computeAnchorPtLocal(w, anch)
|
|
931
|
+
const dist = Math.hypot(pt.x - canvasPt.x, pt.y - canvasPt.y)
|
|
932
|
+
if (dist < bestDist) {
|
|
933
|
+
bestDist = dist
|
|
934
|
+
best = { widgetId: w.id, anchor: anch, pt }
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return best
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const cursorPt = toCanvasPoint(e.clientX, e.clientY)
|
|
942
|
+
const snap = findNearestAnchorLocal(cursorPt)
|
|
943
|
+
setConnectorDrag({
|
|
944
|
+
startWidgetId: fixedWidgetId,
|
|
945
|
+
startAnchor: fixedSide.anchor,
|
|
946
|
+
startPt: fixedPt,
|
|
947
|
+
endPt: snap ? snap.pt : cursorPt,
|
|
948
|
+
endAnchor: snap ? snap.anchor : fixedSide.anchor,
|
|
949
|
+
snapTarget: snap,
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
const handlePointerMove = (moveE) => {
|
|
953
|
+
const pt = toCanvasPoint(moveE.clientX, moveE.clientY)
|
|
954
|
+
const nearSnap = findNearestAnchorLocal(pt)
|
|
955
|
+
setConnectorDrag((prev) => prev ? {
|
|
956
|
+
...prev,
|
|
957
|
+
endPt: nearSnap ? nearSnap.pt : pt,
|
|
958
|
+
endAnchor: nearSnap ? nearSnap.anchor : prev.startAnchor,
|
|
959
|
+
snapTarget: nearSnap,
|
|
960
|
+
} : null)
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const handlePointerUp = (upE) => {
|
|
964
|
+
document.removeEventListener('pointermove', handlePointerMove)
|
|
965
|
+
document.removeEventListener('pointerup', handlePointerUp)
|
|
966
|
+
|
|
967
|
+
const pt = toCanvasPoint(upE.clientX, upE.clientY)
|
|
968
|
+
const nearSnap = findNearestAnchorLocal(pt)
|
|
969
|
+
|
|
970
|
+
// Always remove the old connector
|
|
971
|
+
handleConnectorRemove(connector.id)
|
|
972
|
+
|
|
973
|
+
// If snapped to a new anchor, create a new connector
|
|
974
|
+
if (nearSnap) {
|
|
975
|
+
handleConnectorAdd({
|
|
976
|
+
startWidgetId: fixedWidgetId,
|
|
977
|
+
startAnchor: fixedSide.anchor,
|
|
978
|
+
endWidgetId: nearSnap.widgetId,
|
|
979
|
+
endAnchor: nearSnap.anchor,
|
|
980
|
+
})
|
|
981
|
+
}
|
|
982
|
+
setConnectorDrag(null)
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
document.addEventListener('pointermove', handlePointerMove)
|
|
986
|
+
document.addEventListener('pointerup', handlePointerUp)
|
|
987
|
+
}, [handleConnectorAdd, handleConnectorRemove])
|
|
988
|
+
|
|
623
989
|
const handleWidgetCopy = useCallback(async (widget) => {
|
|
624
990
|
// Find the next free offset — check how many copies already exist at +n*40
|
|
625
991
|
const baseX = widget.position?.x ?? 0
|
|
@@ -641,6 +1007,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
641
1007
|
})
|
|
642
1008
|
if (result.success && result.widget) {
|
|
643
1009
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1010
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
644
1011
|
}
|
|
645
1012
|
} catch (err) {
|
|
646
1013
|
console.error('[canvas] Failed to copy widget:', err)
|
|
@@ -725,6 +1092,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
725
1092
|
}, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
|
|
726
1093
|
|
|
727
1094
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
1095
|
+
setWidgetDragging(false)
|
|
728
1096
|
if (!dragId || !position) {
|
|
729
1097
|
clearDragPreview()
|
|
730
1098
|
return
|
|
@@ -1189,6 +1557,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1189
1557
|
if (result.success && result.widget) {
|
|
1190
1558
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1191
1559
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1560
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1192
1561
|
}
|
|
1193
1562
|
} catch (err) {
|
|
1194
1563
|
console.error('[canvas] Failed to add widget:', err)
|
|
@@ -1209,6 +1578,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1209
1578
|
if (result.success && result.widget) {
|
|
1210
1579
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1211
1580
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1581
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1212
1582
|
}
|
|
1213
1583
|
} catch (err) {
|
|
1214
1584
|
console.error('[canvas] Failed to add story widget:', err)
|
|
@@ -1486,6 +1856,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1486
1856
|
if (result.success && result.widget) {
|
|
1487
1857
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1488
1858
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1859
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1489
1860
|
}
|
|
1490
1861
|
return true
|
|
1491
1862
|
} catch (err) {
|
|
@@ -1704,8 +2075,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1704
2075
|
debouncedSourceSave.cancel()
|
|
1705
2076
|
setLocalWidgets(previous.widgets)
|
|
1706
2077
|
setLocalSources(previous.sources)
|
|
2078
|
+
setLocalConnectors(previous.connectors ?? [])
|
|
1707
2079
|
queueWrite(() =>
|
|
1708
|
-
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
|
|
2080
|
+
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors }).catch((err) =>
|
|
1709
2081
|
console.error('[canvas] Failed to persist undo:', err)
|
|
1710
2082
|
)
|
|
1711
2083
|
)
|
|
@@ -1718,8 +2090,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1718
2090
|
debouncedSourceSave.cancel()
|
|
1719
2091
|
setLocalWidgets(next.widgets)
|
|
1720
2092
|
setLocalSources(next.sources)
|
|
2093
|
+
setLocalConnectors(next.connectors ?? [])
|
|
1721
2094
|
queueWrite(() =>
|
|
1722
|
-
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
|
|
2095
|
+
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors }).catch((err) =>
|
|
1723
2096
|
console.error('[canvas] Failed to persist redo:', err)
|
|
1724
2097
|
)
|
|
1725
2098
|
)
|
|
@@ -2006,7 +2379,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2006
2379
|
}
|
|
2007
2380
|
|
|
2008
2381
|
// 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
|
|
2009
|
-
|
|
2382
|
+
// Sort so selected widgets render last (visually on top via DOM order)
|
|
2383
|
+
const sortedWidgets = (localWidgets ?? []).slice().sort((a, b) => {
|
|
2384
|
+
const aSelected = selectedWidgetIds.has(a.id) ? 1 : 0
|
|
2385
|
+
const bSelected = selectedWidgetIds.has(b.id) ? 1 : 0
|
|
2386
|
+
return aSelected - bSelected
|
|
2387
|
+
})
|
|
2388
|
+
for (const widget of sortedWidgets) {
|
|
2389
|
+
if (!isLocalDev && widget.type === 'terminal') continue
|
|
2010
2390
|
allChildren.push(
|
|
2011
2391
|
<div
|
|
2012
2392
|
key={widget.id}
|
|
@@ -2034,6 +2414,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2034
2414
|
onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
|
|
2035
2415
|
onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
|
|
2036
2416
|
canRefreshGitHub={isLocalDev}
|
|
2417
|
+
onConnectorDragStart={isLocalDev ? handleConnectorDragStart : undefined}
|
|
2037
2418
|
readOnly={!isLocalDev}
|
|
2038
2419
|
/>
|
|
2039
2420
|
</div>
|
|
@@ -2042,13 +2423,26 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2042
2423
|
|
|
2043
2424
|
const scale = zoom / 100
|
|
2044
2425
|
|
|
2426
|
+
const terminalWidgetIds = !isLocalDev
|
|
2427
|
+
? new Set((localWidgets ?? []).filter(w => w.type === 'terminal').map(w => w.id))
|
|
2428
|
+
: null
|
|
2429
|
+
|
|
2430
|
+
const filteredConnectors = terminalWidgetIds?.size
|
|
2431
|
+
? localConnectors.filter(c => !terminalWidgetIds.has(c.startWidgetId) && !terminalWidgetIds.has(c.endWidgetId))
|
|
2432
|
+
: localConnectors
|
|
2433
|
+
|
|
2045
2434
|
return (
|
|
2046
2435
|
<>
|
|
2047
2436
|
<div className={styles.canvasTitle}>
|
|
2048
2437
|
<a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
|
|
2049
2438
|
<Icon name="iconoir/key-command" size={16} color="#fff" />
|
|
2050
2439
|
</a>
|
|
2051
|
-
<
|
|
2440
|
+
<CanvasTitleEditable
|
|
2441
|
+
canvasId={canvasId}
|
|
2442
|
+
canvasMeta={canvasMeta}
|
|
2443
|
+
canvas={canvas}
|
|
2444
|
+
isLocalDev={isLocalDev}
|
|
2445
|
+
/>
|
|
2052
2446
|
<PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
|
|
2053
2447
|
{isLocalDev && (
|
|
2054
2448
|
<span className={styles.localEditingLabel}>Local editing</span>
|
|
@@ -2080,6 +2474,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2080
2474
|
...(spaceHeld ? { pointerEvents: 'none' } : {}),
|
|
2081
2475
|
}}
|
|
2082
2476
|
>
|
|
2477
|
+
<ConnectorLayer
|
|
2478
|
+
connectors={filteredConnectors}
|
|
2479
|
+
widgets={localWidgets ?? []}
|
|
2480
|
+
onRemove={isLocalDev ? handleConnectorRemove : undefined}
|
|
2481
|
+
onEndpointDrag={isLocalDev ? handleEndpointDrag : undefined}
|
|
2482
|
+
dragPreview={connectorDrag}
|
|
2483
|
+
hidden={widgetDragging}
|
|
2484
|
+
/>
|
|
2083
2485
|
<Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
|
|
2084
2486
|
{allChildren}
|
|
2085
2487
|
</Canvas>
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
.canvasZoom {
|
|
32
|
+
position: relative;
|
|
32
33
|
min-width: 100%;
|
|
33
34
|
min-height: 100%;
|
|
34
35
|
}
|
|
@@ -76,15 +77,36 @@
|
|
|
76
77
|
white-space: nowrap;
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
.canvasTitleEditing {
|
|
81
|
+
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
82
|
+
font-size: 14px;
|
|
83
|
+
font-weight: 600;
|
|
84
|
+
color: var(--fgColor-default, #1f2328);
|
|
85
|
+
margin: 0;
|
|
86
|
+
padding: 3px 7px;
|
|
87
|
+
white-space: nowrap;
|
|
88
|
+
border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
|
|
89
|
+
border-radius: 4px;
|
|
90
|
+
background: var(--bgColor-default, #fff);
|
|
91
|
+
outline: none;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.canvasTitleEditing:focus {
|
|
95
|
+
border-color: var(--focus-outlineColor, #0969da);
|
|
96
|
+
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.3);
|
|
97
|
+
}
|
|
98
|
+
|
|
79
99
|
/* Remove tiny-canvas wrapper clipping — widgets handle their own overflow/radius */
|
|
80
100
|
:global(.tc-draggable-inner) {
|
|
81
101
|
overflow: visible;
|
|
82
102
|
}
|
|
83
103
|
|
|
84
|
-
/*
|
|
85
|
-
(
|
|
86
|
-
|
|
87
|
-
|
|
104
|
+
/* Each widget gets its own stacking context so internal z-index
|
|
105
|
+
(chrome toolbars, resize handles, overlays) never leaks to siblings.
|
|
106
|
+
Visual stacking between widgets is controlled by DOM order — selected
|
|
107
|
+
widgets are rendered last so they appear on top. */
|
|
108
|
+
:global(.tc-drag) {
|
|
109
|
+
isolation: isolate;
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
.localEditingLabel {
|