@dfosco/storyboard 0.6.12 → 0.6.13
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/dist/storyboard-ui.css +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +6 -2
- package/src/core/canvas/materializer.js +39 -13
- package/src/core/canvas/server.js +245 -46
- package/src/core/canvas/server.test.js +226 -0
- package/src/core/canvas/undoRedo.js +174 -0
- package/src/core/canvas/undoRedo.test.js +250 -0
- package/src/core/cli/canvasRedo.js +44 -0
- package/src/core/cli/canvasUndo.js +40 -0
- package/src/core/cli/dev.js +5 -10
- package/src/core/cli/index.js +6 -0
- package/src/internals/canvas/CanvasPage.jsx +247 -124
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +39 -26
- package/src/internals/canvas/canvasApi.js +29 -0
- package/src/internals/canvas/prototypeRoutes.jsx +131 -0
- package/src/internals/canvas/prototypesEntry.jsx +66 -0
- package/src/internals/canvas/useUndoRedo.js +75 -60
- package/src/internals/canvas/useUndoRedo.test.js +46 -178
- package/src/internals/vite/data-plugin.js +72 -0
|
@@ -28,7 +28,10 @@ import {
|
|
|
28
28
|
duplicateImage,
|
|
29
29
|
fetchGitHubEmbed,
|
|
30
30
|
getCanvas as getCanvasApi,
|
|
31
|
+
patchWidget as patchWidgetApi,
|
|
31
32
|
removeWidget as removeWidgetApi,
|
|
33
|
+
redoEvent as redoEventApi,
|
|
34
|
+
undoEvent as undoEventApi,
|
|
32
35
|
updateCanvas,
|
|
33
36
|
updateFolderMeta,
|
|
34
37
|
uploadImage,
|
|
@@ -950,27 +953,66 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
950
953
|
}
|
|
951
954
|
}
|
|
952
955
|
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
956
|
+
// Per-widget debounced PATCH. Each widget gets its own pending props
|
|
957
|
+
// accumulator + timer so concurrent edits to different widgets don't stomp
|
|
958
|
+
// on one another. Successful patches return event ids — we push them onto
|
|
959
|
+
// the undo stack via undoRedo.trackMany so the toolbar Undo button targets
|
|
960
|
+
// exactly the right granular events.
|
|
961
|
+
const widgetPatchDebouncersRef = useRef(new Map())
|
|
962
|
+
const flushAllPendingWidgetPatches = useCallback(() => {
|
|
963
|
+
for (const entry of widgetPatchDebouncersRef.current.values()) {
|
|
964
|
+
if (entry.timer) {
|
|
965
|
+
clearTimeout(entry.timer)
|
|
966
|
+
entry.timer = null
|
|
967
|
+
entry.flushNow()
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}, [])
|
|
971
|
+
const cancelAllPendingWidgetPatches = useCallback(() => {
|
|
972
|
+
for (const entry of widgetPatchDebouncersRef.current.values()) {
|
|
973
|
+
if (entry.timer) {
|
|
974
|
+
clearTimeout(entry.timer)
|
|
975
|
+
entry.timer = null
|
|
976
|
+
entry.pendingProps = {}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}, [])
|
|
980
|
+
// patchWidgetDebounced(canvasId, widgetId, propsDelta) — coalesces multiple
|
|
981
|
+
// PATCHes per widget within DEBOUNCE_MS into a single request.
|
|
982
|
+
const patchWidgetDebounced = useCallback((targetCanvasId, widgetId, propsDelta) => {
|
|
983
|
+
if (!widgetId || !propsDelta) return
|
|
984
|
+
const map = widgetPatchDebouncersRef.current
|
|
985
|
+
let entry = map.get(widgetId)
|
|
986
|
+
if (!entry) {
|
|
987
|
+
entry = {
|
|
988
|
+
pendingProps: {},
|
|
989
|
+
timer: null,
|
|
990
|
+
flushNow: () => {
|
|
991
|
+
const props = entry.pendingProps
|
|
992
|
+
entry.pendingProps = {}
|
|
993
|
+
if (Object.keys(props).length === 0) return
|
|
994
|
+
dirtyRef.current = true
|
|
995
|
+
queueWrite(async () => {
|
|
996
|
+
try {
|
|
997
|
+
const res = await patchWidgetApi(targetCanvasId, widgetId, { props })
|
|
998
|
+
if (Array.isArray(res?.eventIds)) undoRedo.trackMany(res.eventIds)
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
console.error('[canvas] Failed to patch widget:', err)
|
|
1001
|
+
}
|
|
1002
|
+
})
|
|
1003
|
+
},
|
|
1004
|
+
}
|
|
1005
|
+
map.set(widgetId, entry)
|
|
1006
|
+
}
|
|
1007
|
+
Object.assign(entry.pendingProps, propsDelta)
|
|
1008
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
1009
|
+
entry.timer = setTimeout(() => {
|
|
1010
|
+
entry.timer = null
|
|
1011
|
+
entry.flushNow()
|
|
1012
|
+
}, 500)
|
|
1013
|
+
}, [undoRedo])
|
|
971
1014
|
|
|
972
1015
|
const handleWidgetUpdate = useCallback((widgetId, updates) => {
|
|
973
|
-
undoRedo.snapshot(stateRef.current, 'edit', widgetId)
|
|
974
1016
|
// Snap width/height to grid when snap is enabled
|
|
975
1017
|
const snapped = { ...updates }
|
|
976
1018
|
if (snapEnabled && snapGridSize) {
|
|
@@ -985,11 +1027,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
985
1027
|
const next = prev.map((w) =>
|
|
986
1028
|
w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
|
|
987
1029
|
)
|
|
988
|
-
dirtyRef.current = true
|
|
989
|
-
debouncedSave(canvasId, next)
|
|
990
1030
|
return next
|
|
991
1031
|
})
|
|
992
|
-
|
|
1032
|
+
dirtyRef.current = true
|
|
1033
|
+
patchWidgetDebounced(canvasId, widgetId, snapped)
|
|
1034
|
+
}, [canvasId, patchWidgetDebounced, snapEnabled, snapGridSize])
|
|
993
1035
|
|
|
994
1036
|
// Stable ref so handleWidgetSelect can flip a "done" agent back to
|
|
995
1037
|
// "running" without re-creating itself on every widget update.
|
|
@@ -1002,35 +1044,60 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1002
1044
|
const defaultMeta = hubRoleOptions.find((r) => r.id === defaultHubRole) || null
|
|
1003
1045
|
const fallbackRoleId = defaultMeta?.id || 'member'
|
|
1004
1046
|
|
|
1005
|
-
undoRedo.snapshot(stateRef.current, 'edit-role', widgetId)
|
|
1006
1047
|
setLocalWidgets((prev) => {
|
|
1007
1048
|
if (!prev) return prev
|
|
1008
1049
|
|
|
1009
1050
|
const componentIds = getConnectedComponent(widgetId, localConnectors)
|
|
1010
1051
|
const scopeHas = (id) => componentIds.has(id)
|
|
1052
|
+
const roleChanges = [] // { widgetId, role }
|
|
1011
1053
|
const next = prev.map((widget) => {
|
|
1012
1054
|
if (widget.id === widgetId) {
|
|
1055
|
+
roleChanges.push({ widgetId: widget.id, role: targetRole })
|
|
1013
1056
|
return { ...widget, props: { ...widget.props, role: targetRole } }
|
|
1014
1057
|
}
|
|
1015
1058
|
if (!roleMeta || roleMeta.type !== 'unique') return widget
|
|
1016
1059
|
if (widget.type !== 'agent' && widget.type !== 'prompt') return widget
|
|
1017
1060
|
if (!scopeHas(widget.id)) return widget
|
|
1018
1061
|
if ((widget.props?.role || fallbackRoleId) !== targetRole) return widget
|
|
1062
|
+
roleChanges.push({ widgetId: widget.id, role: fallbackRoleId })
|
|
1019
1063
|
return { ...widget, props: { ...widget.props, role: fallbackRoleId } }
|
|
1020
1064
|
})
|
|
1021
1065
|
|
|
1022
1066
|
dirtyRef.current = true
|
|
1023
|
-
|
|
1067
|
+
// Persist as a single batch so undo restores all role flips atomically.
|
|
1068
|
+
if (roleChanges.length > 0) {
|
|
1069
|
+
const operations = roleChanges.map((c) => ({
|
|
1070
|
+
op: 'update-widget',
|
|
1071
|
+
widgetId: c.widgetId,
|
|
1072
|
+
props: { role: c.role },
|
|
1073
|
+
}))
|
|
1074
|
+
queueWrite(async () => {
|
|
1075
|
+
try {
|
|
1076
|
+
const res = await batchOperations(canvasId, operations)
|
|
1077
|
+
const ids = (res?.results || [])
|
|
1078
|
+
.map((r) => r?.eventId)
|
|
1079
|
+
.filter(Boolean)
|
|
1080
|
+
if (ids.length > 0) undoRedo.trackMany(ids)
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
console.error('[canvas] Failed to persist role change:', err)
|
|
1083
|
+
}
|
|
1084
|
+
})
|
|
1085
|
+
}
|
|
1024
1086
|
return next
|
|
1025
1087
|
})
|
|
1026
|
-
}, [canvasId,
|
|
1088
|
+
}, [canvasId, undoRedo, defaultHubRole, hubRoleOptions, localConnectors])
|
|
1027
1089
|
|
|
1028
1090
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
1029
|
-
// Cancel any pending
|
|
1030
|
-
//
|
|
1031
|
-
|
|
1091
|
+
// Cancel any pending widget patches that target this widget — they
|
|
1092
|
+
// would race against the delete and leave a stale widget_updated event
|
|
1093
|
+
// in the JSONL.
|
|
1094
|
+
const debouncer = widgetPatchDebouncersRef.current.get(widgetId)
|
|
1095
|
+
if (debouncer?.timer) {
|
|
1096
|
+
clearTimeout(debouncer.timer)
|
|
1097
|
+
debouncer.timer = null
|
|
1098
|
+
debouncer.pendingProps = {}
|
|
1099
|
+
}
|
|
1032
1100
|
|
|
1033
|
-
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
1034
1101
|
markWidgetDeleted(widgetId)
|
|
1035
1102
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
1036
1103
|
// Cascade: remove connectors referencing this widget
|
|
@@ -1039,27 +1106,34 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1039
1106
|
if (orphaned.length === 0) return prev
|
|
1040
1107
|
for (const c of orphaned) {
|
|
1041
1108
|
markConnectorDeleted(c.id)
|
|
1042
|
-
queueWrite(() =>
|
|
1043
|
-
|
|
1109
|
+
queueWrite(async () => {
|
|
1110
|
+
try {
|
|
1111
|
+
const res = await removeConnectorApi(canvasId, c.id)
|
|
1112
|
+
if (res?.eventId) undoRedo.track(res.eventId)
|
|
1113
|
+
} catch (err) {
|
|
1044
1114
|
console.error('[canvas] Failed to remove orphaned connector:', err)
|
|
1045
|
-
|
|
1046
|
-
)
|
|
1115
|
+
}
|
|
1116
|
+
})
|
|
1047
1117
|
}
|
|
1048
1118
|
return prev.filter((c) => c.start.widgetId !== widgetId && c.end.widgetId !== widgetId)
|
|
1049
1119
|
})
|
|
1050
1120
|
dirtyRef.current = true
|
|
1051
|
-
queueWrite(() =>
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1121
|
+
queueWrite(async () => {
|
|
1122
|
+
try {
|
|
1123
|
+
const res = await removeWidgetApi(canvasId, widgetId)
|
|
1124
|
+
if (res?.eventId) undoRedo.track(res.eventId)
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
console.error('[canvas] Failed to remove widget:', err)
|
|
1127
|
+
}
|
|
1128
|
+
})
|
|
1129
|
+
}, [canvasId, undoRedo, markWidgetDeleted, markConnectorDeleted])
|
|
1056
1130
|
|
|
1057
1131
|
const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
|
|
1058
1132
|
try {
|
|
1059
|
-
undoRedo.snapshot(stateRef.current, 'connector-add')
|
|
1060
1133
|
const result = await addConnectorApi(canvasId, { startWidgetId, startAnchor, endWidgetId, endAnchor })
|
|
1061
1134
|
if (result.success && result.connector) {
|
|
1062
1135
|
setLocalConnectors((prev) => [...prev, result.connector])
|
|
1136
|
+
if (result.eventId) undoRedo.track(result.eventId)
|
|
1063
1137
|
}
|
|
1064
1138
|
} catch (err) {
|
|
1065
1139
|
console.error('[canvas] Failed to add connector:', err)
|
|
@@ -1099,15 +1173,17 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1099
1173
|
}, [handleConnectorAdd])
|
|
1100
1174
|
|
|
1101
1175
|
const handleConnectorRemove = useCallback((connectorId) => {
|
|
1102
|
-
undoRedo.snapshot(stateRef.current, 'connector-remove')
|
|
1103
1176
|
markConnectorDeleted(connectorId)
|
|
1104
1177
|
setLocalConnectors((prev) => prev.filter((c) => c.id !== connectorId))
|
|
1105
1178
|
dirtyRef.current = true
|
|
1106
|
-
queueWrite(() =>
|
|
1107
|
-
|
|
1179
|
+
queueWrite(async () => {
|
|
1180
|
+
try {
|
|
1181
|
+
const res = await removeConnectorApi(canvasId, connectorId)
|
|
1182
|
+
if (res?.eventId) undoRedo.track(res.eventId)
|
|
1183
|
+
} catch (err) {
|
|
1108
1184
|
console.error('[canvas] Failed to remove connector:', err)
|
|
1109
|
-
|
|
1110
|
-
)
|
|
1185
|
+
}
|
|
1186
|
+
})
|
|
1111
1187
|
}, [canvasId, undoRedo, markConnectorDeleted])
|
|
1112
1188
|
|
|
1113
1189
|
// Connector drag state
|
|
@@ -1293,7 +1369,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1293
1369
|
if (dupResult.success) copyProps.src = dupResult.filename
|
|
1294
1370
|
}
|
|
1295
1371
|
|
|
1296
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
1297
1372
|
const result = await addWidgetApi(canvasId, {
|
|
1298
1373
|
type: widget.type,
|
|
1299
1374
|
props: copyProps,
|
|
@@ -1305,6 +1380,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1305
1380
|
}
|
|
1306
1381
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1307
1382
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1383
|
+
if (result.eventId) undoRedo.track(result.eventId)
|
|
1308
1384
|
}
|
|
1309
1385
|
} catch (err) {
|
|
1310
1386
|
console.error('[canvas] Failed to copy widget:', err)
|
|
@@ -1316,8 +1392,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1316
1392
|
if (!widget) return
|
|
1317
1393
|
const widgets = [widget]
|
|
1318
1394
|
|
|
1319
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
1320
|
-
|
|
1321
1395
|
const occupied = new Set(
|
|
1322
1396
|
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
1323
1397
|
)
|
|
@@ -1378,8 +1452,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1378
1452
|
const newWidgets = []
|
|
1379
1453
|
const newConnectors = []
|
|
1380
1454
|
const refMap = response.refs || {}
|
|
1455
|
+
const eventIds = []
|
|
1381
1456
|
|
|
1382
1457
|
for (const result of response.results) {
|
|
1458
|
+
if (result.eventId) eventIds.push(result.eventId)
|
|
1383
1459
|
if (result.op === 'create-widget' && result.widget) {
|
|
1384
1460
|
newWidgets.push(result.widget)
|
|
1385
1461
|
}
|
|
@@ -1409,6 +1485,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1409
1485
|
if (newConnectors.length > 0) {
|
|
1410
1486
|
setLocalConnectors((prev) => [...prev, ...newConnectors])
|
|
1411
1487
|
}
|
|
1488
|
+
if (eventIds.length > 0) undoRedo.trackMany(eventIds)
|
|
1412
1489
|
} catch (err) {
|
|
1413
1490
|
console.error('[canvas] Failed to duplicate with connectors:', err)
|
|
1414
1491
|
}
|
|
@@ -1419,9 +1496,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1419
1496
|
const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
|
|
1420
1497
|
if (widgets.length === 0) return
|
|
1421
1498
|
|
|
1422
|
-
// Single undo snapshot for the entire batch
|
|
1423
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
1424
|
-
|
|
1425
1499
|
// Compute occupied positions to find free offset
|
|
1426
1500
|
const occupied = new Set(
|
|
1427
1501
|
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
@@ -1435,6 +1509,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1435
1509
|
while (anyOccupied()) offset++
|
|
1436
1510
|
|
|
1437
1511
|
const newWidgets = []
|
|
1512
|
+
const eventIds = []
|
|
1438
1513
|
for (const widget of widgets) {
|
|
1439
1514
|
const position = {
|
|
1440
1515
|
x: (widget.position?.x ?? 0) + offset * 40,
|
|
@@ -1460,6 +1535,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1460
1535
|
result.widget.props = { ...result.widget.props, webglReady: true }
|
|
1461
1536
|
}
|
|
1462
1537
|
newWidgets.push(result.widget)
|
|
1538
|
+
if (result.eventId) eventIds.push(result.eventId)
|
|
1463
1539
|
}
|
|
1464
1540
|
} catch (err) {
|
|
1465
1541
|
console.error('[canvas] Failed to duplicate widget:', err)
|
|
@@ -1470,6 +1546,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1470
1546
|
setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
|
|
1471
1547
|
setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
|
|
1472
1548
|
}
|
|
1549
|
+
if (eventIds.length > 0) undoRedo.trackMany(eventIds)
|
|
1473
1550
|
}, [canvasId, localWidgets, selectedWidgetIds, undoRedo])
|
|
1474
1551
|
|
|
1475
1552
|
// Duplicate selected widgets WITH connectors (Cmd+Shift+D)
|
|
@@ -1479,8 +1556,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1479
1556
|
const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
|
|
1480
1557
|
if (widgets.length === 0) return
|
|
1481
1558
|
|
|
1482
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
1483
|
-
|
|
1484
1559
|
// Compute offset — same logic as handleDuplicateSelected
|
|
1485
1560
|
const occupied = new Set(
|
|
1486
1561
|
(localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
|
|
@@ -1558,8 +1633,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1558
1633
|
const newWidgets = []
|
|
1559
1634
|
const newConnectors = []
|
|
1560
1635
|
const refMap = response.refs || {}
|
|
1636
|
+
const eventIds = []
|
|
1561
1637
|
|
|
1562
1638
|
for (const result of response.results) {
|
|
1639
|
+
if (result.eventId) eventIds.push(result.eventId)
|
|
1563
1640
|
if (result.op === 'create-widget' && result.widget) {
|
|
1564
1641
|
newWidgets.push(result.widget)
|
|
1565
1642
|
}
|
|
@@ -1590,6 +1667,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1590
1667
|
if (newConnectors.length > 0) {
|
|
1591
1668
|
setLocalConnectors((prev) => [...prev, ...newConnectors])
|
|
1592
1669
|
}
|
|
1670
|
+
if (eventIds.length > 0) undoRedo.trackMany(eventIds)
|
|
1593
1671
|
} catch (err) {
|
|
1594
1672
|
console.error('[canvas] Failed to duplicate with connectors:', err)
|
|
1595
1673
|
}
|
|
@@ -1663,17 +1741,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1663
1741
|
}, [buildGitHubPreviewUpdates, handleWidgetUpdate])
|
|
1664
1742
|
|
|
1665
1743
|
const debouncedSourceSave = useRef(
|
|
1666
|
-
debounce((canvasId, sources) => {
|
|
1667
|
-
queueWrite(() =>
|
|
1668
|
-
|
|
1744
|
+
debounce((canvasId, sources, onResult) => {
|
|
1745
|
+
queueWrite(async () => {
|
|
1746
|
+
try {
|
|
1747
|
+
const res = await updateCanvas(canvasId, { sources })
|
|
1748
|
+
if (typeof onResult === 'function') onResult(res)
|
|
1749
|
+
} catch (err) {
|
|
1669
1750
|
console.error('[canvas] Failed to save sources:', err)
|
|
1670
|
-
|
|
1671
|
-
)
|
|
1751
|
+
}
|
|
1752
|
+
})
|
|
1672
1753
|
}, 2000)
|
|
1673
1754
|
).current
|
|
1674
1755
|
|
|
1675
1756
|
const handleSourceUpdate = useCallback((exportName, updates) => {
|
|
1676
|
-
undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
|
|
1677
1757
|
const snapped = { ...updates }
|
|
1678
1758
|
if (snapEnabled && snapGridSize) {
|
|
1679
1759
|
if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 100)
|
|
@@ -1688,7 +1768,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1688
1768
|
debouncedSourceSave(canvasId, next)
|
|
1689
1769
|
return next
|
|
1690
1770
|
})
|
|
1691
|
-
}, [canvasId, debouncedSourceSave,
|
|
1771
|
+
}, [canvasId, debouncedSourceSave, snapEnabled, snapGridSize])
|
|
1692
1772
|
|
|
1693
1773
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
1694
1774
|
setWidgetDragging(false)
|
|
@@ -1706,7 +1786,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1706
1786
|
// Suppress the click-based selection reset that fires after pointerup
|
|
1707
1787
|
justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
|
|
1708
1788
|
requestAnimationFrame(() => { justDraggedRef.current = false })
|
|
1709
|
-
undoRedo.snapshot(stateRef.current, 'multi-move')
|
|
1710
1789
|
|
|
1711
1790
|
// Compute delta from the dragged widget's old position
|
|
1712
1791
|
const isJsx = dragId.startsWith('jsx-')
|
|
@@ -1722,33 +1801,46 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1722
1801
|
const dx = rounded.x - oldPos.x
|
|
1723
1802
|
const dy = rounded.y - oldPos.y
|
|
1724
1803
|
|
|
1725
|
-
|
|
1804
|
+
cancelAllPendingWidgetPatches()
|
|
1726
1805
|
|
|
1727
|
-
// Update JSON widget positions
|
|
1806
|
+
// Update JSON widget positions. We collect move ops inside the setState
|
|
1807
|
+
// updater (which runs during commit) and fire the batch from there to
|
|
1808
|
+
// avoid the "set before commit" timing trap.
|
|
1728
1809
|
setLocalWidgets((prev) => {
|
|
1729
1810
|
if (!prev) return prev
|
|
1811
|
+
const widgetMoveOps = []
|
|
1730
1812
|
const next = prev.map((w) => {
|
|
1731
|
-
if (w.id === dragId)
|
|
1813
|
+
if (w.id === dragId) {
|
|
1814
|
+
widgetMoveOps.push({ op: 'move-widget', widgetId: w.id, position: rounded })
|
|
1815
|
+
return { ...w, position: rounded }
|
|
1816
|
+
}
|
|
1732
1817
|
if (ids.has(w.id)) {
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
|
|
1737
|
-
y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
|
|
1738
|
-
},
|
|
1818
|
+
const newPos = {
|
|
1819
|
+
x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
|
|
1820
|
+
y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
|
|
1739
1821
|
}
|
|
1822
|
+
widgetMoveOps.push({ op: 'move-widget', widgetId: w.id, position: newPos })
|
|
1823
|
+
return { ...w, position: newPos }
|
|
1740
1824
|
}
|
|
1741
1825
|
return w
|
|
1742
1826
|
})
|
|
1743
1827
|
dirtyRef.current = true
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1828
|
+
if (widgetMoveOps.length > 0) {
|
|
1829
|
+
queueWrite(async () => {
|
|
1830
|
+
try {
|
|
1831
|
+
const res = await batchOperations(canvasId, widgetMoveOps)
|
|
1832
|
+
const evIds = (res?.results || []).map((r) => r?.eventId).filter(Boolean)
|
|
1833
|
+
if (evIds.length > 0) undoRedo.trackMany(evIds)
|
|
1834
|
+
} catch (err) {
|
|
1835
|
+
console.error('[canvas] Failed to save multi-move:', err)
|
|
1836
|
+
}
|
|
1837
|
+
})
|
|
1838
|
+
}
|
|
1748
1839
|
return next
|
|
1749
1840
|
})
|
|
1750
1841
|
|
|
1751
|
-
// Update JSX source positions
|
|
1842
|
+
// Update JSX source positions — still a single source_updated event;
|
|
1843
|
+
// not yet wired to per-source undo tracking.
|
|
1752
1844
|
setLocalSources((prev) => {
|
|
1753
1845
|
const current = Array.isArray(prev) ? prev : []
|
|
1754
1846
|
let changed = false
|
|
@@ -1784,7 +1876,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1784
1876
|
}
|
|
1785
1877
|
|
|
1786
1878
|
if (dragId.startsWith('jsx-')) {
|
|
1787
|
-
undoRedo.snapshot(stateRef.current, 'move', dragId)
|
|
1788
1879
|
const sourceExport = dragId.replace(/^jsx-/, '')
|
|
1789
1880
|
setLocalSources((prev) => {
|
|
1790
1881
|
const current = Array.isArray(prev) ? prev : []
|
|
@@ -1801,21 +1892,26 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1801
1892
|
return
|
|
1802
1893
|
}
|
|
1803
1894
|
|
|
1804
|
-
|
|
1805
|
-
|
|
1895
|
+
// Single-widget move — granular PATCH /widget so the move is its own
|
|
1896
|
+
// undoable event.
|
|
1897
|
+
cancelAllPendingWidgetPatches()
|
|
1806
1898
|
setLocalWidgets((prev) => {
|
|
1807
1899
|
if (!prev) return prev
|
|
1808
1900
|
const next = prev.map((w) =>
|
|
1809
1901
|
w.id === dragId ? { ...w, position: rounded } : w
|
|
1810
1902
|
)
|
|
1811
1903
|
dirtyRef.current = true
|
|
1812
|
-
queueWrite(() =>
|
|
1813
|
-
updateCanvas(canvasId, { widgets: next })
|
|
1814
|
-
.catch((err) => console.error('[canvas] Failed to save widget position:', err))
|
|
1815
|
-
)
|
|
1816
1904
|
return next
|
|
1817
1905
|
})
|
|
1818
|
-
|
|
1906
|
+
queueWrite(async () => {
|
|
1907
|
+
try {
|
|
1908
|
+
const res = await patchWidgetApi(canvasId, dragId, { position: rounded })
|
|
1909
|
+
if (Array.isArray(res?.eventIds)) undoRedo.trackMany(res.eventIds)
|
|
1910
|
+
} catch (err) {
|
|
1911
|
+
console.error('[canvas] Failed to save widget position:', err)
|
|
1912
|
+
}
|
|
1913
|
+
})
|
|
1914
|
+
}, [canvasId, undoRedo, cancelAllPendingWidgetPatches, transitionPeers, clearDragPreview])
|
|
1819
1915
|
|
|
1820
1916
|
// Keep zoomRef in sync when React state is set (e.g. by toolbar or zoom-to-fit)
|
|
1821
1917
|
useEffect(() => {
|
|
@@ -2250,7 +2346,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2250
2346
|
if (result.hotSession?.webglReady) {
|
|
2251
2347
|
result.widget.props = { ...result.widget.props, webglReady: true }
|
|
2252
2348
|
}
|
|
2253
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
2254
2349
|
// Guard against duplicates: HMR may have already pushed the new widget
|
|
2255
2350
|
// into localWidgets before the API response returns (race condition).
|
|
2256
2351
|
setLocalWidgets((prev) => {
|
|
@@ -2259,6 +2354,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2259
2354
|
return [...arr, result.widget]
|
|
2260
2355
|
})
|
|
2261
2356
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
2357
|
+
if (result.eventId) undoRedo.track(result.eventId)
|
|
2262
2358
|
}
|
|
2263
2359
|
} catch (err) {
|
|
2264
2360
|
console.error('[canvas] Failed to add widget:', err)
|
|
@@ -2277,13 +2373,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2277
2373
|
position: pos,
|
|
2278
2374
|
})
|
|
2279
2375
|
if (result.success && result.widget) {
|
|
2280
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
2281
2376
|
setLocalWidgets((prev) => {
|
|
2282
2377
|
const arr = prev || []
|
|
2283
2378
|
if (arr.some((w) => w.id === result.widget.id)) return arr
|
|
2284
2379
|
return [...arr, result.widget]
|
|
2285
2380
|
})
|
|
2286
2381
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
2382
|
+
if (result.eventId) undoRedo.track(result.eventId)
|
|
2287
2383
|
}
|
|
2288
2384
|
} catch (err) {
|
|
2289
2385
|
console.error('[canvas] Failed to add story widget:', err)
|
|
@@ -2727,11 +2823,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2727
2823
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
2728
2824
|
e.preventDefault()
|
|
2729
2825
|
if (selectedWidgetIds.size > 1) {
|
|
2730
|
-
// Multi-delete —
|
|
2731
|
-
//
|
|
2826
|
+
// Multi-delete — remove all via individual widget_removed events
|
|
2827
|
+
// (not widgets_replaced) to avoid the server's wipe guard and
|
|
2732
2828
|
// ensure terminal sessions are properly orphaned per widget.
|
|
2733
|
-
|
|
2734
|
-
debouncedSave.cancel()
|
|
2829
|
+
cancelAllPendingWidgetPatches()
|
|
2735
2830
|
const idsToRemove = new Set(selectedWidgetIds)
|
|
2736
2831
|
// Mark deletions before mutating local state so the HMR merge
|
|
2737
2832
|
// doesn't re-add them mid-flight.
|
|
@@ -2742,22 +2837,28 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2742
2837
|
const orphaned = prev.filter((c) => idsToRemove.has(c.start.widgetId) || idsToRemove.has(c.end.widgetId))
|
|
2743
2838
|
for (const c of orphaned) {
|
|
2744
2839
|
markConnectorDeleted(c.id)
|
|
2745
|
-
queueWrite(() =>
|
|
2746
|
-
|
|
2840
|
+
queueWrite(async () => {
|
|
2841
|
+
try {
|
|
2842
|
+
const res = await removeConnectorApi(canvasId, c.id)
|
|
2843
|
+
if (res?.eventId) undoRedo.track(res.eventId)
|
|
2844
|
+
} catch (err) {
|
|
2747
2845
|
console.error('[canvas] Failed to remove orphaned connector:', err)
|
|
2748
|
-
|
|
2749
|
-
)
|
|
2846
|
+
}
|
|
2847
|
+
})
|
|
2750
2848
|
}
|
|
2751
2849
|
return prev.filter((c) => !idsToRemove.has(c.start.widgetId) && !idsToRemove.has(c.end.widgetId))
|
|
2752
2850
|
})
|
|
2753
2851
|
dirtyRef.current = true
|
|
2754
2852
|
// Queue individual delete API calls
|
|
2755
2853
|
for (const widgetId of idsToRemove) {
|
|
2756
|
-
queueWrite(() =>
|
|
2757
|
-
|
|
2854
|
+
queueWrite(async () => {
|
|
2855
|
+
try {
|
|
2856
|
+
const res = await removeWidgetApi(canvasId, widgetId)
|
|
2857
|
+
if (res?.eventId) undoRedo.track(res.eventId)
|
|
2858
|
+
} catch (err) {
|
|
2758
2859
|
console.error('[canvas] Failed to remove widget in multi-delete:', err)
|
|
2759
|
-
|
|
2760
|
-
)
|
|
2860
|
+
}
|
|
2861
|
+
})
|
|
2761
2862
|
}
|
|
2762
2863
|
} else {
|
|
2763
2864
|
const widgetId = [...selectedWidgetIds][0]
|
|
@@ -2768,7 +2869,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2768
2869
|
}
|
|
2769
2870
|
document.addEventListener('keydown', handleKeyDown)
|
|
2770
2871
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
2771
|
-
}, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId,
|
|
2872
|
+
}, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, cancelAllPendingWidgetPatches, markWidgetDeleted, markConnectorDeleted])
|
|
2772
2873
|
|
|
2773
2874
|
// Ref to store processImageFile for use by drop effect
|
|
2774
2875
|
const processImageFileRef = useRef(null)
|
|
@@ -2838,10 +2939,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2838
2939
|
position: pos,
|
|
2839
2940
|
})
|
|
2840
2941
|
if (result.success && result.widget) {
|
|
2841
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
2842
2942
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
2843
2943
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
2844
2944
|
navigator.clipboard?.writeText(result.widget.id).catch(() => {})
|
|
2945
|
+
if (result.eventId) undoRedo.track(result.eventId)
|
|
2845
2946
|
}
|
|
2846
2947
|
return true
|
|
2847
2948
|
} catch (err) {
|
|
@@ -2925,11 +3026,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2925
3026
|
const baseX = Math.round(center.x - groupW / 2)
|
|
2926
3027
|
const baseY = Math.round(center.y - groupH / 2)
|
|
2927
3028
|
|
|
2928
|
-
// Single undo snapshot for the entire paste
|
|
2929
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
2930
|
-
|
|
2931
3029
|
// Paste all widgets, collecting new IDs for selection
|
|
2932
3030
|
const newWidgets = []
|
|
3031
|
+
const eventIds = []
|
|
2933
3032
|
for (const w of sourceWidgets) {
|
|
2934
3033
|
const relX = (w.position?.x ?? 0) - minX
|
|
2935
3034
|
const relY = (w.position?.y ?? 0) - minY
|
|
@@ -2952,6 +3051,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2952
3051
|
result.widget.props = { ...result.widget.props, webglReady: true }
|
|
2953
3052
|
}
|
|
2954
3053
|
newWidgets.push(result.widget)
|
|
3054
|
+
if (result.eventId) eventIds.push(result.eventId)
|
|
2955
3055
|
}
|
|
2956
3056
|
}
|
|
2957
3057
|
|
|
@@ -2959,6 +3059,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2959
3059
|
setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
|
|
2960
3060
|
setSelectedWidgetIds(new Set(newWidgets.map(w => w.id)))
|
|
2961
3061
|
}
|
|
3062
|
+
if (eventIds.length > 0) undoRedo.trackMany(eventIds)
|
|
2962
3063
|
} catch (err) {
|
|
2963
3064
|
console.error('[canvas] Failed to paste widget reference:', err)
|
|
2964
3065
|
}
|
|
@@ -3009,9 +3110,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
3009
3110
|
position: pos,
|
|
3010
3111
|
})
|
|
3011
3112
|
if (result.success && result.widget) {
|
|
3012
|
-
undoRedo.snapshot(stateRef.current, 'add')
|
|
3013
3113
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
3014
3114
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
3115
|
+
if (result.eventId) undoRedo.track(result.eventId)
|
|
3015
3116
|
}
|
|
3016
3117
|
} catch (err) {
|
|
3017
3118
|
console.error('[canvas] Failed to add widget from paste:', err)
|
|
@@ -3099,35 +3200,57 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
3099
3200
|
}, [loading])
|
|
3100
3201
|
|
|
3101
3202
|
// --- Undo / Redo ---
|
|
3203
|
+
// Server-authoritative: POST /undo / POST /redo append an inverse event
|
|
3204
|
+
// to the JSONL. The HMR push from that write flows back through useCanvas
|
|
3205
|
+
// → setLocalWidgets, so we don't optimistically mutate local state here.
|
|
3206
|
+
//
|
|
3207
|
+
// 404 handling: if the server can't find the target event, it was almost
|
|
3208
|
+
// certainly compacted away (compaction collapses history into a single
|
|
3209
|
+
// canvas_created baseline). We clear the per-tab stack so the toolbar
|
|
3210
|
+
// button greys out instead of silently no-op'ing on every click.
|
|
3102
3211
|
const handleUndo = useCallback(() => {
|
|
3103
|
-
const
|
|
3104
|
-
if (!
|
|
3105
|
-
|
|
3212
|
+
const eventId = undoRedo.popUndo()
|
|
3213
|
+
if (!eventId) return
|
|
3214
|
+
flushAllPendingWidgetPatches()
|
|
3106
3215
|
debouncedSourceSave.cancel()
|
|
3107
3216
|
dirtyRef.current = true
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3217
|
+
queueWrite(async () => {
|
|
3218
|
+
try {
|
|
3219
|
+
const { status, data } = await undoEventApi(canvasId, eventId)
|
|
3220
|
+
if (status === 404) {
|
|
3221
|
+
console.warn('[canvas] Undo target was compacted away — resetting undo stack')
|
|
3222
|
+
undoRedo.reset()
|
|
3223
|
+
return
|
|
3224
|
+
}
|
|
3225
|
+
if (data?.eventId) undoRedo.pushRedo(data.eventId)
|
|
3226
|
+
else if (data?.error) console.error('[canvas] Undo rejected:', data.error)
|
|
3227
|
+
} catch (err) {
|
|
3228
|
+
console.error('[canvas] Failed to persist undo:', err)
|
|
3229
|
+
}
|
|
3230
|
+
})
|
|
3231
|
+
}, [canvasId, debouncedSourceSave, flushAllPendingWidgetPatches, undoRedo])
|
|
3116
3232
|
|
|
3117
3233
|
const handleRedo = useCallback(() => {
|
|
3118
|
-
const
|
|
3119
|
-
if (!
|
|
3120
|
-
|
|
3234
|
+
const eventId = undoRedo.popRedo()
|
|
3235
|
+
if (!eventId) return
|
|
3236
|
+
flushAllPendingWidgetPatches()
|
|
3121
3237
|
debouncedSourceSave.cancel()
|
|
3122
3238
|
dirtyRef.current = true
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3239
|
+
queueWrite(async () => {
|
|
3240
|
+
try {
|
|
3241
|
+
const { status, data } = await redoEventApi(canvasId, eventId)
|
|
3242
|
+
if (status === 404) {
|
|
3243
|
+
console.warn('[canvas] Redo target was compacted away — resetting redo stack')
|
|
3244
|
+
undoRedo.reset()
|
|
3245
|
+
return
|
|
3246
|
+
}
|
|
3247
|
+
if (data?.eventId) undoRedo.pushUndo(data.eventId)
|
|
3248
|
+
else if (data?.error) console.error('[canvas] Redo rejected:', data.error)
|
|
3249
|
+
} catch (err) {
|
|
3250
|
+
console.error('[canvas] Failed to persist redo:', err)
|
|
3251
|
+
}
|
|
3252
|
+
})
|
|
3253
|
+
}, [canvasId, debouncedSourceSave, flushAllPendingWidgetPatches, undoRedo])
|
|
3131
3254
|
|
|
3132
3255
|
// Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z / Cmd+D / Cmd+A)
|
|
3133
3256
|
useEffect(() => {
|