Skip to content

Commit 7bf4260

Browse files
Merge pull request #33 from RuntimeRascal/copilot/add-undo-last-drawing
Add Ctrl+Z undo for completed drawing actions with permanent eraser semantics
2 parents daa4b8f + 8aa29e7 commit 7bf4260

33 files changed

+696
-110
lines changed

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88

9+
## v1.0.13
10+
11+
### Added
12+
- **Undo (Ctrl+Z) with Permanent Eraser Semantics**
13+
- Press `Ctrl+Z` while drawing mode is active to undo the most recent **completed** action (pen stroke, line, rectangle, circle)
14+
- Erased items are permanently deleted and are never restored by undo
15+
- New `DrawingHistory` service tracks completed actions using stable element IDs
16+
- Added unit tests covering undo behavior and eraser permanence
17+
18+
### Changed
19+
- **Tool Completion Events**
20+
- Tools report completed actions (pen on mouse-up; shapes on second click) so undo is per-action instead of per-mouse-move
21+
- Eraser reports erased elements so history entries are marked removed
22+
- **Keyboard Hook Handling for Ctrl+Z**
23+
- Detects `Ctrl+Z` only when drawing mode is active
24+
- Suppresses `Ctrl+Z` during drawing mode to prevent pass-through to underlying apps
25+
- **History Reset Behavior**
26+
- Undo history is cleared when clearing the canvas and when exiting drawing mode
27+
28+
929
## v1.0.12
1030

1131
### Added

Installer/GhostDraw.Installer.wixproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project Sdk="WixToolset.Sdk/4.0.5">
22
<PropertyGroup>
3-
<Version Condition="'$(Version)' == ''">1.0.12</Version>
3+
<Version Condition="'$(Version)' == ''">1.0.13</Version>
44
<OutputName>GhostDrawSetup-$(Version)</OutputName>
55
<OutputType>Package</OutputType>
66
<Platform>x64</Platform>

Src/GhostDraw/App.xaml.cs

Lines changed: 25 additions & 1 deletion
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
@@ -367,7 +368,7 @@ private void OnScreenshotFullPressed(object? sender, EventArgs e)
367368
_logger?.LogInformation("====== OnScreenshotFullPressed CALLED ======");
368369
_logger?.LogInformation("DrawingManager null: {IsNull}", _drawingManager == null);
369370
_logger?.LogInformation("DrawingManager.IsDrawingMode: {IsDrawingMode}", _drawingManager?.IsDrawingMode);
370-
371+
371372
// Capture full screenshot if drawing mode is active
372373
if (_drawingManager?.IsDrawingMode == true)
373374
{
@@ -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/AppSettings.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public class AppSettings
8686
/// </summary>
8787
[JsonPropertyName("screenshotSavePath")]
8888
public string ScreenshotSavePath { get; set; } = System.IO.Path.Combine(
89-
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
89+
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
9090
"GhostDraw");
9191

9292
/// <summary>

Src/GhostDraw/Core/DrawTool.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,22 @@ public enum DrawTool
1212
/// Freehand drawing tool (default)
1313
/// </summary>
1414
Pen,
15-
15+
1616
/// <summary>
1717
/// Straight line tool - click two points to draw a line
1818
/// </summary>
1919
Line,
20-
20+
2121
/// <summary>
2222
/// Eraser tool - removes drawing objects underneath the cursor
2323
/// </summary>
2424
Eraser,
25-
25+
2626
/// <summary>
2727
/// Rectangle tool - click two points to draw a rectangle
2828
/// </summary>
2929
Rectangle,
30-
30+
3131
/// <summary>
3232
/// Circle tool - click two points to draw a circle/ellipse
3333
/// </summary>

Src/GhostDraw/Core/GlobalKeyboardHook.cs

Lines changed: 24 additions & 11 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;
@@ -51,7 +53,7 @@ public class GlobalKeyboardHook : IDisposable
5153
private Dictionary<int, bool> _keyStates = new();
5254
private bool _wasHotkeyActive = false;
5355
private volatile bool _isControlPressed = false;
54-
56+
5557
// Drawing mode state - used to determine if we should suppress keys
5658
private volatile bool _isDrawingModeActive = false;
5759

@@ -65,7 +67,7 @@ public GlobalKeyboardHook(ILogger<GlobalKeyboardHook> logger)
6567
foreach (var vk in _hotkeyVKs)
6668
_keyStates[vk] = false;
6769
}
68-
70+
6971
/// <summary>
7072
/// Configures the hotkey combination
7173
/// </summary>
@@ -184,7 +186,7 @@ private nint SetHook(LowLevelKeyboardProc proc)
184186
private nint HookCallback(int nCode, nint wParam, nint lParam)
185187
{
186188
bool shouldSuppressKey = false;
187-
189+
188190
try
189191
{
190192
if (nCode >= 0)
@@ -202,8 +204,8 @@ private nint HookCallback(int nCode, nint wParam, nint lParam)
202204
if (vkCode == VK_LCONTROL || vkCode == VK_RCONTROL)
203205
{
204206
_isControlPressed = isKeyDown;
205-
_logger.LogDebug("Control key ({Type}) {State}",
206-
vkCode == VK_LCONTROL ? "Left" : "Right",
207+
_logger.LogDebug("Control key ({Type}) {State}",
208+
vkCode == VK_LCONTROL ? "Left" : "Right",
207209
isKeyDown ? "PRESSED" : "RELEASED");
208210
}
209211

@@ -269,12 +271,12 @@ private nint HookCallback(int nCode, nint wParam, nint lParam)
269271
_logger.LogInformation("====== CTRL+S DETECTED ======");
270272
_logger.LogInformation("Control key state: {IsControlPressed}", _isControlPressed);
271273
_logger.LogInformation("Drawing mode active: {IsDrawingModeActive}", _isDrawingModeActive);
272-
274+
273275
_logger.LogInformation("Ctrl+S pressed - firing ScreenshotFullPressed event");
274276
ScreenshotFullPressed?.Invoke(this, EventArgs.Empty);
275-
_logger.LogInformation("ScreenshotFullPressed event fired, subscribers: {Count}",
277+
_logger.LogInformation("ScreenshotFullPressed event fired, subscribers: {Count}",
276278
ScreenshotFullPressed?.GetInvocationList().Length ?? 0);
277-
279+
278280
// Suppress Ctrl+S when drawing mode is active to prevent Windows Snipping Tool
279281
if (_isDrawingModeActive)
280282
{
@@ -285,10 +287,21 @@ private nint HookCallback(int nCode, nint wParam, nint lParam)
285287
{
286288
_logger.LogInformation("KEY WILL NOT BE SUPPRESSED - Drawing mode is inactive");
287289
}
288-
290+
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
{
@@ -328,7 +341,7 @@ private nint HookCallback(int nCode, nint wParam, nint lParam)
328341
_logger.LogTrace("Key suppressed - not calling CallNextHookEx");
329342
return (nint)1;
330343
}
331-
344+
332345
// MUST call CallNextHookEx for non-suppressed keys to allow other applications to process them
333346
return CallNextHookEx(_hookID, nCode, wParam, lParam);
334347
}
@@ -359,7 +372,7 @@ public void SetDrawingModeActive(bool isActive)
359372
{
360373
var previousState = _isDrawingModeActive;
361374
_isDrawingModeActive = isActive;
362-
375+
363376
if (previousState != isActive)
364377
{
365378
_logger.LogInformation("====== DRAWING MODE STATE CHANGED ======");

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/GhostDraw.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<UseWPF>true</UseWPF>
99
<UseWindowsForms>true</UseWindowsForms>
1010
<ApplicationIcon>Assets\favicon.ico</ApplicationIcon>
11-
<Version>1.0.12</Version>
11+
<Version>1.0.13</Version>
1212
<EnableWindowsTargeting>true</EnableWindowsTargeting>
1313
</PropertyGroup>
1414

Src/GhostDraw/Helpers/CursorHelper.cs

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,8 @@ public WpfCursor CreateEraserCursor()
216216
for (int i = 0; i < 3; i++)
217217
{
218218
int offset = i * 5;
219-
g.DrawLine(texturePen,
220-
eraserLeft + offset, eraserTop,
219+
g.DrawLine(texturePen,
220+
eraserLeft + offset, eraserTop,
221221
eraserLeft + offset + 6, eraserTop + eraserHeight);
222222
}
223223
}
@@ -410,7 +410,7 @@ public WpfCursor CreateLineCursor(string colorHex)
410410
// Left circle is at the hotspot (where the mouse clicks)
411411
int circleRadius = 4;
412412
int circleSpacing = 20; // Increased spacing for better visibility
413-
413+
414414
// Position left circle at the hotspot (6 pixels from left edge for visual balance)
415415
Point leftCircleCenter = new Point(6, size / 2);
416416
Point rightCircleCenter = new Point(6 + circleSpacing, size / 2);
@@ -424,36 +424,36 @@ public WpfCursor CreateLineCursor(string colorHex)
424424
// Draw left circle (outline) - this is where the line starts
425425
using (Pen circlePen = new Pen(Color.White, 2))
426426
{
427-
g.DrawEllipse(circlePen,
428-
leftCircleCenter.X - circleRadius,
429-
leftCircleCenter.Y - circleRadius,
430-
circleRadius * 2,
427+
g.DrawEllipse(circlePen,
428+
leftCircleCenter.X - circleRadius,
429+
leftCircleCenter.Y - circleRadius,
430+
circleRadius * 2,
431431
circleRadius * 2);
432432
}
433433
using (Pen circleOutline = new Pen(Color.Black, 1))
434434
{
435-
g.DrawEllipse(circleOutline,
436-
leftCircleCenter.X - circleRadius - 1,
437-
leftCircleCenter.Y - circleRadius - 1,
438-
circleRadius * 2 + 2,
435+
g.DrawEllipse(circleOutline,
436+
leftCircleCenter.X - circleRadius - 1,
437+
leftCircleCenter.Y - circleRadius - 1,
438+
circleRadius * 2 + 2,
439439
circleRadius * 2 + 2);
440440
}
441441

442442
// Draw right circle (outline)
443443
using (Pen circlePen = new Pen(Color.White, 2))
444444
{
445-
g.DrawEllipse(circlePen,
446-
rightCircleCenter.X - circleRadius,
447-
rightCircleCenter.Y - circleRadius,
448-
circleRadius * 2,
445+
g.DrawEllipse(circlePen,
446+
rightCircleCenter.X - circleRadius,
447+
rightCircleCenter.Y - circleRadius,
448+
circleRadius * 2,
449449
circleRadius * 2);
450450
}
451451
using (Pen circleOutline = new Pen(Color.Black, 1))
452452
{
453-
g.DrawEllipse(circleOutline,
454-
rightCircleCenter.X - circleRadius - 1,
455-
rightCircleCenter.Y - circleRadius - 1,
456-
circleRadius * 2 + 2,
453+
g.DrawEllipse(circleOutline,
454+
rightCircleCenter.X - circleRadius - 1,
455+
rightCircleCenter.Y - circleRadius - 1,
456+
circleRadius * 2 + 2,
457457
circleRadius * 2 + 2);
458458
}
459459

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
}

0 commit comments

Comments
 (0)