@dfosco/storyboard-react 4.1.0-beta.3 → 4.2.0-alpha.10
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 +5 -4
- package/src/CommandPalette/CommandPalette.jsx +86 -9
- package/src/CommandPalette/command-palette.css +5 -0
- package/src/canvas/CanvasPage.jsx +353 -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/ActionWidget.jsx +193 -0
- package/src/canvas/widgets/ActionWidget.module.css +122 -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 +280 -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 +82 -0
- package/src/canvas/widgets/index.js +4 -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,180 @@ 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 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
|
+
|
|
616
910
|
const handleWidgetCopy = useCallback(async (widget) => {
|
|
617
911
|
// Find the next free offset — check how many copies already exist at +n*40
|
|
618
912
|
const baseX = widget.position?.x ?? 0
|
|
@@ -634,6 +928,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
634
928
|
})
|
|
635
929
|
if (result.success && result.widget) {
|
|
636
930
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
931
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
637
932
|
}
|
|
638
933
|
} catch (err) {
|
|
639
934
|
console.error('[canvas] Failed to copy widget:', err)
|
|
@@ -718,6 +1013,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
718
1013
|
}, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
|
|
719
1014
|
|
|
720
1015
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
1016
|
+
setWidgetDragging(false)
|
|
721
1017
|
if (!dragId || !position) {
|
|
722
1018
|
clearDragPreview()
|
|
723
1019
|
return
|
|
@@ -861,11 +1157,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
861
1157
|
if (!el || loading) return
|
|
862
1158
|
const saved = pendingScrollRestore.current
|
|
863
1159
|
if (saved) {
|
|
1160
|
+
console.log('[viewport] restoring saved viewport — zoom:', saved.zoom, 'scroll:', saved.scrollLeft, saved.scrollTop)
|
|
864
1161
|
// Fresh saved viewport — restore exactly
|
|
865
1162
|
if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
|
|
866
1163
|
if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
|
|
867
1164
|
pendingScrollRestore.current = null
|
|
868
1165
|
} else {
|
|
1166
|
+
console.log('[viewport] no saved viewport — fitting to objects')
|
|
869
1167
|
// No saved state or stale — zoom-to-fit all objects
|
|
870
1168
|
const bounds = computeCanvasBounds(localWidgets, componentEntries)
|
|
871
1169
|
if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
|
|
@@ -949,6 +1247,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
949
1247
|
useEffect(() => {
|
|
950
1248
|
if (viewportInitName.current !== canvasId) return
|
|
951
1249
|
const el = scrollRef.current
|
|
1250
|
+
console.log('[viewport] saving — zoom:', zoom, 'scroll:', el?.scrollLeft, el?.scrollTop)
|
|
952
1251
|
// Read current scroll so the zoom entry doesn't zero-out position,
|
|
953
1252
|
// but the authoritative scroll save comes from the scroll handler.
|
|
954
1253
|
saveViewportState(canvasId, {
|
|
@@ -1179,6 +1478,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1179
1478
|
if (result.success && result.widget) {
|
|
1180
1479
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1181
1480
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1481
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1182
1482
|
}
|
|
1183
1483
|
} catch (err) {
|
|
1184
1484
|
console.error('[canvas] Failed to add widget:', err)
|
|
@@ -1199,6 +1499,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1199
1499
|
if (result.success && result.widget) {
|
|
1200
1500
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1201
1501
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1502
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1202
1503
|
}
|
|
1203
1504
|
} catch (err) {
|
|
1204
1505
|
console.error('[canvas] Failed to add story widget:', err)
|
|
@@ -1476,6 +1777,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1476
1777
|
if (result.success && result.widget) {
|
|
1477
1778
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1478
1779
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1780
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1479
1781
|
}
|
|
1480
1782
|
return true
|
|
1481
1783
|
} catch (err) {
|
|
@@ -1694,8 +1996,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1694
1996
|
debouncedSourceSave.cancel()
|
|
1695
1997
|
setLocalWidgets(previous.widgets)
|
|
1696
1998
|
setLocalSources(previous.sources)
|
|
1999
|
+
setLocalConnectors(previous.connectors ?? [])
|
|
1697
2000
|
queueWrite(() =>
|
|
1698
|
-
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
|
|
2001
|
+
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors }).catch((err) =>
|
|
1699
2002
|
console.error('[canvas] Failed to persist undo:', err)
|
|
1700
2003
|
)
|
|
1701
2004
|
)
|
|
@@ -1708,8 +2011,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1708
2011
|
debouncedSourceSave.cancel()
|
|
1709
2012
|
setLocalWidgets(next.widgets)
|
|
1710
2013
|
setLocalSources(next.sources)
|
|
2014
|
+
setLocalConnectors(next.connectors ?? [])
|
|
1711
2015
|
queueWrite(() =>
|
|
1712
|
-
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
|
|
2016
|
+
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors }).catch((err) =>
|
|
1713
2017
|
console.error('[canvas] Failed to persist redo:', err)
|
|
1714
2018
|
)
|
|
1715
2019
|
)
|
|
@@ -1996,7 +2300,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1996
2300
|
}
|
|
1997
2301
|
|
|
1998
2302
|
// 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
|
|
1999
|
-
|
|
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
|
|
2000
2311
|
allChildren.push(
|
|
2001
2312
|
<div
|
|
2002
2313
|
key={widget.id}
|
|
@@ -2024,6 +2335,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2024
2335
|
onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
|
|
2025
2336
|
onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
|
|
2026
2337
|
canRefreshGitHub={isLocalDev}
|
|
2338
|
+
onConnectorDragStart={isLocalDev ? handleConnectorDragStart : undefined}
|
|
2027
2339
|
readOnly={!isLocalDev}
|
|
2028
2340
|
/>
|
|
2029
2341
|
</div>
|
|
@@ -2032,13 +2344,26 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2032
2344
|
|
|
2033
2345
|
const scale = zoom / 100
|
|
2034
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
|
+
|
|
2035
2355
|
return (
|
|
2036
2356
|
<>
|
|
2037
2357
|
<div className={styles.canvasTitle}>
|
|
2038
2358
|
<a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
|
|
2039
2359
|
<Icon name="iconoir/key-command" size={16} color="#fff" />
|
|
2040
2360
|
</a>
|
|
2041
|
-
<
|
|
2361
|
+
<CanvasTitleEditable
|
|
2362
|
+
canvasId={canvasId}
|
|
2363
|
+
canvasMeta={canvasMeta}
|
|
2364
|
+
canvas={canvas}
|
|
2365
|
+
isLocalDev={isLocalDev}
|
|
2366
|
+
/>
|
|
2042
2367
|
<PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
|
|
2043
2368
|
{isLocalDev && (
|
|
2044
2369
|
<span className={styles.localEditingLabel}>Local editing</span>
|
|
@@ -2070,6 +2395,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2070
2395
|
...(spaceHeld ? { pointerEvents: 'none' } : {}),
|
|
2071
2396
|
}}
|
|
2072
2397
|
>
|
|
2398
|
+
<ConnectorLayer
|
|
2399
|
+
connectors={filteredConnectors}
|
|
2400
|
+
widgets={localWidgets ?? []}
|
|
2401
|
+
onRemove={isLocalDev ? handleConnectorRemove : undefined}
|
|
2402
|
+
onEndpointDrag={undefined}
|
|
2403
|
+
dragPreview={connectorDrag}
|
|
2404
|
+
hidden={widgetDragging}
|
|
2405
|
+
/>
|
|
2073
2406
|
<Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
|
|
2074
2407
|
{allChildren}
|
|
2075
2408
|
</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 {
|