Skip to content

Commit e662f8e

Browse files
Add Ctrl+Z undo functionality with history tracking
Co-authored-by: RuntimeRascal <2422222+RuntimeRascal@users.noreply.github.com>
1 parent 8b4946f commit e662f8e

File tree

14 files changed

+548
-1
lines changed

14 files changed

+548
-1
lines changed

Src/GhostDraw/App.xaml.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ protected override void OnStartup(StartupEventArgs e)
8181
_keyboardHook.CircleToolPressed += OnCircleToolPressed;
8282
_keyboardHook.HelpPressed += OnHelpPressed;
8383
_keyboardHook.ScreenshotFullPressed += OnScreenshotFullPressed;
84+
_keyboardHook.UndoPressed += OnUndoPressed;
8485
_keyboardHook.Start();
8586

8687
// Setup system tray icon
@@ -388,6 +389,29 @@ private void OnScreenshotFullPressed(object? sender, EventArgs e)
388389
}
389390
}
390391

392+
private void OnUndoPressed(object? sender, EventArgs e)
393+
{
394+
try
395+
{
396+
_logger?.LogInformation("Ctrl+Z pressed - undoing last action");
397+
398+
// Undo last action if drawing mode is active
399+
if (_drawingManager?.IsDrawingMode == true)
400+
{
401+
_drawingManager?.UndoLastAction();
402+
}
403+
else
404+
{
405+
_logger?.LogDebug("Undo ignored - drawing mode is NOT active");
406+
}
407+
}
408+
catch (Exception ex)
409+
{
410+
_logger?.LogError(ex, "Exception in OnUndoPressed");
411+
_exceptionHandler?.HandleException(ex, "Undo pressed handler");
412+
}
413+
}
414+
391415
protected override void OnExit(ExitEventArgs e)
392416
{
393417
_logger?.LogInformation("Application exiting");

Src/GhostDraw/Core/GlobalKeyboardHook.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public class GlobalKeyboardHook : IDisposable
2020
private const int VK_C = 0x43; // 67 - 'C' key for circle tool
2121
private const int VK_F1 = 0x70; // 112 - 'F1' key for help
2222
private const int VK_S = 0x53; // 83 - 'S' key for screenshot (Ctrl+S only)
23+
private const int VK_Z = 0x5A; // 90 - 'Z' key for undo (Ctrl+Z only)
2324
private const int VK_LCONTROL = 0xA2; // 162 - Left Control key
2425
private const int VK_RCONTROL = 0xA3; // 163 - Right Control key
2526

@@ -41,6 +42,7 @@ public class GlobalKeyboardHook : IDisposable
4142
public event EventHandler? CircleToolPressed;
4243
public event EventHandler? HelpPressed;
4344
public event EventHandler? ScreenshotFullPressed;
45+
public event EventHandler? UndoPressed;
4446

4547
// NEW: Raw key events for recorder
4648
public event EventHandler<KeyEventArgs>? KeyPressed;
@@ -289,6 +291,17 @@ private nint HookCallback(int nCode, nint wParam, nint lParam)
289291
_logger.LogInformation("====== END CTRL+S HANDLING ======");
290292
}
291293

294+
// Check for Ctrl+Z key press (undo - only when drawing mode is active)
295+
if (vkCode == VK_Z && isKeyDown && _isControlPressed && _isDrawingModeActive)
296+
{
297+
_logger.LogInformation("Ctrl+Z pressed - firing UndoPressed event");
298+
UndoPressed?.Invoke(this, EventArgs.Empty);
299+
300+
// Suppress Ctrl+Z when drawing mode is active to prevent underlying apps from receiving it
301+
shouldSuppressKey = true;
302+
_logger.LogDebug("Ctrl+Z suppressed - drawing mode is active");
303+
}
304+
292305
// Track hotkey state
293306
if (_hotkeyVKs.Contains(vkCode))
294307
{

Src/GhostDraw/Core/ServiceConfiguration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ public static ServiceProvider ConfigureServices()
5858
services.AddSingleton<AppSettingsService>();
5959
services.AddSingleton<CursorHelper>();
6060
services.AddSingleton<ScreenshotService>();
61+
services.AddSingleton<DrawingHistory>();
6162

6263
// Register drawing tools
6364
services.AddSingleton<GhostDraw.Tools.PenTool>();

Src/GhostDraw/Managers/DrawingManager.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,4 +447,28 @@ public void CaptureFullScreenshot()
447447
// Don't re-throw - not critical
448448
}
449449
}
450+
451+
/// <summary>
452+
/// Undoes the last drawing action (called via Ctrl+Z)
453+
/// </summary>
454+
public void UndoLastAction()
455+
{
456+
try
457+
{
458+
if (_overlayWindow.IsVisible)
459+
{
460+
_logger.LogInformation("Undo last action (Ctrl+Z)");
461+
_overlayWindow.UndoLastAction();
462+
}
463+
else
464+
{
465+
_logger.LogDebug("UndoLastAction ignored - overlay not visible");
466+
}
467+
}
468+
catch (Exception ex)
469+
{
470+
_logger.LogError(ex, "Failed to undo last action");
471+
// Don't re-throw - not critical
472+
}
473+
}
450474
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
using System.Windows;
2+
using Microsoft.Extensions.Logging;
3+
4+
namespace GhostDraw.Services;
5+
6+
/// <summary>
7+
/// Manages the history of completed drawing actions for undo functionality.
8+
/// Uses a stable GUID identifier on each drawable element's Tag property.
9+
/// </summary>
10+
public class DrawingHistory
11+
{
12+
private readonly ILogger<DrawingHistory> _logger;
13+
14+
// Stack of completed drawing actions (most recent at the top)
15+
private readonly Stack<HistoryEntry> _undoStack = new();
16+
17+
// Dictionary for O(1) lookup when eraser needs to remove history entries
18+
private readonly Dictionary<Guid, HistoryEntry> _elementIdToEntry = new();
19+
20+
public DrawingHistory(ILogger<DrawingHistory> logger)
21+
{
22+
_logger = logger;
23+
}
24+
25+
/// <summary>
26+
/// Records a completed drawing action. Assigns a unique ID to the element.
27+
/// </summary>
28+
/// <param name="element">The UIElement that was added to the canvas</param>
29+
public void RecordAction(UIElement element)
30+
{
31+
try
32+
{
33+
if (element == null)
34+
{
35+
_logger.LogWarning("Attempted to record null element");
36+
return;
37+
}
38+
39+
// Assign a unique ID to this element using Tag (cast to FrameworkElement)
40+
if (element is FrameworkElement frameworkElement)
41+
{
42+
var id = Guid.NewGuid();
43+
frameworkElement.Tag = id;
44+
45+
var entry = new HistoryEntry(id, element);
46+
_undoStack.Push(entry);
47+
_elementIdToEntry[id] = entry;
48+
49+
_logger.LogDebug("Action recorded: ID={Id}, Type={Type}, StackSize={StackSize}",
50+
id, element.GetType().Name, _undoStack.Count);
51+
}
52+
else
53+
{
54+
_logger.LogWarning("Element is not a FrameworkElement, cannot assign Tag");
55+
}
56+
}
57+
catch (Exception ex)
58+
{
59+
_logger.LogError(ex, "Failed to record action");
60+
}
61+
}
62+
63+
/// <summary>
64+
/// Removes the most recent completed action from history.
65+
/// Returns the element to be removed from the canvas, or null if history is empty.
66+
/// </summary>
67+
public UIElement? UndoLastAction()
68+
{
69+
try
70+
{
71+
while (_undoStack.Count > 0)
72+
{
73+
var entry = _undoStack.Pop();
74+
75+
// Remove from dictionary
76+
_elementIdToEntry.Remove(entry.Id);
77+
78+
// Check if element is still valid (not already removed)
79+
if (entry.Element != null)
80+
{
81+
_logger.LogInformation("Undo: Removing element ID={Id}, Type={Type}, RemainingActions={Count}",
82+
entry.Id, entry.Element.GetType().Name, _undoStack.Count);
83+
return entry.Element;
84+
}
85+
else
86+
{
87+
// Element was already removed (e.g., by eraser), skip to next
88+
_logger.LogDebug("Undo: Skipping null element ID={Id} (already removed)", entry.Id);
89+
}
90+
}
91+
92+
_logger.LogDebug("Undo: History stack is empty");
93+
return null;
94+
}
95+
catch (Exception ex)
96+
{
97+
_logger.LogError(ex, "Failed to undo last action");
98+
return null;
99+
}
100+
}
101+
102+
/// <summary>
103+
/// Removes an element from the history (called when eraser deletes it).
104+
/// This ensures erased elements can never be restored by undo.
105+
/// </summary>
106+
/// <param name="element">The element that was erased</param>
107+
public void RemoveFromHistory(UIElement element)
108+
{
109+
try
110+
{
111+
if (element is FrameworkElement frameworkElement && frameworkElement.Tag is Guid id)
112+
{
113+
if (_elementIdToEntry.TryGetValue(id, out var entry))
114+
{
115+
// Mark the entry's element as null so it will be skipped during undo
116+
entry.Element = null;
117+
_elementIdToEntry.Remove(id);
118+
119+
_logger.LogDebug("Element removed from history: ID={Id}, Type={Type}",
120+
id, element.GetType().Name);
121+
}
122+
else
123+
{
124+
_logger.LogDebug("Element not found in history dictionary: ID={Id}", id);
125+
}
126+
}
127+
else
128+
{
129+
_logger.LogDebug("Element has no GUID tag, cannot remove from history");
130+
}
131+
}
132+
catch (Exception ex)
133+
{
134+
_logger.LogError(ex, "Failed to remove element from history");
135+
}
136+
}
137+
138+
/// <summary>
139+
/// Clears all history (called when canvas is cleared or drawing mode exits)
140+
/// </summary>
141+
public void Clear()
142+
{
143+
try
144+
{
145+
var count = _undoStack.Count;
146+
_undoStack.Clear();
147+
_elementIdToEntry.Clear();
148+
149+
if (count > 0)
150+
{
151+
_logger.LogInformation("History cleared: {Count} entries removed", count);
152+
}
153+
}
154+
catch (Exception ex)
155+
{
156+
_logger.LogError(ex, "Failed to clear history");
157+
}
158+
}
159+
160+
/// <summary>
161+
/// Returns the number of actions that can be undone
162+
/// </summary>
163+
public int Count => _undoStack.Count;
164+
165+
/// <summary>
166+
/// Represents a single action in the history
167+
/// </summary>
168+
private class HistoryEntry
169+
{
170+
public Guid Id { get; }
171+
public UIElement? Element { get; set; }
172+
173+
public HistoryEntry(Guid id, UIElement element)
174+
{
175+
Id = id;
176+
Element = element;
177+
}
178+
}
179+
}

Src/GhostDraw/Tools/CircleTool.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ public class CircleTool(ILogger<CircleTool> logger) : IDrawingTool
2525
private string _currentColor = "#FF0000";
2626
private double _currentThickness = 3.0;
2727

28+
public event EventHandler<DrawingActionCompletedEventArgs>? ActionCompleted;
29+
2830
public void OnMouseDown(Point position, Canvas canvas)
2931
{
3032
if (!_isCreatingCircle)
@@ -185,6 +187,9 @@ private void FinishCircle(Point endPoint)
185187
bool isShiftPressed = Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift);
186188
UpdateCircle(_circleStartPoint.Value, endPoint, isShiftPressed);
187189
_logger.LogInformation("Circle finished at ({X:F0}, {Y:F0})", endPoint.X, endPoint.Y);
190+
191+
// Fire ActionCompleted event for history tracking
192+
ActionCompleted?.Invoke(this, new DrawingActionCompletedEventArgs(_currentCircle));
188193
}
189194

190195
_currentCircle = null;

Src/GhostDraw/Tools/EraserTool.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@
77

88
namespace GhostDraw.Tools;
99

10+
/// <summary>
11+
/// Event args for when an element is erased
12+
/// </summary>
13+
public class ElementErasedEventArgs : EventArgs
14+
{
15+
public UIElement Element { get; }
16+
17+
public ElementErasedEventArgs(UIElement element)
18+
{
19+
Element = element;
20+
}
21+
}
22+
1023
/// <summary>
1124
/// Eraser tool - removes drawing objects underneath the cursor
1225
/// </summary>
@@ -17,6 +30,13 @@ public class EraserTool(ILogger<EraserTool> logger) : IDrawingTool
1730
private double _currentThickness = 3.0;
1831
private readonly HashSet<UIElement> _erasedElements = new();
1932

33+
public event EventHandler<DrawingActionCompletedEventArgs>? ActionCompleted;
34+
35+
/// <summary>
36+
/// Event fired when elements are erased (for history removal)
37+
/// </summary>
38+
public event EventHandler<ElementErasedEventArgs>? ElementErased;
39+
2040
// Tolerance for parallel line detection in intersection algorithm
2141
private const double PARALLEL_LINE_TOLERANCE = 0.001;
2242

@@ -185,6 +205,9 @@ private void EraseAtPosition(Point position, Canvas canvas)
185205
{
186206
canvas.Children.Remove(element);
187207
_logger.LogTrace("Erased element at position ({X:F0}, {Y:F0})", position.X, position.Y);
208+
209+
// Fire ElementErased event for history removal
210+
ElementErased?.Invoke(this, new ElementErasedEventArgs(element));
188211
}
189212
}
190213
catch (Exception ex)

Src/GhostDraw/Tools/IDrawingTool.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ namespace GhostDraw.Tools;
99
/// </summary>
1010
public interface IDrawingTool
1111
{
12+
/// <summary>
13+
/// Event fired when a drawing action is completed (e.g., stroke finished, shape placed)
14+
/// </summary>
15+
event EventHandler<DrawingActionCompletedEventArgs>? ActionCompleted;
16+
1217
/// <summary>
1318
/// Called when the mouse left button is pressed
1419
/// </summary>
@@ -49,3 +54,19 @@ public interface IDrawingTool
4954
/// </summary>
5055
void Cancel(Canvas canvas);
5156
}
57+
58+
/// <summary>
59+
/// Event args for when a drawing action is completed
60+
/// </summary>
61+
public class DrawingActionCompletedEventArgs : EventArgs
62+
{
63+
/// <summary>
64+
/// The UIElement that was added to the canvas
65+
/// </summary>
66+
public System.Windows.UIElement Element { get; }
67+
68+
public DrawingActionCompletedEventArgs(System.Windows.UIElement element)
69+
{
70+
Element = element;
71+
}
72+
}

0 commit comments

Comments
 (0)