@dfosco/storyboard 0.6.11 → 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/scaffold/AGENTS.md +2 -0
- package/scaffold/README.md +18 -0
- package/scaffold/gitignore +7 -14
- package/scaffold/{manifest.json → scaffold.config.json} +7 -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/core/cli/setup.js +14 -26
- package/src/core/scaffold/fragments.js +287 -0
- package/src/core/scaffold/fragments.test.js +401 -0
- package/src/core/scaffold/index.js +291 -0
- package/src/core/scaffold/migrateLegacyGitignore.js +157 -0
- package/src/core/scaffold/scaffold-integration.test.js +244 -0
- package/src/core/scaffold.js +4 -97
- 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
|
@@ -377,3 +377,229 @@ describe('POST /batch', () => {
|
|
|
377
377
|
expect(refs['1']).toMatch(/^connector-/)
|
|
378
378
|
})
|
|
379
379
|
})
|
|
380
|
+
|
|
381
|
+
describe('POST /undo and POST /redo (event-id targeting)', () => {
|
|
382
|
+
let root, canvasDir, invoke, lastResponse
|
|
383
|
+
|
|
384
|
+
beforeEach(() => {
|
|
385
|
+
({ invoke, root, canvasDir, lastResponse } = setup())
|
|
386
|
+
writeCanvas(canvasDir, 'undo-canvas', { title: 'Undo Canvas' })
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
afterEach(() => {
|
|
390
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
async function addSticky(text) {
|
|
394
|
+
await invoke('/widget', 'POST', {
|
|
395
|
+
name: 'undo-canvas',
|
|
396
|
+
type: 'sticky-note',
|
|
397
|
+
props: { text },
|
|
398
|
+
position: { x: 0, y: 0 },
|
|
399
|
+
})
|
|
400
|
+
return { widgetId: lastResponse.body.widget.id, eventId: lastResponse.body.eventId }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function readState() {
|
|
404
|
+
await invoke('/read?name=undo-canvas', 'GET', {})
|
|
405
|
+
return lastResponse.body
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
it('appends an event id on every mutation', async () => {
|
|
409
|
+
const { eventId } = await addSticky('a')
|
|
410
|
+
expect(eventId).toMatch(/^evt_/)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('reproduces the cq-enablement scenario: 3 deletes + undo restores only the most recent', async () => {
|
|
414
|
+
const a = await addSticky('a')
|
|
415
|
+
const b = await addSticky('b')
|
|
416
|
+
const c = await addSticky('c')
|
|
417
|
+
|
|
418
|
+
await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: a.widgetId })
|
|
419
|
+
const delAEventId = lastResponse.body.eventId
|
|
420
|
+
await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: b.widgetId })
|
|
421
|
+
const delBEventId = lastResponse.body.eventId
|
|
422
|
+
await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: c.widgetId })
|
|
423
|
+
const delCEventId = lastResponse.body.eventId
|
|
424
|
+
|
|
425
|
+
expect((await readState()).widgets).toEqual([])
|
|
426
|
+
|
|
427
|
+
// Undo only the LAST delete (c).
|
|
428
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: delCEventId })
|
|
429
|
+
expect(lastResponse.status).toBe(200)
|
|
430
|
+
expect(lastResponse.body.inverseEvent.event).toBe('widget_added')
|
|
431
|
+
expect(lastResponse.body.inverseEvent.meta).toEqual({ kind: 'undo', of: delCEventId })
|
|
432
|
+
|
|
433
|
+
const after = await readState()
|
|
434
|
+
expect(after.widgets).toHaveLength(1)
|
|
435
|
+
expect(after.widgets[0].id).toBe(c.widgetId)
|
|
436
|
+
// a and b stay deleted — the bug class would have resurrected them too.
|
|
437
|
+
expect(after.widgets.map((w) => w.id)).not.toContain(a.widgetId)
|
|
438
|
+
expect(after.widgets.map((w) => w.id)).not.toContain(b.widgetId)
|
|
439
|
+
|
|
440
|
+
// Tracking that we don't leave delAEventId / delBEventId mentioned.
|
|
441
|
+
void delAEventId
|
|
442
|
+
void delBEventId
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('undo of widget_updated reverts the props using prevProps', async () => {
|
|
446
|
+
const { widgetId } = await addSticky('original')
|
|
447
|
+
|
|
448
|
+
await invoke('/widget', 'PATCH', {
|
|
449
|
+
name: 'undo-canvas',
|
|
450
|
+
widgetId,
|
|
451
|
+
props: { text: 'edited' },
|
|
452
|
+
})
|
|
453
|
+
const editEventId = lastResponse.body.eventIds[0]
|
|
454
|
+
|
|
455
|
+
let state = await readState()
|
|
456
|
+
expect(state.widgets[0].props.text).toBe('edited')
|
|
457
|
+
|
|
458
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: editEventId })
|
|
459
|
+
state = await readState()
|
|
460
|
+
expect(state.widgets[0].props.text).toBe('original')
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('undo of widget_moved reverts the position using prevPosition', async () => {
|
|
464
|
+
const { widgetId } = await addSticky('a')
|
|
465
|
+
|
|
466
|
+
await invoke('/widget', 'PATCH', {
|
|
467
|
+
name: 'undo-canvas',
|
|
468
|
+
widgetId,
|
|
469
|
+
position: { x: 500, y: 600 },
|
|
470
|
+
})
|
|
471
|
+
const moveEventId = lastResponse.body.eventIds[0]
|
|
472
|
+
|
|
473
|
+
let state = await readState()
|
|
474
|
+
expect(state.widgets[0].position).toEqual({ x: 500, y: 600 })
|
|
475
|
+
|
|
476
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: moveEventId })
|
|
477
|
+
state = await readState()
|
|
478
|
+
expect(state.widgets[0].position).toEqual({ x: 0, y: 0 })
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('redo of an undone delete re-removes the widget', async () => {
|
|
482
|
+
const { widgetId } = await addSticky('a')
|
|
483
|
+
await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId })
|
|
484
|
+
const delEventId = lastResponse.body.eventId
|
|
485
|
+
|
|
486
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: delEventId })
|
|
487
|
+
const undoEventId = lastResponse.body.eventId
|
|
488
|
+
expect((await readState()).widgets).toHaveLength(1)
|
|
489
|
+
|
|
490
|
+
// Redo with the undo event id — server emits the inverse of the undo,
|
|
491
|
+
// which is a fresh widget_removed.
|
|
492
|
+
await invoke('/redo', 'POST', { name: 'undo-canvas', eventId: undoEventId })
|
|
493
|
+
expect(lastResponse.body.inverseEvent.event).toBe('widget_removed')
|
|
494
|
+
expect(lastResponse.body.inverseEvent.meta).toEqual({ kind: 'redo', of: undoEventId })
|
|
495
|
+
|
|
496
|
+
expect((await readState()).widgets).toEqual([])
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('rejects undo on an unknown event id with 404', async () => {
|
|
500
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: 'evt_does_not_exist' })
|
|
501
|
+
expect(lastResponse.status).toBe(404)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('rejects undo on a non-undoable event (widgets_replaced) with 400', async () => {
|
|
505
|
+
// Trigger a widgets_replaced via PUT /update with replaceAll
|
|
506
|
+
await invoke('/update', 'PUT', {
|
|
507
|
+
name: 'undo-canvas',
|
|
508
|
+
widgets: [],
|
|
509
|
+
replaceAll: true,
|
|
510
|
+
})
|
|
511
|
+
// Read the file to get the event id of the widgets_replaced event.
|
|
512
|
+
const filePath = path.join(canvasDir, 'undo-canvas.canvas.jsonl')
|
|
513
|
+
const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l))
|
|
514
|
+
const replacedEvt = lines.find((l) => l.event === 'widgets_replaced')
|
|
515
|
+
expect(replacedEvt?.id).toBeTruthy()
|
|
516
|
+
|
|
517
|
+
await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: replacedEvt.id })
|
|
518
|
+
expect(lastResponse.status).toBe(400)
|
|
519
|
+
expect(lastResponse.body.error).toMatch(/cannot be undone/i)
|
|
520
|
+
})
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
describe('runaway-session hard ceiling compaction', () => {
|
|
524
|
+
let root, canvasDir, invoke, lastResponse
|
|
525
|
+
|
|
526
|
+
beforeEach(() => {
|
|
527
|
+
({ invoke, root, canvasDir, lastResponse } = setup())
|
|
528
|
+
writeCanvas(canvasDir, 'big-canvas', { title: 'Big Canvas' })
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
afterEach(() => {
|
|
532
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
// setImmediate-based test helper
|
|
536
|
+
function flushSetImmediate() {
|
|
537
|
+
return new Promise((resolve) => setImmediate(resolve))
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
it('does not compact a small file (under the 2 MB threshold)', async () => {
|
|
541
|
+
const filePath = path.join(canvasDir, 'big-canvas.canvas.jsonl')
|
|
542
|
+
await invoke('/widget', 'POST', {
|
|
543
|
+
name: 'big-canvas',
|
|
544
|
+
type: 'sticky-note',
|
|
545
|
+
props: { text: 'hello' },
|
|
546
|
+
position: { x: 0, y: 0 },
|
|
547
|
+
})
|
|
548
|
+
await flushSetImmediate()
|
|
549
|
+
const sizeAfter = fs.statSync(filePath).size
|
|
550
|
+
expect(sizeAfter).toBeLessThan(2 * 1024 * 1024)
|
|
551
|
+
// File should still contain both canvas_created + widget_added events.
|
|
552
|
+
const events = fs.readFileSync(filePath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l))
|
|
553
|
+
expect(events.length).toBe(2)
|
|
554
|
+
expect(events[0].event).toBe('canvas_created')
|
|
555
|
+
expect(events[1].event).toBe('widget_added')
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('compacts the file once it exceeds the runaway threshold', async () => {
|
|
559
|
+
const filePath = path.join(canvasDir, 'big-canvas.canvas.jsonl')
|
|
560
|
+
|
|
561
|
+
// Pre-bloat the file past 2 MB by appending raw garbage events. Each
|
|
562
|
+
// event has a unique id so the materializer treats it as valid.
|
|
563
|
+
const padding = 'x'.repeat(4000)
|
|
564
|
+
const bloatLines = []
|
|
565
|
+
for (let i = 0; i < 600; i++) {
|
|
566
|
+
bloatLines.push(JSON.stringify({
|
|
567
|
+
id: `evt_bloat_${i}`,
|
|
568
|
+
event: 'widget_added',
|
|
569
|
+
timestamp: '2026-01-01',
|
|
570
|
+
widget: { id: `bloat-${i}`, type: 'sticky-note', position: { x: 0, y: 0 }, props: { text: padding } },
|
|
571
|
+
}))
|
|
572
|
+
}
|
|
573
|
+
fs.appendFileSync(filePath, bloatLines.join('\n') + '\n')
|
|
574
|
+
const sizeBefore = fs.statSync(filePath).size
|
|
575
|
+
expect(sizeBefore).toBeGreaterThan(2 * 1024 * 1024)
|
|
576
|
+
|
|
577
|
+
// Trigger appendEvent — this should detect the runaway size and kick
|
|
578
|
+
// off a compaction.
|
|
579
|
+
await invoke('/widget', 'POST', {
|
|
580
|
+
name: 'big-canvas',
|
|
581
|
+
type: 'sticky-note',
|
|
582
|
+
props: { text: 'trigger' },
|
|
583
|
+
position: { x: 0, y: 0 },
|
|
584
|
+
})
|
|
585
|
+
// Compaction runs on setImmediate; flush it.
|
|
586
|
+
await flushSetImmediate()
|
|
587
|
+
await flushSetImmediate()
|
|
588
|
+
|
|
589
|
+
const sizeAfter = fs.statSync(filePath).size
|
|
590
|
+
expect(sizeAfter).toBeLessThan(sizeBefore)
|
|
591
|
+
|
|
592
|
+
// After compaction, file should be a single canvas_created event
|
|
593
|
+
// containing all the materialized widgets.
|
|
594
|
+
const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n')
|
|
595
|
+
expect(lines.length).toBe(1)
|
|
596
|
+
const baseline = JSON.parse(lines[0])
|
|
597
|
+
expect(baseline.event).toBe('canvas_created')
|
|
598
|
+
// 600 bloat widgets + 1 from the triggering append = 601.
|
|
599
|
+
expect(baseline.widgets.length).toBe(601)
|
|
600
|
+
// Sanity: state still reads cleanly via the normal /read endpoint.
|
|
601
|
+
await invoke('/read?name=big-canvas', 'GET', {})
|
|
602
|
+
expect(lastResponse.status).toBe(200)
|
|
603
|
+
expect(lastResponse.body.widgets.length).toBe(601)
|
|
604
|
+
})
|
|
605
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas undo/redo: build inverse forward events.
|
|
3
|
+
*
|
|
4
|
+
* Pure module — given a target canvas event (with inverse payload `prev*`
|
|
5
|
+
* fields or full `widget`/`connector` objects on removes), returns the
|
|
6
|
+
* inverse event that, when appended to the JSONL and materialized, restores
|
|
7
|
+
* the state to what it was before the target event was applied.
|
|
8
|
+
*
|
|
9
|
+
* The inverse events are real, valid forward events (widget_added,
|
|
10
|
+
* widget_removed, widget_updated, etc.) — the materializer folds them with
|
|
11
|
+
* no special handling. We tag them with `meta.kind = 'undo' | 'redo'` and
|
|
12
|
+
* `meta.of = <targetEventId>` purely for human readability and DX.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compute the inverse forward event for a given canvas event.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} targetEvent — the event being undone/redone (must have id)
|
|
19
|
+
* @param {'undo'|'redo'} kind — meta.kind to stamp on the returned event
|
|
20
|
+
* @returns {object|null} the inverse event (without timestamp/id — the
|
|
21
|
+
* caller stamps those via appendEvent), or null if the event is not
|
|
22
|
+
* undoable (legacy events without inverse payloads, widgets_replaced,
|
|
23
|
+
* connectors_replaced, canvas_created, etc.).
|
|
24
|
+
*/
|
|
25
|
+
export function buildInverseEvent(targetEvent, kind) {
|
|
26
|
+
if (!targetEvent || typeof targetEvent !== 'object') return null
|
|
27
|
+
if (!targetEvent.id) return null
|
|
28
|
+
|
|
29
|
+
const meta = { kind, of: targetEvent.id }
|
|
30
|
+
|
|
31
|
+
switch (targetEvent.event) {
|
|
32
|
+
case 'widget_added': {
|
|
33
|
+
const widgetId = targetEvent.widget?.id
|
|
34
|
+
if (!widgetId) return null
|
|
35
|
+
// Inverse of add is remove. Carry the full widget so the inverse
|
|
36
|
+
// event itself is undoable (redo-of-undo brings it back).
|
|
37
|
+
return {
|
|
38
|
+
event: 'widget_removed',
|
|
39
|
+
widgetId,
|
|
40
|
+
widget: targetEvent.widget,
|
|
41
|
+
meta,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
case 'widget_removed': {
|
|
46
|
+
// Inverse of remove is add. Requires the full widget payload.
|
|
47
|
+
const widget = targetEvent.widget
|
|
48
|
+
if (!widget || !widget.id) return null
|
|
49
|
+
return {
|
|
50
|
+
event: 'widget_added',
|
|
51
|
+
widget,
|
|
52
|
+
meta,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
case 'widget_updated': {
|
|
57
|
+
if (!targetEvent.widgetId || !targetEvent.prevProps) return null
|
|
58
|
+
return {
|
|
59
|
+
event: 'widget_updated',
|
|
60
|
+
widgetId: targetEvent.widgetId,
|
|
61
|
+
props: targetEvent.prevProps,
|
|
62
|
+
// The inverse of the inverse is the original — capture so redo works.
|
|
63
|
+
prevProps: targetEvent.props,
|
|
64
|
+
meta,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
case 'widget_moved': {
|
|
69
|
+
if (!targetEvent.widgetId || !targetEvent.prevPosition) return null
|
|
70
|
+
return {
|
|
71
|
+
event: 'widget_moved',
|
|
72
|
+
widgetId: targetEvent.widgetId,
|
|
73
|
+
position: targetEvent.prevPosition,
|
|
74
|
+
prevPosition: targetEvent.position,
|
|
75
|
+
meta,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
case 'connector_added': {
|
|
80
|
+
const connectorId = targetEvent.connector?.id
|
|
81
|
+
if (!connectorId) return null
|
|
82
|
+
return {
|
|
83
|
+
event: 'connector_removed',
|
|
84
|
+
connectorId,
|
|
85
|
+
connector: targetEvent.connector,
|
|
86
|
+
meta,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
case 'connector_removed': {
|
|
91
|
+
const connector = targetEvent.connector
|
|
92
|
+
if (!connector || !connector.id) return null
|
|
93
|
+
return {
|
|
94
|
+
event: 'connector_added',
|
|
95
|
+
connector,
|
|
96
|
+
meta,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
case 'connector_updated': {
|
|
101
|
+
if (!targetEvent.connectorId || !targetEvent.prevUpdates) return null
|
|
102
|
+
return {
|
|
103
|
+
event: 'connector_updated',
|
|
104
|
+
connectorId: targetEvent.connectorId,
|
|
105
|
+
updates: targetEvent.prevUpdates,
|
|
106
|
+
prevUpdates: targetEvent.updates,
|
|
107
|
+
meta,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
case 'connector_waypoints_set': {
|
|
112
|
+
if (!targetEvent.connectorId) return null
|
|
113
|
+
const prev = targetEvent.prevWaypoints
|
|
114
|
+
// prevWaypoints null means there were no manual waypoints before —
|
|
115
|
+
// inverse is `cleared` (which itself carries the current waypoints
|
|
116
|
+
// as prevWaypoints so redo can re-set them).
|
|
117
|
+
if (prev === null) {
|
|
118
|
+
return {
|
|
119
|
+
event: 'connector_waypoints_cleared',
|
|
120
|
+
connectorId: targetEvent.connectorId,
|
|
121
|
+
prevWaypoints: targetEvent.waypoints,
|
|
122
|
+
meta,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!Array.isArray(prev)) return null
|
|
126
|
+
return {
|
|
127
|
+
event: 'connector_waypoints_set',
|
|
128
|
+
connectorId: targetEvent.connectorId,
|
|
129
|
+
waypoints: prev,
|
|
130
|
+
prevWaypoints: targetEvent.waypoints,
|
|
131
|
+
meta,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
case 'connector_waypoints_cleared': {
|
|
136
|
+
if (!targetEvent.connectorId) return null
|
|
137
|
+
// Clearing had no prior manual routing: there's nothing to restore.
|
|
138
|
+
const prev = targetEvent.prevWaypoints
|
|
139
|
+
if (!Array.isArray(prev)) return null
|
|
140
|
+
return {
|
|
141
|
+
event: 'connector_waypoints_set',
|
|
142
|
+
connectorId: targetEvent.connectorId,
|
|
143
|
+
waypoints: prev,
|
|
144
|
+
prevWaypoints: null,
|
|
145
|
+
meta,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
case 'source_updated': {
|
|
150
|
+
if (!Array.isArray(targetEvent.prevSources)) return null
|
|
151
|
+
return {
|
|
152
|
+
event: 'source_updated',
|
|
153
|
+
sources: targetEvent.prevSources,
|
|
154
|
+
prevSources: targetEvent.sources,
|
|
155
|
+
meta,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case 'settings_updated': {
|
|
160
|
+
if (!targetEvent.prevSettings || typeof targetEvent.prevSettings !== 'object') return null
|
|
161
|
+
return {
|
|
162
|
+
event: 'settings_updated',
|
|
163
|
+
settings: targetEvent.prevSettings,
|
|
164
|
+
prevSettings: targetEvent.settings,
|
|
165
|
+
meta,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// widgets_replaced, connectors_replaced, canvas_created are compaction
|
|
170
|
+
// / baseline events and intentionally not undoable.
|
|
171
|
+
default:
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildInverseEvent } from './undoRedo.js'
|
|
3
|
+
import { materialize } from './materializer.js'
|
|
4
|
+
|
|
5
|
+
const widget = (overrides = {}) => ({
|
|
6
|
+
id: 'sticky-1',
|
|
7
|
+
type: 'sticky-note',
|
|
8
|
+
position: { x: 10, y: 20 },
|
|
9
|
+
props: { text: 'hello' },
|
|
10
|
+
...overrides,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('buildInverseEvent', () => {
|
|
14
|
+
it('returns null for events without an id', () => {
|
|
15
|
+
expect(buildInverseEvent({ event: 'widget_added', widget: widget() }, 'undo')).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns null for non-undoable event types', () => {
|
|
19
|
+
expect(buildInverseEvent({ id: 'x', event: 'widgets_replaced', widgets: [] }, 'undo')).toBeNull()
|
|
20
|
+
expect(buildInverseEvent({ id: 'x', event: 'connectors_replaced', connectors: [] }, 'undo')).toBeNull()
|
|
21
|
+
expect(buildInverseEvent({ id: 'x', event: 'canvas_created', widgets: [] }, 'undo')).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('inverts widget_added into widget_removed carrying the widget', () => {
|
|
25
|
+
const target = { id: 'evt_1', event: 'widget_added', widget: widget() }
|
|
26
|
+
const inv = buildInverseEvent(target, 'undo')
|
|
27
|
+
expect(inv).toEqual({
|
|
28
|
+
event: 'widget_removed',
|
|
29
|
+
widgetId: 'sticky-1',
|
|
30
|
+
widget: widget(),
|
|
31
|
+
meta: { kind: 'undo', of: 'evt_1' },
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('inverts widget_removed into widget_added when the widget payload is present', () => {
|
|
36
|
+
const target = { id: 'evt_2', event: 'widget_removed', widgetId: 'sticky-1', widget: widget() }
|
|
37
|
+
const inv = buildInverseEvent(target, 'undo')
|
|
38
|
+
expect(inv).toEqual({
|
|
39
|
+
event: 'widget_added',
|
|
40
|
+
widget: widget(),
|
|
41
|
+
meta: { kind: 'undo', of: 'evt_2' },
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns null for legacy widget_removed events that lack the full widget payload', () => {
|
|
46
|
+
const target = { id: 'evt_old', event: 'widget_removed', widgetId: 'sticky-1' }
|
|
47
|
+
expect(buildInverseEvent(target, 'undo')).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('inverts widget_updated using prevProps and mirrors current props for redo', () => {
|
|
51
|
+
const target = {
|
|
52
|
+
id: 'evt_3',
|
|
53
|
+
event: 'widget_updated',
|
|
54
|
+
widgetId: 'sticky-1',
|
|
55
|
+
props: { text: 'new', color: 'red' },
|
|
56
|
+
prevProps: { text: 'old', color: undefined },
|
|
57
|
+
}
|
|
58
|
+
const inv = buildInverseEvent(target, 'undo')
|
|
59
|
+
expect(inv).toEqual({
|
|
60
|
+
event: 'widget_updated',
|
|
61
|
+
widgetId: 'sticky-1',
|
|
62
|
+
props: { text: 'old', color: undefined },
|
|
63
|
+
prevProps: { text: 'new', color: 'red' },
|
|
64
|
+
meta: { kind: 'undo', of: 'evt_3' },
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('inverts widget_moved using prevPosition', () => {
|
|
69
|
+
const target = {
|
|
70
|
+
id: 'evt_4',
|
|
71
|
+
event: 'widget_moved',
|
|
72
|
+
widgetId: 'sticky-1',
|
|
73
|
+
position: { x: 200, y: 200 },
|
|
74
|
+
prevPosition: { x: 10, y: 20 },
|
|
75
|
+
}
|
|
76
|
+
const inv = buildInverseEvent(target, 'undo')
|
|
77
|
+
expect(inv).toEqual({
|
|
78
|
+
event: 'widget_moved',
|
|
79
|
+
widgetId: 'sticky-1',
|
|
80
|
+
position: { x: 10, y: 20 },
|
|
81
|
+
prevPosition: { x: 200, y: 200 },
|
|
82
|
+
meta: { kind: 'undo', of: 'evt_4' },
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('inverts connector_added into connector_removed', () => {
|
|
87
|
+
const connector = {
|
|
88
|
+
id: 'connector-1',
|
|
89
|
+
type: 'connector',
|
|
90
|
+
connectorType: 'default',
|
|
91
|
+
start: { widgetId: 'sticky-1', anchor: 'right' },
|
|
92
|
+
end: { widgetId: 'sticky-2', anchor: 'left' },
|
|
93
|
+
meta: {},
|
|
94
|
+
}
|
|
95
|
+
const inv = buildInverseEvent({ id: 'evt_5', event: 'connector_added', connector }, 'undo')
|
|
96
|
+
expect(inv).toEqual({
|
|
97
|
+
event: 'connector_removed',
|
|
98
|
+
connectorId: 'connector-1',
|
|
99
|
+
connector,
|
|
100
|
+
meta: { kind: 'undo', of: 'evt_5' },
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('inverts connector_removed into connector_added', () => {
|
|
105
|
+
const connector = {
|
|
106
|
+
id: 'connector-1',
|
|
107
|
+
type: 'connector',
|
|
108
|
+
connectorType: 'default',
|
|
109
|
+
start: { widgetId: 'sticky-1', anchor: 'right' },
|
|
110
|
+
end: { widgetId: 'sticky-2', anchor: 'left' },
|
|
111
|
+
meta: {},
|
|
112
|
+
}
|
|
113
|
+
const inv = buildInverseEvent({ id: 'evt_6', event: 'connector_removed', connectorId: 'connector-1', connector }, 'undo')
|
|
114
|
+
expect(inv).toEqual({
|
|
115
|
+
event: 'connector_added',
|
|
116
|
+
connector,
|
|
117
|
+
meta: { kind: 'undo', of: 'evt_6' },
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('inverts connector_waypoints_set into connector_waypoints_cleared when there were no prior waypoints', () => {
|
|
122
|
+
const target = {
|
|
123
|
+
id: 'evt_7',
|
|
124
|
+
event: 'connector_waypoints_set',
|
|
125
|
+
connectorId: 'connector-1',
|
|
126
|
+
waypoints: [{ dx: 10, dy: 10 }],
|
|
127
|
+
prevWaypoints: null,
|
|
128
|
+
}
|
|
129
|
+
const inv = buildInverseEvent(target, 'undo')
|
|
130
|
+
expect(inv).toEqual({
|
|
131
|
+
event: 'connector_waypoints_cleared',
|
|
132
|
+
connectorId: 'connector-1',
|
|
133
|
+
prevWaypoints: [{ dx: 10, dy: 10 }],
|
|
134
|
+
meta: { kind: 'undo', of: 'evt_7' },
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('inverts source_updated using prevSources', () => {
|
|
139
|
+
const target = {
|
|
140
|
+
id: 'evt_8',
|
|
141
|
+
event: 'source_updated',
|
|
142
|
+
sources: [{ export: 'B', position: { x: 0, y: 0 } }],
|
|
143
|
+
prevSources: [{ export: 'A', position: { x: 1, y: 1 } }],
|
|
144
|
+
}
|
|
145
|
+
const inv = buildInverseEvent(target, 'undo')
|
|
146
|
+
expect(inv).toEqual({
|
|
147
|
+
event: 'source_updated',
|
|
148
|
+
sources: [{ export: 'A', position: { x: 1, y: 1 } }],
|
|
149
|
+
prevSources: [{ export: 'B', position: { x: 0, y: 0 } }],
|
|
150
|
+
meta: { kind: 'undo', of: 'evt_8' },
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('tags redo emissions with meta.kind="redo"', () => {
|
|
155
|
+
const target = { id: 'evt_9', event: 'widget_added', widget: widget() }
|
|
156
|
+
const inv = buildInverseEvent(target, 'redo')
|
|
157
|
+
expect(inv?.meta).toEqual({ kind: 'redo', of: 'evt_9' })
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('materialize folds undo events like normal forward events', () => {
|
|
162
|
+
it('a widget_removed undo emission as widget_added re-introduces the widget', () => {
|
|
163
|
+
const w = widget()
|
|
164
|
+
const events = [
|
|
165
|
+
{ event: 'canvas_created', timestamp: '1', widgets: [] },
|
|
166
|
+
{ id: 'evt_add', event: 'widget_added', timestamp: '2', widget: w },
|
|
167
|
+
{ id: 'evt_del', event: 'widget_removed', timestamp: '3', widgetId: w.id, widget: w },
|
|
168
|
+
{
|
|
169
|
+
id: 'evt_undo',
|
|
170
|
+
event: 'widget_added',
|
|
171
|
+
timestamp: '4',
|
|
172
|
+
widget: w,
|
|
173
|
+
meta: { kind: 'undo', of: 'evt_del' },
|
|
174
|
+
},
|
|
175
|
+
]
|
|
176
|
+
const state = materialize(events)
|
|
177
|
+
expect(state.widgets).toHaveLength(1)
|
|
178
|
+
expect(state.widgets[0].id).toBe(w.id)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('back-compat: legacy widget_removed event with only widgetId still removes', () => {
|
|
182
|
+
const w = widget()
|
|
183
|
+
const events = [
|
|
184
|
+
{ event: 'canvas_created', timestamp: '1', widgets: [w] },
|
|
185
|
+
{ event: 'widget_removed', timestamp: '2', widgetId: w.id },
|
|
186
|
+
]
|
|
187
|
+
const state = materialize(events)
|
|
188
|
+
expect(state.widgets).toEqual([])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('back-compat: legacy connector_removed event with only connectorId still removes', () => {
|
|
192
|
+
const connector = {
|
|
193
|
+
id: 'connector-1',
|
|
194
|
+
type: 'connector',
|
|
195
|
+
connectorType: 'default',
|
|
196
|
+
start: { widgetId: 'a', anchor: 'right' },
|
|
197
|
+
end: { widgetId: 'b', anchor: 'left' },
|
|
198
|
+
}
|
|
199
|
+
const events = [
|
|
200
|
+
{ event: 'canvas_created', timestamp: '1', widgets: [], connectors: [connector] },
|
|
201
|
+
{ event: 'connector_removed', timestamp: '2', connectorId: connector.id },
|
|
202
|
+
]
|
|
203
|
+
const state = materialize(events)
|
|
204
|
+
expect(state.connectors).toEqual([])
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('end-to-end: cq-enablement bug scenario', () => {
|
|
209
|
+
it('deleting 5 widgets then undoing once only restores the most recent delete', () => {
|
|
210
|
+
// Reproduces the rollback bug. In the old model an undo emitted a
|
|
211
|
+
// widgets_replaced event that resurrected ALL deleted widgets. In the
|
|
212
|
+
// new model the undo targets the most recent widget_removed event and
|
|
213
|
+
// re-adds only that widget.
|
|
214
|
+
const widgets = [
|
|
215
|
+
widget({ id: 'a' }),
|
|
216
|
+
widget({ id: 'b' }),
|
|
217
|
+
widget({ id: 'c' }),
|
|
218
|
+
widget({ id: 'd' }),
|
|
219
|
+
widget({ id: 'e' }),
|
|
220
|
+
]
|
|
221
|
+
const removeEvents = widgets.map((w, i) => ({
|
|
222
|
+
id: `evt_del_${i}`,
|
|
223
|
+
event: 'widget_removed',
|
|
224
|
+
timestamp: `${10 + i}`,
|
|
225
|
+
widgetId: w.id,
|
|
226
|
+
widget: w,
|
|
227
|
+
}))
|
|
228
|
+
|
|
229
|
+
const events = [
|
|
230
|
+
{ event: 'canvas_created', timestamp: '1', widgets },
|
|
231
|
+
...removeEvents,
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
// Sanity: after all 5 deletes, the canvas is empty.
|
|
235
|
+
expect(materialize(events).widgets).toEqual([])
|
|
236
|
+
|
|
237
|
+
// Undo the LAST delete only (evt_del_4).
|
|
238
|
+
const inverse = buildInverseEvent(removeEvents[4], 'undo')
|
|
239
|
+
expect(inverse?.event).toBe('widget_added')
|
|
240
|
+
expect(inverse?.widget?.id).toBe('e')
|
|
241
|
+
|
|
242
|
+
const undoneEvents = [...events, { ...inverse, id: 'evt_undo_1', timestamp: '20' }]
|
|
243
|
+
const undoneState = materialize(undoneEvents)
|
|
244
|
+
|
|
245
|
+
// Only widget 'e' is restored — a, b, c, d remain deleted.
|
|
246
|
+
expect(undoneState.widgets).toHaveLength(1)
|
|
247
|
+
expect(undoneState.widgets[0].id).toBe('e')
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storyboard canvas redo — re-apply a previously-undone event.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* storyboard canvas redo --canvas my-canvas --event evt_xxx
|
|
6
|
+
*
|
|
7
|
+
* The --event is the id of the *undo event* (the one with meta.kind = 'undo')
|
|
8
|
+
* that you want to cancel — the server emits the inverse of that, restoring
|
|
9
|
+
* the original change.
|
|
10
|
+
*
|
|
11
|
+
* Flags:
|
|
12
|
+
* -c, --canvas Canvas name (required)
|
|
13
|
+
* -e, --event Event ID (the undo event) to redo (required)
|
|
14
|
+
* --json Output as JSON
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { post, parseSimpleArgs, jsonOut, die } from './cliHelpers.js'
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(4) // skip: node, sb, canvas, redo
|
|
20
|
+
|
|
21
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
22
|
+
console.log(`
|
|
23
|
+
canvas redo flags:
|
|
24
|
+
|
|
25
|
+
-c, --canvas Canvas name (required)
|
|
26
|
+
-e, --event Event ID (the undo event) to redo (required)
|
|
27
|
+
--json Output as JSON
|
|
28
|
+
`)
|
|
29
|
+
process.exit(0)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { flags } = parseSimpleArgs(args)
|
|
33
|
+
const canvas = flags.canvas || flags.c
|
|
34
|
+
const eventId = flags.event || flags.e
|
|
35
|
+
|
|
36
|
+
if (!canvas) die('--canvas is required')
|
|
37
|
+
if (!eventId) die('--event is required')
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const result = await post('/_storyboard/canvas/redo', { name: canvas, eventId })
|
|
41
|
+
if (flags.json) { jsonOut(result) } else {
|
|
42
|
+
console.log(`Redid event ${eventId} → appended ${result.eventId} (${result.inverseEvent?.event})`)
|
|
43
|
+
}
|
|
44
|
+
} catch (err) { die(err.message) }
|