@dfosco/storyboard-react 4.2.0-beta.1 → 4.2.0-beta.17
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/AuthModal/AuthModal.jsx +6 -2
- package/src/BranchBar/BranchBar.jsx +17 -5
- package/src/BranchBar/BranchBar.module.css +11 -2
- package/src/CommandPalette/CommandPalette.jsx +267 -164
- package/src/CommandPalette/command-palette.css +130 -78
- package/src/Icon.jsx +112 -48
- package/src/Viewfinder.jsx +511 -61
- package/src/Viewfinder.module.css +414 -2
- package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
- package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
- package/src/canvas/CanvasPage.jsx +157 -174
- package/src/canvas/CanvasPage.module.css +0 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +10 -6
- package/src/canvas/ConnectorLayer.jsx +5 -5
- package/src/canvas/PageSelector.test.jsx +15 -6
- package/src/canvas/useCanvas.js +1 -1
- package/src/canvas/widgets/ActionWidget.jsx +200 -0
- package/src/canvas/widgets/ActionWidget.module.css +122 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +97 -29
- package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
- package/src/canvas/widgets/ImageWidget.jsx +1 -1
- package/src/canvas/widgets/LinkPreview.jsx +64 -5
- package/src/canvas/widgets/LinkPreview.module.css +127 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +39 -17
- package/src/canvas/widgets/MarkdownBlock.module.css +123 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +183 -20
- package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
- package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
- package/src/canvas/widgets/SplitExpandModal.jsx +234 -0
- package/src/canvas/widgets/SplitExpandModal.module.css +335 -0
- package/src/canvas/widgets/SplitScreenTopBar.jsx +30 -0
- package/src/canvas/widgets/SplitScreenTopBar.module.css +58 -0
- package/src/canvas/widgets/StoryWidget.jsx +7 -4
- package/src/canvas/widgets/TerminalReadWidget.jsx +140 -0
- package/src/canvas/widgets/TerminalReadWidget.module.css +92 -0
- package/src/canvas/widgets/TerminalWidget.jsx +299 -49
- package/src/canvas/widgets/TerminalWidget.module.css +155 -1
- package/src/canvas/widgets/WidgetChrome.jsx +19 -14
- package/src/canvas/widgets/WidgetChrome.module.css +10 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
- package/src/canvas/widgets/expandUtils.js +188 -0
- package/src/canvas/widgets/index.js +5 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
- package/src/canvas/widgets/widgetConfig.js +19 -1
- package/src/hooks/useConfig.js +14 -0
- package/src/index.js +4 -0
- package/src/vite/data-plugin.js +264 -14
|
@@ -6,11 +6,12 @@ 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, getAnchorState, canAcceptConnection } from './widgets/widgetConfig.js'
|
|
9
|
+
import { getFeatures, isResizable, isExpandable, 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'
|
|
13
13
|
import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
|
|
14
|
+
import { findConnectedSplitTarget } from './widgets/expandUtils.js'
|
|
14
15
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
15
16
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
16
17
|
import useUndoRedo from './useUndoRedo.js'
|
|
@@ -303,6 +304,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
303
304
|
widget,
|
|
304
305
|
selected,
|
|
305
306
|
multiSelected,
|
|
307
|
+
connectorCount,
|
|
306
308
|
onSelect,
|
|
307
309
|
onDeselect,
|
|
308
310
|
onUpdate,
|
|
@@ -319,7 +321,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
319
321
|
// Dynamically adjust features based on widget state
|
|
320
322
|
const features = useMemo(() => {
|
|
321
323
|
const isGitHub = !!widget.props?.github
|
|
322
|
-
|
|
324
|
+
const adjusted = rawFeatures.map((f) => {
|
|
323
325
|
// Toggle collapse label and hide when content is short (no github = no collapse)
|
|
324
326
|
if (f.action === 'toggle-collapse') {
|
|
325
327
|
if (widget.type === 'link-preview' && !isGitHub) return null
|
|
@@ -333,7 +335,28 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
333
335
|
if (f.action === 'refresh-github' && !isGitHub) return null
|
|
334
336
|
return f
|
|
335
337
|
}).filter(Boolean)
|
|
336
|
-
|
|
338
|
+
|
|
339
|
+
// Add dynamic "Split Screen" action when a connected split target exists
|
|
340
|
+
if (isExpandable(widget.type)) {
|
|
341
|
+
const hasConnected = Boolean(findConnectedSplitTarget(widget.id))
|
|
342
|
+
if (hasConnected) {
|
|
343
|
+
// Insert before the first menu-only feature
|
|
344
|
+
const insertIdx = adjusted.findIndex((f) => f.menu)
|
|
345
|
+
const splitFeature = {
|
|
346
|
+
id: 'split-screen',
|
|
347
|
+
type: 'action',
|
|
348
|
+
action: 'split-screen',
|
|
349
|
+
label: 'Split Screen',
|
|
350
|
+
icon: 'columns',
|
|
351
|
+
prod: true,
|
|
352
|
+
}
|
|
353
|
+
if (insertIdx >= 0) adjusted.splice(insertIdx, 0, splitFeature)
|
|
354
|
+
else adjusted.push(splitFeature)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return adjusted
|
|
359
|
+
}, [rawFeatures, widget.props?.github, widget.props?.collapsed, widget.type, widget.id, connectorCount])
|
|
337
360
|
|
|
338
361
|
const handleAction = useCallback((actionId) => {
|
|
339
362
|
if (actionId === 'delete') {
|
|
@@ -398,6 +421,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
398
421
|
prev.widget === next.widget &&
|
|
399
422
|
prev.selected === next.selected &&
|
|
400
423
|
prev.multiSelected === next.multiSelected &&
|
|
424
|
+
prev.connectorCount === next.connectorCount &&
|
|
401
425
|
prev.readOnly === next.readOnly &&
|
|
402
426
|
prev.onSelect === next.onSelect &&
|
|
403
427
|
prev.onDeselect === next.onDeselect &&
|
|
@@ -571,6 +595,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
571
595
|
stateRef.current = { widgets: localWidgets, sources: localSources, connectors: localConnectors }
|
|
572
596
|
}, [localWidgets, localSources, localConnectors])
|
|
573
597
|
|
|
598
|
+
// Dirty flag — true while optimistic edits haven't been persisted yet.
|
|
599
|
+
// Prevents HMR echoes from overwriting in-flight local state.
|
|
600
|
+
const dirtyRef = useRef(false)
|
|
601
|
+
|
|
574
602
|
// Serialized write queue — ensures JSONL events land in the right order
|
|
575
603
|
const writeQueueRef = useRef(Promise.resolve())
|
|
576
604
|
function queueWrite(fn) {
|
|
@@ -665,9 +693,16 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
665
693
|
const isCanvasSwitch = trackedCanvas && canvas && trackedCanvas._route !== canvas._route
|
|
666
694
|
console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
|
|
667
695
|
setTrackedCanvas(canvas)
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
696
|
+
|
|
697
|
+
// Skip replacing local state with server data when optimistic edits are
|
|
698
|
+
// pending — the local state is more recent. The next save will persist it
|
|
699
|
+
// and the subsequent server push (after dirty clears) will reconcile.
|
|
700
|
+
if (!dirtyRef.current || isCanvasSwitch) {
|
|
701
|
+
setLocalWidgets(canvas?.widgets ?? null)
|
|
702
|
+
setLocalConnectors(canvas?.connectors ?? [])
|
|
703
|
+
setLocalSources(canvas?.sources ?? [])
|
|
704
|
+
}
|
|
705
|
+
|
|
671
706
|
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
672
707
|
setSnapGridSize(canvas?.gridSize || 40)
|
|
673
708
|
undoRedo.reset()
|
|
@@ -683,11 +718,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
683
718
|
}
|
|
684
719
|
}
|
|
685
720
|
|
|
686
|
-
// Debounced save to server
|
|
721
|
+
// Debounced save to server — routed through queueWrite to serialize
|
|
722
|
+
// with deletes and other writes, preventing stale data from overwriting.
|
|
687
723
|
const debouncedSave = useRef(
|
|
688
724
|
debounce((canvasId, widgets) => {
|
|
689
|
-
|
|
690
|
-
|
|
725
|
+
queueWrite(() =>
|
|
726
|
+
updateCanvas(canvasId, { widgets })
|
|
727
|
+
.catch((err) => console.error('[canvas] Failed to save:', err))
|
|
728
|
+
.finally(() => { dirtyRef.current = false })
|
|
691
729
|
)
|
|
692
730
|
}, 2000)
|
|
693
731
|
).current
|
|
@@ -705,12 +743,17 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
705
743
|
const next = prev.map((w) =>
|
|
706
744
|
w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
|
|
707
745
|
)
|
|
746
|
+
dirtyRef.current = true
|
|
708
747
|
debouncedSave(canvasId, next)
|
|
709
748
|
return next
|
|
710
749
|
})
|
|
711
750
|
}, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
|
|
712
751
|
|
|
713
752
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
753
|
+
// Cancel any pending debounced save — it may contain stale data
|
|
754
|
+
// that includes the widget we're about to delete
|
|
755
|
+
debouncedSave.cancel()
|
|
756
|
+
|
|
714
757
|
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
715
758
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
716
759
|
// Cascade: remove connectors referencing this widget
|
|
@@ -726,12 +769,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
726
769
|
}
|
|
727
770
|
return prev.filter((c) => c.start.widgetId !== widgetId && c.end.widgetId !== widgetId)
|
|
728
771
|
})
|
|
772
|
+
dirtyRef.current = true
|
|
729
773
|
queueWrite(() =>
|
|
730
|
-
removeWidgetApi(canvasId, widgetId)
|
|
731
|
-
console.error('[canvas] Failed to remove widget:', err)
|
|
732
|
-
|
|
774
|
+
removeWidgetApi(canvasId, widgetId)
|
|
775
|
+
.catch((err) => console.error('[canvas] Failed to remove widget:', err))
|
|
776
|
+
.finally(() => { dirtyRef.current = false })
|
|
733
777
|
)
|
|
734
|
-
}, [canvasId, undoRedo])
|
|
778
|
+
}, [canvasId, undoRedo, debouncedSave])
|
|
735
779
|
|
|
736
780
|
const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
|
|
737
781
|
try {
|
|
@@ -802,21 +846,52 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
802
846
|
y: (scrollEl.scrollTop + clientY - rect.top) / scale,
|
|
803
847
|
})
|
|
804
848
|
|
|
805
|
-
// Find nearest anchor on any other widget within snap
|
|
806
|
-
|
|
849
|
+
// Find nearest anchor on any other widget within a rectangular snap zone.
|
|
850
|
+
// Each anchor has a 30px-wide strip (15px each side) extending from the widget edge.
|
|
851
|
+
const SNAP_EXTEND = 15
|
|
852
|
+
const SNAP_DEPTH = 40
|
|
853
|
+
const SNAP_CROSS = 20 // perpendicular expansion so you can approach from any direction
|
|
807
854
|
const sourceType = startWidget.type
|
|
808
855
|
const findNearestAnchor = (canvasPt) => {
|
|
809
856
|
const currentWidgets = stateRef.current.widgets ?? []
|
|
810
857
|
let best = null
|
|
811
|
-
let bestDist =
|
|
858
|
+
let bestDist = Infinity
|
|
812
859
|
for (const w of currentWidgets) {
|
|
813
860
|
if (w.id === widgetId) continue
|
|
814
|
-
// Check if this widget type accepts connections from the source type
|
|
815
861
|
if (!canAcceptConnection(w.type, sourceType)) continue
|
|
862
|
+
|
|
863
|
+
let ww, wh
|
|
864
|
+
const el = document.getElementById(w.id)
|
|
865
|
+
if (el) {
|
|
866
|
+
const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
|
|
867
|
+
if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
|
|
868
|
+
}
|
|
869
|
+
if (!ww) ww = w.props?.width ?? w.bounds?.width ?? 270
|
|
870
|
+
if (!wh) wh = w.props?.height ?? w.bounds?.height ?? 170
|
|
871
|
+
const wx = w.position?.x ?? 0
|
|
872
|
+
const wy = w.position?.y ?? 0
|
|
873
|
+
|
|
816
874
|
for (const anch of ['top', 'bottom', 'left', 'right']) {
|
|
817
|
-
// Skip unavailable or disabled anchors
|
|
818
875
|
const anchorState = getAnchorState(w.type, anch)
|
|
819
876
|
if (anchorState !== 'available') continue
|
|
877
|
+
|
|
878
|
+
// Build a rectangular hit zone for this anchor
|
|
879
|
+
let inZone = false
|
|
880
|
+
if (anch === 'top') {
|
|
881
|
+
inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
|
|
882
|
+
canvasPt.y >= wy - SNAP_DEPTH && canvasPt.y <= wy + SNAP_EXTEND
|
|
883
|
+
} else if (anch === 'bottom') {
|
|
884
|
+
inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
|
|
885
|
+
canvasPt.y >= wy + wh - SNAP_EXTEND && canvasPt.y <= wy + wh + SNAP_DEPTH
|
|
886
|
+
} else if (anch === 'left') {
|
|
887
|
+
inZone = canvasPt.x >= wx - SNAP_DEPTH && canvasPt.x <= wx + SNAP_EXTEND &&
|
|
888
|
+
canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
|
|
889
|
+
} else if (anch === 'right') {
|
|
890
|
+
inZone = canvasPt.x >= wx + ww - SNAP_EXTEND && canvasPt.x <= wx + ww + SNAP_DEPTH &&
|
|
891
|
+
canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
|
|
892
|
+
}
|
|
893
|
+
if (!inZone) continue
|
|
894
|
+
|
|
820
895
|
const pt = computeAnchorPt(w, anch)
|
|
821
896
|
const dist = Math.hypot(pt.x - canvasPt.x, pt.y - canvasPt.y)
|
|
822
897
|
if (dist < bestDist) {
|
|
@@ -872,119 +947,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
872
947
|
document.addEventListener('pointerup', handlePointerUp)
|
|
873
948
|
}, [handleConnectorAdd])
|
|
874
949
|
|
|
875
|
-
//
|
|
876
|
-
|
|
877
|
-
|
|
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])
|
|
950
|
+
// Endpoint drag removed — dragging from a filled anchor now always
|
|
951
|
+
// creates a new connection via handleConnectorDragStart instead of
|
|
952
|
+
// repositioning the existing one.
|
|
988
953
|
|
|
989
954
|
const handleWidgetCopy = useCallback(async (widget) => {
|
|
990
955
|
// Find the next free offset — check how many copies already exist at +n*40
|
|
@@ -999,10 +964,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
999
964
|
}
|
|
1000
965
|
const position = { x: baseX + n * 40, y: baseY + n * 40 }
|
|
1001
966
|
try {
|
|
967
|
+
const copyProps = { ...widget.props }
|
|
968
|
+
// Terminal widgets must get unique names — strip prettyName so the server generates a fresh one
|
|
969
|
+
if (widget.type === 'terminal' || widget.type === 'agent') delete copyProps.prettyName
|
|
970
|
+
|
|
1002
971
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1003
972
|
const result = await addWidgetApi(canvasId, {
|
|
1004
973
|
type: widget.type,
|
|
1005
|
-
props:
|
|
974
|
+
props: copyProps,
|
|
1006
975
|
position,
|
|
1007
976
|
})
|
|
1008
977
|
if (result.success && result.widget) {
|
|
@@ -1141,10 +1110,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1141
1110
|
}
|
|
1142
1111
|
return w
|
|
1143
1112
|
})
|
|
1113
|
+
dirtyRef.current = true
|
|
1144
1114
|
queueWrite(() =>
|
|
1145
|
-
updateCanvas(canvasId, { widgets: next })
|
|
1146
|
-
console.error('[canvas] Failed to save multi-move:', err)
|
|
1147
|
-
|
|
1115
|
+
updateCanvas(canvasId, { widgets: next })
|
|
1116
|
+
.catch((err) => console.error('[canvas] Failed to save multi-move:', err))
|
|
1117
|
+
.finally(() => { dirtyRef.current = false })
|
|
1148
1118
|
)
|
|
1149
1119
|
return next
|
|
1150
1120
|
})
|
|
@@ -1173,10 +1143,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1173
1143
|
return s
|
|
1174
1144
|
})
|
|
1175
1145
|
if (changed) {
|
|
1146
|
+
dirtyRef.current = true
|
|
1176
1147
|
queueWrite(() =>
|
|
1177
|
-
updateCanvas(canvasId, { sources: next })
|
|
1178
|
-
console.error('[canvas] Failed to save multi-move sources:', err)
|
|
1179
|
-
|
|
1148
|
+
updateCanvas(canvasId, { sources: next })
|
|
1149
|
+
.catch((err) => console.error('[canvas] Failed to save multi-move sources:', err))
|
|
1150
|
+
.finally(() => { dirtyRef.current = false })
|
|
1180
1151
|
)
|
|
1181
1152
|
}
|
|
1182
1153
|
return changed ? next : current
|
|
@@ -1192,10 +1163,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1192
1163
|
const next = current.some((s) => s?.export === sourceExport)
|
|
1193
1164
|
? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
|
|
1194
1165
|
: [...current, { export: sourceExport, position: rounded }]
|
|
1166
|
+
dirtyRef.current = true
|
|
1195
1167
|
queueWrite(() =>
|
|
1196
|
-
updateCanvas(canvasId, { sources: next })
|
|
1197
|
-
console.error('[canvas] Failed to save source position:', err)
|
|
1198
|
-
|
|
1168
|
+
updateCanvas(canvasId, { sources: next })
|
|
1169
|
+
.catch((err) => console.error('[canvas] Failed to save source position:', err))
|
|
1170
|
+
.finally(() => { dirtyRef.current = false })
|
|
1199
1171
|
)
|
|
1200
1172
|
return next
|
|
1201
1173
|
})
|
|
@@ -1203,15 +1175,17 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1203
1175
|
}
|
|
1204
1176
|
|
|
1205
1177
|
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
1178
|
+
debouncedSave.cancel()
|
|
1206
1179
|
setLocalWidgets((prev) => {
|
|
1207
1180
|
if (!prev) return prev
|
|
1208
1181
|
const next = prev.map((w) =>
|
|
1209
1182
|
w.id === dragId ? { ...w, position: rounded } : w
|
|
1210
1183
|
)
|
|
1184
|
+
dirtyRef.current = true
|
|
1211
1185
|
queueWrite(() =>
|
|
1212
|
-
updateCanvas(canvasId, { widgets: next })
|
|
1213
|
-
console.error('[canvas] Failed to save widget position:', err)
|
|
1214
|
-
|
|
1186
|
+
updateCanvas(canvasId, { widgets: next })
|
|
1187
|
+
.catch((err) => console.error('[canvas] Failed to save widget position:', err))
|
|
1188
|
+
.finally(() => { dirtyRef.current = false })
|
|
1215
1189
|
)
|
|
1216
1190
|
return next
|
|
1217
1191
|
})
|
|
@@ -1544,14 +1518,15 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1544
1518
|
}, [selectedWidgetIds, canvasId, getSelectedWidgetData])
|
|
1545
1519
|
|
|
1546
1520
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
1547
|
-
const addWidget = useCallback(async (type) => {
|
|
1521
|
+
const addWidget = useCallback(async (type, extraProps = {}) => {
|
|
1548
1522
|
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
1523
|
+
const mergedProps = { ...defaultProps, ...extraProps }
|
|
1549
1524
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1550
|
-
const pos = centerPositionForWidget(center, type,
|
|
1525
|
+
const pos = centerPositionForWidget(center, type, mergedProps)
|
|
1551
1526
|
try {
|
|
1552
1527
|
const result = await addWidgetApi(canvasId, {
|
|
1553
1528
|
type,
|
|
1554
|
-
props:
|
|
1529
|
+
props: mergedProps,
|
|
1555
1530
|
position: pos,
|
|
1556
1531
|
})
|
|
1557
1532
|
if (result.success && result.widget) {
|
|
@@ -1588,7 +1563,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1588
1563
|
// Listen for CoreUIBar add-widget events
|
|
1589
1564
|
useEffect(() => {
|
|
1590
1565
|
function handleAddWidget(e) {
|
|
1591
|
-
addWidget(e.detail.type)
|
|
1566
|
+
addWidget(e.detail.type, e.detail.props)
|
|
1592
1567
|
}
|
|
1593
1568
|
function handleAddStoryWidget(e) {
|
|
1594
1569
|
addStoryWidget(e.detail.storyId)
|
|
@@ -1728,6 +1703,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1728
1703
|
}))
|
|
1729
1704
|
}, [canvasId, zoom])
|
|
1730
1705
|
|
|
1706
|
+
// Keep bridge in sync with widgets/connectors for expand features
|
|
1707
|
+
useEffect(() => {
|
|
1708
|
+
const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
|
|
1709
|
+
bridge.widgets = localWidgets
|
|
1710
|
+
bridge.connectors = localConnectors
|
|
1711
|
+
window[CANVAS_BRIDGE_STATE_KEY] = bridge
|
|
1712
|
+
}, [localWidgets, localConnectors])
|
|
1713
|
+
|
|
1731
1714
|
// Delete selected widget on Delete/Backspace key
|
|
1732
1715
|
useEffect(() => {
|
|
1733
1716
|
function handleSelectStart(e) {
|
|
@@ -1857,6 +1840,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1857
1840
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1858
1841
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1859
1842
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1843
|
+
navigator.clipboard?.writeText(result.widget.id).catch(() => {})
|
|
1860
1844
|
}
|
|
1861
1845
|
return true
|
|
1862
1846
|
} catch (err) {
|
|
@@ -1948,9 +1932,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1948
1932
|
for (const w of sourceWidgets) {
|
|
1949
1933
|
const relX = (w.position?.x ?? 0) - minX
|
|
1950
1934
|
const relY = (w.position?.y ?? 0) - minY
|
|
1935
|
+
const pasteProps = { ...w.props }
|
|
1936
|
+
if (w.type === 'terminal' || w.type === 'agent') delete pasteProps.prettyName
|
|
1951
1937
|
const result = await addWidgetApi(canvasId, {
|
|
1952
1938
|
type: w.type,
|
|
1953
|
-
props:
|
|
1939
|
+
props: pasteProps,
|
|
1954
1940
|
position: { x: baseX + relX, y: baseY + relY },
|
|
1955
1941
|
})
|
|
1956
1942
|
if (result.success && result.widget) {
|
|
@@ -2073,13 +2059,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2073
2059
|
if (!previous) return
|
|
2074
2060
|
debouncedSave.cancel()
|
|
2075
2061
|
debouncedSourceSave.cancel()
|
|
2062
|
+
dirtyRef.current = true
|
|
2076
2063
|
setLocalWidgets(previous.widgets)
|
|
2077
2064
|
setLocalSources(previous.sources)
|
|
2078
2065
|
setLocalConnectors(previous.connectors ?? [])
|
|
2079
2066
|
queueWrite(() =>
|
|
2080
|
-
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors })
|
|
2081
|
-
console.error('[canvas] Failed to persist undo:', err)
|
|
2082
|
-
|
|
2067
|
+
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors })
|
|
2068
|
+
.catch((err) => console.error('[canvas] Failed to persist undo:', err))
|
|
2069
|
+
.finally(() => { dirtyRef.current = false })
|
|
2083
2070
|
)
|
|
2084
2071
|
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
2085
2072
|
|
|
@@ -2088,13 +2075,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2088
2075
|
if (!next) return
|
|
2089
2076
|
debouncedSave.cancel()
|
|
2090
2077
|
debouncedSourceSave.cancel()
|
|
2078
|
+
dirtyRef.current = true
|
|
2091
2079
|
setLocalWidgets(next.widgets)
|
|
2092
2080
|
setLocalSources(next.sources)
|
|
2093
2081
|
setLocalConnectors(next.connectors ?? [])
|
|
2094
2082
|
queueWrite(() =>
|
|
2095
|
-
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors })
|
|
2096
|
-
console.error('[canvas] Failed to persist redo:', err)
|
|
2097
|
-
|
|
2083
|
+
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors })
|
|
2084
|
+
.catch((err) => console.error('[canvas] Failed to persist redo:', err))
|
|
2085
|
+
.finally(() => { dirtyRef.current = false })
|
|
2098
2086
|
)
|
|
2099
2087
|
}, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
|
|
2100
2088
|
|
|
@@ -2386,27 +2374,31 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2386
2374
|
return aSelected - bSelected
|
|
2387
2375
|
})
|
|
2388
2376
|
for (const widget of sortedWidgets) {
|
|
2389
|
-
|
|
2377
|
+
// In production, render terminal widgets as read-only instead of hiding them
|
|
2378
|
+
const effectiveWidget = (!isLocalDev && (widget.type === 'terminal' || widget.type === 'agent'))
|
|
2379
|
+
? { ...widget, type: 'terminal-read' }
|
|
2380
|
+
: widget
|
|
2390
2381
|
allChildren.push(
|
|
2391
2382
|
<div
|
|
2392
|
-
key={
|
|
2393
|
-
id={
|
|
2394
|
-
data-tc-x={
|
|
2395
|
-
data-tc-y={
|
|
2383
|
+
key={effectiveWidget.id}
|
|
2384
|
+
id={effectiveWidget.id}
|
|
2385
|
+
data-tc-x={effectiveWidget?.position?.x ?? 0}
|
|
2386
|
+
data-tc-y={effectiveWidget?.position?.y ?? 0}
|
|
2396
2387
|
{...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
|
|
2397
2388
|
{...canvasPrimerAttrs}
|
|
2398
2389
|
style={canvasThemeVars}
|
|
2399
2390
|
onClick={isLocalDev ? (e) => {
|
|
2400
2391
|
e.stopPropagation()
|
|
2401
2392
|
if (!e.target.closest('.tc-drag-handle')) {
|
|
2402
|
-
handleWidgetSelect(
|
|
2393
|
+
handleWidgetSelect(effectiveWidget.id, e.shiftKey)
|
|
2403
2394
|
}
|
|
2404
2395
|
} : undefined}
|
|
2405
2396
|
>
|
|
2406
2397
|
<ChromeWrappedWidget
|
|
2407
|
-
widget={
|
|
2398
|
+
widget={effectiveWidget}
|
|
2408
2399
|
selected={selectedWidgetIds.has(widget.id)}
|
|
2409
2400
|
multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
|
|
2401
|
+
connectorCount={localConnectors.filter((c) => c.start?.widgetId === widget.id || c.end?.widgetId === widget.id).length}
|
|
2410
2402
|
onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
|
|
2411
2403
|
onDeselect={handleDeselectAll}
|
|
2412
2404
|
onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
|
|
@@ -2423,19 +2415,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2423
2415
|
|
|
2424
2416
|
const scale = zoom / 100
|
|
2425
2417
|
|
|
2426
|
-
const
|
|
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
|
|
2418
|
+
const filteredConnectors = localConnectors
|
|
2433
2419
|
|
|
2434
2420
|
return (
|
|
2435
2421
|
<>
|
|
2436
2422
|
<div className={styles.canvasTitle}>
|
|
2437
2423
|
<a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
|
|
2438
|
-
<Icon name="
|
|
2424
|
+
<Icon name="home" size={16} color="#fff" />
|
|
2439
2425
|
</a>
|
|
2440
2426
|
<CanvasTitleEditable
|
|
2441
2427
|
canvasId={canvasId}
|
|
@@ -2444,9 +2430,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2444
2430
|
isLocalDev={isLocalDev}
|
|
2445
2431
|
/>
|
|
2446
2432
|
<PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
|
|
2447
|
-
{isLocalDev && (
|
|
2448
|
-
<span className={styles.localEditingLabel}>Local editing</span>
|
|
2449
|
-
)}
|
|
2450
2433
|
</div>
|
|
2451
2434
|
<div
|
|
2452
2435
|
ref={scrollRef}
|
|
@@ -2478,7 +2461,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2478
2461
|
connectors={filteredConnectors}
|
|
2479
2462
|
widgets={localWidgets ?? []}
|
|
2480
2463
|
onRemove={isLocalDev ? handleConnectorRemove : undefined}
|
|
2481
|
-
onEndpointDrag={
|
|
2464
|
+
onEndpointDrag={undefined}
|
|
2482
2465
|
dragPreview={connectorDrag}
|
|
2483
2466
|
hidden={widgetDragging}
|
|
2484
2467
|
/>
|
|
@@ -109,21 +109,6 @@
|
|
|
109
109
|
isolation: isolate;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
.localEditingLabel {
|
|
113
|
-
display: inline-flex;
|
|
114
|
-
align-items: center;
|
|
115
|
-
padding: 4px 12px;
|
|
116
|
-
background: hsl(212, 92%, 45%);
|
|
117
|
-
color: #fff;
|
|
118
|
-
font-size: 13px;
|
|
119
|
-
font-weight: 600;
|
|
120
|
-
border-radius: 6px;
|
|
121
|
-
letter-spacing: 0.01em;
|
|
122
|
-
white-space: nowrap;
|
|
123
|
-
pointer-events: none;
|
|
124
|
-
user-select: none;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
112
|
.ghInstallBanner {
|
|
128
113
|
position: fixed;
|
|
129
114
|
left: 50%;
|
|
@@ -89,12 +89,16 @@ vi.mock('./widgets/widgetProps.js', () => ({
|
|
|
89
89
|
getDefaults: () => ({}),
|
|
90
90
|
}))
|
|
91
91
|
|
|
92
|
-
vi.mock('./widgets/widgetConfig.js', () =>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
92
|
+
vi.mock('./widgets/widgetConfig.js', async () => {
|
|
93
|
+
const actual = await vi.importActual('./widgets/widgetConfig.js')
|
|
94
|
+
return {
|
|
95
|
+
getFeatures: () => [],
|
|
96
|
+
isResizable: () => false,
|
|
97
|
+
schemas: {},
|
|
98
|
+
getMenuWidgetTypes: () => [],
|
|
99
|
+
getConnectorDefaults: actual.getConnectorDefaults,
|
|
100
|
+
}
|
|
101
|
+
})
|
|
98
102
|
|
|
99
103
|
vi.mock('./widgets/figmaUrl.js', () => ({
|
|
100
104
|
isFigmaUrl: () => false,
|