Skip to content

Commit 061e85a

Browse files
committed
Fix: Proper snapshot handling for drawing and dragging commands (Undo/Redo)
This commit resolves the bug where undo/redo failed for drawing wires and dragging elements, particularly affecting the first actions in a command sequence. The core issue was that before/after snapshots were either captured too late or incorrectly compared, causing the system to skip pushing valid commands to the history. Main changes: - Capture `beforeSnapshot` *before* invoking `.start()` in drag/draw commands. - Ensure `afterSnapshot` is captured *after* `.stop()` and compared meaningfully using `hasStateChanged()`. - Encapsulate GUI interactions into replayable `snapshotCommand` objects (with undo/redo logic). - Refactored and cleaned up GUIAdapter for consistency and redundancy removal. - Simplified `Wire` registration factory in `settings.js` to avoid redundant property handling. - Added `<` and `>` buttons for Undo/Redo in the GUI HTML for testing and use. Result: - Full undo/redo functionality restored and consistent across drawing, dragging, and adding components. - No more missing or duplicated commands in the history stack.
1 parent 5df0327 commit 061e85a

File tree

2 files changed

+153
-77
lines changed

2 files changed

+153
-77
lines changed

src/gui/adapters/GUIAdapter.js

Lines changed: 152 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,71 @@
11
/**
22
* @class GUIAdapter
33
* @description
4-
* The `GUIAdapter` serves as the bridge between the user interface (UI) and the underlying application logic.
5-
* Instead of directly modifying the circuit, it retrieves and executes commands dynamically.
4+
* The `GUIAdapter` bridges the UI and application logic. It transforms user input
5+
* into domain operations via command patterns and ensures state changes are traceable.
66
*
77
* **Core Concepts**:
8-
* - **Event-Driven Execution**: UI interactions trigger commands rather than modifying state directly.
9-
* - **Dynamic Command Injection**: Commands are managed by `GUICommandRegistry` and executed via `CommandHistory`.
10-
* - **Separation of Concerns**: The GUI delegates all logic to `CircuitService` via the command system.
8+
* - **Event-Driven Execution**: User actions trigger command objects.
9+
* - **Command Injection**: Commands are dynamically created via `GUICommandRegistry`.
10+
* - **Undo/Redo**: Commands are managed and reversible via `CommandHistory`.
1111
*
1212
* **Responsibilities**:
13-
* 1. **Initialization**:
14-
* - Renders the circuit and **binds UI htmlelment controls/buttons dynamically**.
15-
* 2. **Command Execution**:
16-
* - Retrieves commands from `GUICommandRegistry` and executes them.
17-
* 3. **Undo/Redo Support**:
18-
* - Ensures that every executed command is trackable via `CommandHistory`.
19-
*
20-
* @example
21-
* const guiAdapter = new GUIAdapter(canvas, circuitService, elementRegistry);
22-
* guiAdapter.initialize();
13+
* 1. Render circuit elements via `CircuitRenderer`.
14+
* 2. Bind UI controls and buttons to domain commands.
15+
* 3. Coordinate undo/redo functionality.
2316
*/
2417
import { CircuitRenderer } from "../renderers/CircuitRenderer.js";
2518
import { CommandHistory } from "../commands/CommandHistory.js";
2619

2720
export class GUIAdapter {
28-
constructor(controls, canvas, circuitService, elementRegistry, rendererFactory, guiCommandRegistry) {
21+
/**
22+
* @constructor
23+
* @param {HTMLElement} controls - DOM container for command buttons.
24+
* @param {HTMLCanvasElement} canvas - Canvas for circuit rendering.
25+
* @param {CircuitService} circuitService - Core domain service.
26+
* @param {ElementRegistry} elementRegistry - Registry of circuit components.
27+
* @param {RendererFactory} rendererFactory - Factory for creating renderers.
28+
* @param {GUICommandRegistry} guiCommandRegistry - Registry for GUI commands.
29+
*/
30+
constructor(
31+
controls,
32+
canvas,
33+
circuitService,
34+
elementRegistry,
35+
rendererFactory,
36+
guiCommandRegistry,
37+
) {
2938
this.controls = controls;
3039
this.canvas = canvas;
3140
this.circuitService = circuitService;
3241
this.elementRegistry = elementRegistry;
33-
this.circuitRenderer = new CircuitRenderer(canvas, circuitService, rendererFactory);
42+
this.circuitRenderer = new CircuitRenderer(
43+
canvas,
44+
circuitService,
45+
rendererFactory,
46+
);
3447
this.guiCommandRegistry = guiCommandRegistry;
3548
this.commandHistory = new CommandHistory();
36-
this.dragCommand = null;
49+
this.activeCommand = null;
3750
this.hasDragged = false;
3851
this.mouseDownPos = { x: 0, y: 0 };
39-
this.activeCommand = null;
4052
}
4153

54+
/**
55+
* Initializes the GUI, binds controls, sets up canvas listeners, and renders.
56+
*/
4257
initialize() {
43-
console.log("GUIAdapter initialized");
4458
this.circuitRenderer.render();
4559
this.bindUIControls();
4660
this.setupCanvasInteractions();
47-
4861
this.circuitService.on("update", () => this.circuitRenderer.render());
4962
}
5063

64+
/**
65+
* Executes a named command through the command registry and stores it in history.
66+
* @param {string} commandName - Identifier for the command.
67+
* @param {...any} args - Arguments passed to the command.
68+
*/
5169
executeCommand(commandName, ...args) {
5270
const command = this.guiCommandRegistry.get(commandName, ...args);
5371
if (command) {
@@ -57,29 +75,28 @@ export class GUIAdapter {
5775
}
5876
}
5977

78+
/**
79+
* Binds UI buttons to element creation and undo/redo commands.
80+
*/
6081
bindUIControls() {
6182
this.elementRegistry.getTypes().forEach((elementType) => {
6283
const buttonName = `add${elementType}`;
63-
console.log(`Searching for button: ${buttonName}`);
64-
6584
const oldButton = this.controls.querySelector(`#${buttonName}`);
6685
if (oldButton) {
6786
const button = oldButton.cloneNode(true);
6887
oldButton.replaceWith(button);
6988

70-
console.log(`Found button: ${button.id}, binding addElement command for ${elementType}`);
7189
button.addEventListener("click", () => {
7290
const command = this.guiCommandRegistry.get(
7391
"addElement",
7492
this.circuitService,
7593
this.circuitRenderer,
7694
this.elementRegistry,
77-
elementType
95+
elementType,
7896
);
7997

8098
if (command) {
8199
this.commandHistory.executeCommand(command, this.circuitService);
82-
console.log(`Command 'addElement' executed for ${elementType}`);
83100
} else {
84101
console.warn(`Command 'addElement' not found for ${elementType}`);
85102
}
@@ -89,29 +106,36 @@ export class GUIAdapter {
89106
}
90107
});
91108

92-
const undoButton = this.controls.querySelector("#undoButton");
93-
if (undoButton) {
94-
undoButton.replaceWith(undoButton.cloneNode(true));
95-
this.controls.querySelector("#undoButton").addEventListener("click", () => {
96-
this.commandHistory.undo(this.circuitService);
97-
this.circuitRenderer.render();
98-
});
99-
} else {
100-
console.warn("Undo button not found");
101-
}
109+
this.bindUndoRedo("#undoButton", () =>
110+
this.commandHistory.undo(this.circuitService),
111+
);
112+
this.bindUndoRedo("#redoButton", () =>
113+
this.commandHistory.redo(this.circuitService),
114+
);
115+
}
102116

103-
const redoButton = this.controls.querySelector("#redoButton");
104-
if (redoButton) {
105-
redoButton.replaceWith(redoButton.cloneNode(true));
106-
this.controls.querySelector("#redoButton").addEventListener("click", () => {
107-
this.commandHistory.redo(this.circuitService);
117+
/**
118+
* Helper for binding undo/redo buttons.
119+
* @param {string} selector - DOM selector for the button.
120+
* @param {Function} action - Function to execute on click.
121+
*/
122+
bindUndoRedo(selector, action) {
123+
const button = this.controls.querySelector(selector);
124+
if (button) {
125+
const clone = button.cloneNode(true);
126+
button.replaceWith(clone);
127+
clone.addEventListener("click", () => {
128+
action();
108129
this.circuitRenderer.render();
109130
});
110131
} else {
111-
console.warn("Redo button not found");
132+
console.warn(`${selector} not found`);
112133
}
113134
}
114135

136+
/**
137+
* Sets up mouse events for interaction on the canvas: zoom, drag, draw.
138+
*/
115139
setupCanvasInteractions() {
116140
this.canvas.addEventListener("wheel", (event) => {
117141
event.preventDefault();
@@ -128,17 +152,20 @@ export class GUIAdapter {
128152

129153
if (event.button === 0) {
130154
const { offsetX, offsetY } = this.getTransformedMousePosition(event);
131-
132155
const element = this.findElementAt(offsetX, offsetY);
133-
if (element) {
134-
this.activeCommand = this.guiCommandRegistry.get("dragElement", this.circuitService);
135-
} else {
136-
this.activeCommand = this.guiCommandRegistry.get("drawWire", this.circuitService, this.elementRegistry);
137-
}
156+
157+
this.activeCommand = element
158+
? this.guiCommandRegistry.get("dragElement", this.circuitService)
159+
: this.guiCommandRegistry.get(
160+
"drawWire",
161+
this.circuitService,
162+
this.elementRegistry,
163+
);
138164

139165
if (this.activeCommand) {
140-
this.activeCommand.beforeSnapshot = this.circuitService.exportState();
166+
const before = this.circuitService.exportState(); // snapshot before changes
141167
this.activeCommand.start(offsetX, offsetY);
168+
this.activeCommand.beforeSnapshot = before;
142169
}
143170

144171
this.hasDragged = false;
@@ -149,14 +176,11 @@ export class GUIAdapter {
149176
this.canvas.addEventListener("mousemove", (event) => {
150177
if (this.activeCommand) {
151178
const { offsetX, offsetY } = this.getTransformedMousePosition(event);
152-
153179
const dx = offsetX - this.mouseDownPos.x;
154180
const dy = offsetY - this.mouseDownPos.y;
155-
const distance = Math.sqrt(dx * dx + dy * dy);
156-
if (distance > 2) {
181+
if (Math.sqrt(dx * dx + dy * dy) > 2) {
157182
this.hasDragged = true;
158183
}
159-
160184
this.activeCommand.move(offsetX, offsetY);
161185
}
162186
});
@@ -168,63 +192,116 @@ export class GUIAdapter {
168192
}
169193

170194
if (this.activeCommand) {
195+
// Make sure snapshot was taken before any state changes
196+
const before = this.activeCommand.beforeSnapshot;
197+
171198
if (!this.hasDragged && this.activeCommand.cancel) {
172199
this.activeCommand.cancel();
173200
} else {
174201
this.activeCommand.stop();
175-
const before = this.activeCommand.beforeSnapshot;
176202
const after = this.circuitService.exportState();
177203

178-
const snapshotCommand = {
179-
execute: () => this.circuitService.importState(after),
180-
undo: () => this.circuitService.importState(before),
181-
};
204+
console.log("[GUI Adapter] State before:", JSON.stringify(before, null, 2));
205+
console.log("[GUI Adapter] State after :", JSON.stringify(after, null, 2));
206+
207+
if (this.hasStateChanged(before, after)) {
208+
console.log("[GUIAdapter] State changed — pushing to history.");
209+
this.circuitService.importState(before);
182210

183-
this.commandHistory.executeCommand(snapshotCommand, this.circuitService);
211+
const snapshotCommand = {
212+
execute: () => this.circuitService.importState(after),
213+
undo: () => this.circuitService.importState(before),
214+
};
215+
216+
this.commandHistory.executeCommand(
217+
snapshotCommand,
218+
this.circuitService,
219+
);
220+
} else {
221+
console.warn(
222+
"[GUIAdapter] No state change — skipping history push.",
223+
);
224+
}
184225
}
185226

186227
this.activeCommand = null;
187228
}
188229
});
189230
}
190231

232+
/**
233+
* Converts screen coordinates to world coordinates.
234+
* @param {MouseEvent} event
235+
* @returns {{offsetX: number, offsetY: number}}
236+
*/
191237
getTransformedMousePosition(event) {
192238
const rect = this.canvas.getBoundingClientRect();
193239
return {
194-
offsetX: (event.clientX - rect.left - this.circuitRenderer.offsetX) / this.circuitRenderer.scale,
195-
offsetY: (event.clientY - rect.top - this.circuitRenderer.offsetY) / this.circuitRenderer.scale,
240+
offsetX:
241+
(event.clientX - rect.left - this.circuitRenderer.offsetX) /
242+
this.circuitRenderer.scale,
243+
offsetY:
244+
(event.clientY - rect.top - this.circuitRenderer.offsetY) /
245+
this.circuitRenderer.scale,
196246
};
197247
}
198248

249+
/**
250+
* Finds the first element at the given world coordinates.
251+
* @param {number} worldX
252+
* @param {number} worldY
253+
* @returns {Element|null}
254+
*/
199255
findElementAt(worldX, worldY) {
200-
for (const element of this.circuitService.getElements()) {
201-
if (this.isInsideElement(worldX, worldY, element)) {
202-
return element;
203-
}
204-
}
205-
return null;
256+
return (
257+
this.circuitService
258+
.getElements()
259+
.find((el) => this.isInsideElement(worldX, worldY, el)) || null
260+
);
206261
}
207262

263+
/**
264+
* Determines if a point is inside or near a circuit element.
265+
* @param {number} x
266+
* @param {number} y
267+
* @param {Element} element
268+
* @returns {boolean}
269+
*/
208270
isInsideElement(x, y, element) {
209271
if (element.nodes.length < 2) return false;
210-
211272
const aura = 10;
212273
const [start, end] = element.nodes;
213274
const dx = end.x - start.x;
214275
const dy = end.y - start.y;
215276
const length = Math.hypot(dx, dy);
216-
if (length < 1e-6) {
217-
return Math.hypot(x - start.x, y - start.y) <= aura;
218-
}
219-
const distance = Math.abs(dy * x - dx * y + end.x * start.y - end.y * start.x) / length;
277+
if (length < 1e-6) return Math.hypot(x - start.x, y - start.y) <= aura;
278+
const distance =
279+
Math.abs(dy * x - dx * y + end.x * start.y - end.y * start.x) / length;
220280
if (distance > aura) return false;
221-
222281
const minX = Math.min(start.x, end.x) - aura;
223282
const maxX = Math.max(start.x, end.x) + aura;
224283
const minY = Math.min(start.y, end.y) - aura;
225284
const maxY = Math.max(start.y, end.y) + aura;
226-
if (x < minX || x > maxX || y < minY || y > maxY) return false;
285+
return !(x < minX || x > maxX || y < minY || y > maxY);
286+
}
227287

228-
return true;
288+
/**
289+
* Compares two circuit snapshots to determine if a meaningful change occurred.
290+
* @param {Object} before
291+
* @param {Object} after
292+
* @returns {boolean}
293+
*/
294+
hasStateChanged(before, after) {
295+
before = JSON.parse(before);
296+
after = JSON.parse(after);
297+
if (!before || !after) return true;
298+
if (before.elements.length !== after.elements.length) return true;
299+
for (let i = 0; i < before.elements.length; i++) {
300+
const a = before.elements[i];
301+
const b = after.elements[i];
302+
if (a.id !== b.id || a.type !== b.type) return true;
303+
if (JSON.stringify(a.nodes) !== JSON.stringify(b.nodes)) return true;
304+
}
305+
return false;
229306
}
230307
}

src/gui/commands/CommandHistory.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,13 @@ export class CommandHistory {
3737
this.history.push({ snapshot, command });
3838
this.future = [];
3939
}
40-
4140
/**
4241
* Reverts to the previous circuit state.
4342
* @param {CircuitService} circuitService
4443
*/
4544
undo(circuitService) {
4645
if (this.history.length === 0) return;
47-
const { snapshot, command } = this.history.pop();
46+
const { snapshot, command } = this.history.pop(); // Pop the last command
4847
const redoSnapshot = circuitService.exportState();
4948
circuitService.importState(snapshot);
5049
this.future.push({ snapshot: redoSnapshot, command });

0 commit comments

Comments
 (0)