Skip to content

Commit d41f4fd

Browse files
committed
Command design pattern to add more undo/redo events
1 parent d781f29 commit d41f4fd

11 files changed

Lines changed: 510 additions & 26 deletions

File tree

frontend/src/actions/canvas_controls.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ import * as THREE from 'three'
88
import { bus } from '../bus'
99
import { ctx } from '../three'
1010
import { registerAnimateHook } from './screen'
11+
import { history } from '../commands/base'
12+
import {
13+
captureTransforms,
14+
TransformCommand,
15+
type TransformSnapshot,
16+
} from '../commands/scene'
1117
import { redrawIntraLayerEdges, unselectAllEdges } from './edge'
1218
import {
1319
checkHoverOverLayer,
@@ -39,6 +45,12 @@ let lasso: THREE.Line | null = null
3945

4046
type CanvasMouseEvent = MouseEvent & { layerX: number; layerY: number }
4147

48+
// Mouse drags (pan, orbit, held-key node/layer transforms) register one undo
49+
// entry per drag: snapshot on press, command pushed on release if a transform
50+
// branch actually ran (lasso/selection drags don't count).
51+
let dragBefore: TransformSnapshot | null = null
52+
let transformDragged = false
53+
4254
// Smooth wheel zoom: the wheel sets a target scale (same 0.2–2 bounds as
4355
// Scene.zoom) and easeZoomStep lerps toward it each frame. Users with
4456
// prefers-reduced-motion get the old instant zoom.
@@ -103,6 +115,10 @@ export function axisRelease(): void {
103115
// mouse: 0 left, 1 middle, 2 right click
104116
export function clickDown(event: CanvasMouseEvent): void {
105117
if (!ctx.scene?.exists()) return
118+
if (event.button === 0 || event.button === 1) {
119+
dragBefore = captureTransforms()
120+
transformDragged = false
121+
}
106122
if (event.button === 0) {
107123
ctx.scene.leftClickPressed = true
108124
ctx.scene.middleClickPressed = false
@@ -150,18 +166,27 @@ export function clickDrag(event: CanvasMouseEvent): void {
150166
event.layerX - ctx.xBoundMax,
151167
ctx.yBoundMax - event.layerY
152168
)
153-
} else if (ctx.scene.axisPressed !== '' && getSelectedNodes().length > 0)
169+
} else if (
170+
ctx.scene.axisPressed !== '' &&
171+
getSelectedNodes().length > 0
172+
) {
154173
translateNodesWithHeldKey(event)
155-
else if (ctx.scene.axisPressed !== '') rotateLayersWithHeldKey(event)
156-
else if (
174+
transformDragged = true
175+
} else if (ctx.scene.axisPressed !== '') {
176+
rotateLayersWithHeldKey(event)
177+
transformDragged = true
178+
} else if (
157179
ctx.lastHoveredLayerIndex === null &&
158180
ctx.lastHoveredNodeIndex === null
159-
)
181+
) {
160182
ctx.scene.translatePanWithMouse(x, y)
183+
transformDragged = true
184+
}
161185
} else if (ctx.scene.middleClickPressed) {
162186
ctx.scene.dragging = true
163187
event.preventDefault()
164188
ctx.scene.orbitSphereWithMouse(x, y)
189+
transformDragged = true
165190
}
166191

167192
ctx.mousePreviousX = x
@@ -186,6 +211,13 @@ export function processPendingHover(): void {
186211
export function clickUp(event: MouseEvent): void {
187212
if (!ctx.scene?.exists()) return
188213
ctx.scene.dragging = false
214+
if (dragBefore && transformDragged) {
215+
history.execute(
216+
new TransformCommand('Drag scene', dragBefore, captureTransforms())
217+
)
218+
}
219+
dragBefore = null
220+
transformDragged = false
189221
if (event.button === 0) {
190222
ctx.scene.leftClickPressed = false
191223
removeContextMenu() // v2 removed the right-click options list here

frontend/src/actions/drag_controls.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import * as THREE from 'three'
99
import { bus } from '../bus'
1010
import { ctx } from '../three'
1111
import { findIndexByUuid } from '../utils'
12+
import { history } from '../commands/base'
13+
import {
14+
captureTransforms,
15+
TransformCommand,
16+
type TransformSnapshot,
17+
} from '../commands/scene'
1218

1319
const _plane = new THREE.Plane()
1420
const _pointer = new THREE.Vector2()
@@ -21,6 +27,9 @@ const _raycaster = new THREE.Raycaster()
2127
let selected: THREE.Object3D | null = null
2228
let hovered: THREE.Object3D | null = null
2329
let surface: DragSurface | null = null
30+
// One undo entry per layer drag: snapshot on grab, pushed on release.
31+
let dragBefore: TransformSnapshot | null = null
32+
let dragMoved = false
2433

2534
// Structural subset of HTMLCanvasElement, so tests can drive a fake surface.
2635
export interface DragSurface {
@@ -64,6 +73,8 @@ export function onPointerDown(event: PointerLikeEvent): void {
6473
_raycaster.setFromCamera(_pointer, ctx.camera)
6574

6675
selected = ctx.layers[idx].plane
76+
dragBefore = captureTransforms()
77+
dragMoved = false
6778
_plane.setFromNormalAndCoplanarPoint(
6879
ctx.camera.getWorldDirection(_plane.normal),
6980
_worldPosition.setFromMatrixPosition(selected.matrixWorld)
@@ -96,6 +107,7 @@ export function onPointerMove(event: PointerLikeEvent): void {
96107
selected.position.copy(
97108
_intersection.sub(_offset).applyMatrix4(_inverseMatrix)
98109
)
110+
dragMoved = true
99111
}
100112
return
101113
}
@@ -128,6 +140,12 @@ export function onPointerCancel(): void {
128140
// redraw what the drag displaced (v2 routed this through Shiny syncs)
129141
ctx.renderInterLayerEdgesFlag = true
130142
if (layerIndex !== -1) bus.emit('layer:moved', { layerIndex })
143+
if (dragBefore && dragMoved)
144+
history.execute(
145+
new TransformCommand('Drag layer', dragBefore, captureTransforms())
146+
)
147+
dragBefore = null
148+
dragMoved = false
131149
}
132150
surface.style.cursor = hovered ? 'pointer' : 'auto'
133151
}

frontend/src/actions/layer.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import type { MeshBasicMaterial } from 'three'
88
import { store } from '../store'
99
import { ctx, SELECTED_LAYER_DEFAULT_COLOR } from '../three'
10+
import { LAYER_DEFAULT_COLOR } from '../three/constants'
1011
import type { ColorPrioritySource } from '../three'
1112
import { findIndexByUuid } from '../utils'
1213
import { decideNodeLabelFlags } from './node'
@@ -37,9 +38,20 @@ export function getSelectedLayers(): number[] {
3738
return ctx.layers.filter((l) => l.isSelected).map((l) => l.id)
3839
}
3940

41+
// Last floor-picker color applied. Tracked so ChangeFloorColorCommand can
42+
// recover the previous value on undo — the DOM color input already holds the
43+
// new value by the time its 'change' event fires, so it can't be read back.
44+
let lastFloorColor = LAYER_DEFAULT_COLOR
45+
46+
export function getLastFloorColor(): string {
47+
return lastFloorColor
48+
}
49+
4050
// `pickerColor` overrides the DOM color input (themes.ts passes the theme's
4151
// floor color; the input itself only exists once Phase 13 builds the UI).
4252
export function repaintLayers(pickerColor?: string): void {
53+
if (ctx.layerColorPrioritySource === 'picker' && pickerColor)
54+
lastFloorColor = pickerColor
4355
for (const layer of ctx.layers) {
4456
if (layer.isSelected) layer.setColor(SELECTED_LAYER_DEFAULT_COLOR)
4557
else if (ctx.layerColorPrioritySource === 'default')

frontend/src/actions/nav_controls.ts

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,45 @@ import { ctx } from '../three'
99
import { redrawIntraLayerEdges, toggleInterLayerEdgesRendering } from './edge'
1010
import { getSelectedLayers, initialSpreadLayers } from './layer'
1111
import { getSelectedNodes } from './node'
12+
import { history } from '../commands/base'
13+
import {
14+
captureTransforms,
15+
TransformCommand,
16+
type TransformSnapshot,
17+
} from '../commands/scene'
1218

1319
type Axis = 'X' | 'Y' | 'Z'
1420

1521
const REPEAT_MS = 70
1622

1723
let repeatId: ReturnType<typeof setInterval> | undefined
24+
// Hold-to-repeat interactions register one undo entry per hold: snapshot on
25+
// press, command pushed on release (only if a tick actually ran).
26+
let repeatBefore: TransformSnapshot | null = null
27+
let repeatMoved = false
1828

1929
function stopRepeat(): void {
2030
clearInterval(repeatId)
2131
repeatId = undefined
32+
if (repeatBefore && repeatMoved)
33+
history.execute(
34+
new TransformCommand(
35+
'Nav control move',
36+
repeatBefore,
37+
captureTransforms()
38+
)
39+
)
40+
repeatBefore = null
41+
repeatMoved = false
2242
}
2343

2444
function startRepeat(fn: () => void): void {
2545
stopRepeat()
26-
repeatId = setInterval(fn, REPEAT_MS)
46+
repeatBefore = captureTransforms()
47+
repeatId = setInterval(() => {
48+
repeatMoved = true
49+
fn()
50+
}, REPEAT_MS)
2751
}
2852

2953
function sliderValue(id: string): number {
@@ -52,8 +76,12 @@ function rotateScene(direction: number, axis: Axis): void {
5276

5377
export function recenterNetwork(): void {
5478
if (ctx.scene?.exists()) {
79+
const before = captureTransforms()
5580
ctx.scene.recenter()
5681
raiseMovedFlags()
82+
history.execute(
83+
new TransformCommand('Recenter network', before, captureTransforms())
84+
)
5785
}
5886
}
5987

@@ -89,8 +117,12 @@ function moveLayers(direction: number, axis: Axis): void {
89117
}
90118

91119
function spreadLayers(direction: number): void {
120+
const before = captureTransforms()
92121
initialSpreadLayers(direction)
93122
raiseMovedFlags()
123+
history.execute(
124+
new TransformCommand('Spread layers', before, captureTransforms())
125+
)
94126
}
95127

96128
function scaleLayers(): void {
@@ -120,13 +152,17 @@ function scaleLayers(): void {
120152
function spreadNodes(multiplier: number): void {
121153
const selected = getSelectedNodes()
122154
if (selected.length === 0) return alert('Please select at least one node.')
155+
const before = captureTransforms()
123156
for (const i of selected) {
124157
const node = ctx.nodeObjects[i]
125158
node.setPosition('y', node.getPosition('y') * multiplier)
126159
node.setPosition('z', node.getPosition('z') * multiplier)
127160
}
128161
redrawIntraLayerEdges()
129162
raiseMovedFlags()
163+
history.execute(
164+
new TransformCommand('Spread nodes', before, captureTransforms())
165+
)
130166
}
131167

132168
function moveNodes(direction: number, axis: Axis): void {
@@ -350,10 +386,61 @@ function attachNavControls(): void {
350386
}
351387
}
352388

353-
const scaleL = document.getElementById('layerScaleSlider')!
354-
scaleL.addEventListener('input', scaleLayers)
355-
const scaleN = document.getElementById('nodeScaleSlider')!
356-
scaleN.addEventListener('input', scaleNodes)
389+
// Live-scale on input; one undo entry per drag, finalized on 'change'.
390+
// `sync` re-applies the restored scale to the slider + its value label on
391+
// undo/redo (the transform snapshot only covers the 3D objects).
392+
const wireScaleSlider = (
393+
el: HTMLElement,
394+
run: () => void,
395+
description: string,
396+
sync: () => void
397+
): void => {
398+
let before: TransformSnapshot | null = null
399+
el.addEventListener('input', () => {
400+
before ??= captureTransforms()
401+
run()
402+
})
403+
el.addEventListener('change', () => {
404+
if (before) {
405+
history.execute(
406+
new TransformCommand(description, before, captureTransforms(), sync)
407+
)
408+
before = null
409+
}
410+
})
411+
}
412+
const setSlider = (sliderId: string, tdId: string, value: number): void => {
413+
const s = document.getElementById(sliderId) as HTMLInputElement | null
414+
if (s) s.value = String(value)
415+
const td = document.getElementById(tdId)
416+
if (td) td.textContent = `x${value}`
417+
}
418+
wireScaleSlider(
419+
document.getElementById('layerScaleSlider')!,
420+
scaleLayers,
421+
'Scale layers',
422+
() => {
423+
const sel = getSelectedLayers()
424+
setSlider(
425+
'layerScaleSlider',
426+
'sliderValue4',
427+
sel.length ? ctx.layers[sel[0]].getScale() : 1
428+
)
429+
}
430+
)
431+
wireScaleSlider(
432+
document.getElementById('nodeScaleSlider')!,
433+
scaleNodes,
434+
'Scale nodes',
435+
() => {
436+
const sel = getSelectedNodes()
437+
setSlider(
438+
'nodeScaleSlider',
439+
'sliderValue6',
440+
sel.length ? ctx.nodeObjects[sel[0]].getScale() : 1
441+
)
442+
}
443+
)
357444
for (const [sliderId, tdId, fmt] of [
358445
['sceneRotateSlider', 'sliderValue1', 'Angle: %v˚'],
359446
['layerRotateSlider', 'sliderValue2', 'Angle: %v˚'],

frontend/src/actions/node.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,14 @@ export function selectNodesByName(search: string): void {
154154
updateSelectedNodesStore()
155155
}
156156

157+
let currentNodeShape: NodeGeometryType = 'sphere'
158+
159+
export function getNodeShape(): NodeGeometryType {
160+
return currentNodeShape
161+
}
162+
157163
export function setNodeShape(shape: NodeGeometryType): void {
164+
currentNodeShape = shape
158165
for (const node of ctx.nodeObjects) node.setGeometry(shape)
159166
}
160167

frontend/src/commands/scene.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,44 @@ describe('LoadNetworkCommand', () => {
171171
expect(ctx.nodeObjects).toHaveLength(1)
172172
})
173173
})
174+
175+
describe('TransformCommand', () => {
176+
it('restores node/layer transforms on undo', async () => {
177+
const { captureTransforms, TransformCommand } = await import('./scene')
178+
const before = captureTransforms()
179+
ctx.nodeObjects[0].setPosition('y', 42)
180+
ctx.layers[0].plane.position.set(1, 2, 3)
181+
const c = new TransformCommand('move', before, captureTransforms())
182+
c.undo()
183+
expect(ctx.nodeObjects[0].getPosition('y')).toBe(0)
184+
expect(ctx.layers[0].plane.position.x).toBe(0)
185+
c.execute() // redo
186+
expect(ctx.nodeObjects[0].getPosition('y')).toBe(42)
187+
expect(ctx.layers[0].plane.position.z).toBe(3)
188+
})
189+
})
190+
191+
describe('ChangeChannelColorCommand', () => {
192+
it('sets and restores the channel color', async () => {
193+
const { ChangeChannelColorCommand } = await import('./scene')
194+
ctx.channelColors = { ppi: '#cfcfcf' }
195+
const c = new ChangeChannelColorCommand('ppi', '#ff0000')
196+
c.execute()
197+
expect(ctx.channelColors['ppi']).toBe('#ff0000')
198+
c.undo()
199+
expect(ctx.channelColors['ppi']).toBe('#cfcfcf')
200+
})
201+
})
202+
203+
describe('ChangeFloorColorCommand', () => {
204+
it('rewinds picker color to previous, not the just-set value', async () => {
205+
const { ChangeFloorColorCommand } = await import('./scene')
206+
ctx.layerColorPrioritySource = 'picker'
207+
new ChangeFloorColorCommand('#111111').execute() // establishes prior picker
208+
const c = new ChangeFloorColorCommand('#ff0000')
209+
c.execute()
210+
expect(ctx.layers[0].color.toLowerCase()).toBe('#ff0000')
211+
c.undo()
212+
expect(ctx.layers[0].color.toLowerCase()).toBe('#111111')
213+
})
214+
})

0 commit comments

Comments
 (0)