diff --git a/CHANGELOG.md b/CHANGELOG.md
index a4d5dbf..d9a76c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,13 @@ broke. The heading is parsed by `scripts/release-notes.ps1`, so keep its shape;
under it is ordinary Markdown, and the renderer handles paragraphs, lists, links, `code`
and **bold**.
+## 1.7.6 - 20 October 2026
+
+### Panning with a mouse
+H switches to the Pan tool and back. The pointer turns into an open hand while Space or the
+Pan tool is ready to pan, and closes while the board moves. With Mouse drawing off, holding
+Space and dragging with the left button now pans, where it used to move containers.
+
## 1.7.5 - 1 October 2026
### Mermaid diagrams in Markdown
diff --git a/Directory.Build.props b/Directory.Build.props
index eaf7bee..c791ca0 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -5,7 +5,7 @@
scripts/build-installer.ps1 both read it from here, so releasing is a reviewed change
to this line rather than an edit in a pipeline variable group.
-->
- 1.7.5
+ 1.7.6
latest
enable
enable
diff --git a/README.md b/README.md
index 66a59e5..bdce648 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ How the project is developed and shipped is documented separately:
- Optional finger drawing (default when no pen is detected): one finger uses the current tool, two fingers still pan and pinch-zoom, and Eraser and Pan appear on the toolbar
- A notice at startup when Windows reports neither a pen tablet nor a touchscreen, saying which pointing device the session is drawing with and what a pen would add. Dismissable from the notice itself or from Preferences, since the tablet list Windows reports can miss a pen that has never been in range
- Basic palm rejection: touch navigation is suspended when the pen makes contact
-- Mouse-wheel zoom and middle-button or temporary Space-key panning
+- Mouse-wheel zoom, middle-button or temporary Space-key panning, and H for the Pan tool
- Whole-stroke erasing
- PNG, JPEG, BMP, GIF, and SVG import, clipboard bitmap paste, and Explorer drag-and-drop of images, text files, `.wimport` recipes, PowerPoint decks, and Microsoft Whiteboard exports
- PowerPoint decks imported as one picture per slide through PowerPoint from Microsoft 365: SVG, or PNG for a slide whose fonts are not installed, placed in one row per section, with an optional frame around each slide. **File → Open** creates a new board from the deck; **File → Import** and drag-and-drop add it below the board's content
@@ -201,6 +201,7 @@ Use **Copy settings** after finding a useful combination so the exact values can
| Middle mouse | Pan |
| Right mouse | Pan while held on the canvas; return to Pen on release. Right-click the palette to hide or show it |
| Space | Temporarily switch to Pan |
+| H | Switch to Pan, or back to the previous tool. With one shape selected, types into it instead |
| Ctrl+Z / Ctrl+Y | Undo / redo |
| Ctrl+C | Copy the selection. Copying a LiveView copies its last frame as a bitmap |
| Ctrl+V | Paste prefers an image (including a file on the clipboard) over text. Otherwise create a text container; Markdown and rich clipboard HTML can retain tables, lists, and headings |
diff --git a/site/guide.html b/site/guide.html
index f49d294..7b414b1 100644
--- a/site/guide.html
+++ b/site/guide.html
@@ -935,6 +935,7 @@
SpacePan while held.
+
HSwitch to Pan, or back to the previous tool. With one shape selected, types into it instead.
Ctrl+Z / Ctrl+YUndo / redo.
Ctrl+VPaste prefers an image, including a file on the clipboard, over text. A formatted answer pastes as Markdown, with its headings, lists, and tables.
Ctrl+CCopy the selection. Copying a LiveView copies its last frame as a bitmap.
diff --git a/src/SQLBI.Whiteboard/GrabCursors.cs b/src/SQLBI.Whiteboard/GrabCursors.cs
new file mode 100644
index 0000000..0f94f11
--- /dev/null
+++ b/src/SQLBI.Whiteboard/GrabCursors.cs
@@ -0,0 +1,187 @@
+using System.IO;
+using System.Windows;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+
+namespace SQLBI.Whiteboard;
+
+///
+/// The open hand shown while a mouse drag would pan, and the closed hand shown
+/// while it pans. Windows has no grab cursor of its own - its Hand is the
+/// pointing finger of a link - so both are drawn here at the pixel size of the
+/// monitor and handed to WPF as a .cur file.
+///
+internal static class GrabCursors
+{
+ // Drawn on the 32-unit grid a cursor has at 100%, then scaled.
+ private const double Grid = 32;
+ private static readonly Point HotSpot = new(16, 15);
+ private static readonly Dictionary<(bool Closed, int Size), Cursor> Cache = [];
+
+ public static Cursor Open(double pixelsPerDip) => Get(closed: false, pixelsPerDip);
+
+ public static Cursor Closed(double pixelsPerDip) => Get(closed: true, pixelsPerDip);
+
+ internal static int SizeFor(double pixelsPerDip) =>
+ (int)Math.Round(Grid * Math.Clamp(pixelsPerDip, 1, 4));
+
+ internal static (int X, int Y) HotSpotFor(int size) =>
+ ((int)Math.Round(HotSpot.X * size / Grid), (int)Math.Round(HotSpot.Y * size / Grid));
+
+ private static Cursor Get(bool closed, double pixelsPerDip)
+ {
+ var size = SizeFor(pixelsPerDip);
+ if (!Cache.TryGetValue((closed, size), out var cursor))
+ {
+ using var stream = new MemoryStream(Encode(Render(closed, size), HotSpotFor(size)));
+ cursor = new Cursor(stream);
+ Cache[(closed, size)] = cursor;
+ }
+
+ return cursor;
+ }
+
+ internal static BitmapSource Render(bool closed, int size)
+ {
+ var (shape, creases) = closed ? ClosedHand() : OpenHand();
+ var pen = new Pen(Brushes.Black, 1.25)
+ {
+ LineJoin = PenLineJoin.Round,
+ StartLineCap = PenLineCap.Round,
+ EndLineCap = PenLineCap.Round,
+ };
+
+ var visual = new DrawingVisual();
+ using (var context = visual.RenderOpen())
+ {
+ context.PushTransform(new ScaleTransform(size / Grid, size / Grid));
+ context.DrawGeometry(Brushes.White, pen, shape);
+ foreach (var (from, to) in creases)
+ {
+ context.DrawLine(pen, from, to);
+ }
+
+ context.Pop();
+ }
+
+ var bitmap = new RenderTargetBitmap(size, size, 96, 96, PixelFormats.Pbgra32);
+ bitmap.Render(visual);
+ bitmap.Freeze();
+ return bitmap;
+ }
+
+ // Four fingers spread upward, the thumb out to the left.
+ private static (Geometry Shape, (Point From, Point To)[] Creases) OpenHand()
+ {
+ var shape = Union(
+ new RectangleGeometry(new Rect(9, 14, 15, 12), 5, 5),
+ Finger(9, 5, 12.75, 16),
+ Finger(12.75, 3.5, 16.5, 16),
+ Finger(16.5, 4.5, 20.25, 16),
+ Finger(20.25, 7, 24, 16),
+ Thumb(new Rect(7.6, 13, 3.75, 10), -40, new Point(9.5, 22)));
+ return (shape,
+ [
+ (new Point(12.75, 7), new Point(12.75, 15)),
+ (new Point(16.5, 6.5), new Point(16.5, 15)),
+ (new Point(20.25, 9), new Point(20.25, 15)),
+ ]);
+ }
+
+ // The same hand with the fingers curled down to the knuckles.
+ private static (Geometry Shape, (Point From, Point To)[] Creases) ClosedHand()
+ {
+ var shape = Union(
+ new RectangleGeometry(new Rect(9, 14, 15, 12), 5, 5),
+ Finger(9, 10, 12.75, 17),
+ Finger(12.75, 9.5, 16.5, 17),
+ Finger(16.5, 10, 20.25, 17),
+ Finger(20.25, 11.5, 24, 17),
+ Thumb(new Rect(6.5, 15, 3.75, 8), -15, new Point(8.4, 23)));
+ return (shape,
+ [
+ (new Point(12.75, 11.5), new Point(12.75, 15)),
+ (new Point(16.5, 11.5), new Point(16.5, 15)),
+ (new Point(20.25, 12.5), new Point(20.25, 15)),
+ ]);
+ }
+
+ private static Geometry Finger(double left, double top, double right, double bottom)
+ {
+ var radius = (right - left) / 2;
+ return new RectangleGeometry(new Rect(left, top, right - left, bottom - top), radius, radius);
+ }
+
+ private static Geometry Thumb(Rect bounds, double angle, Point pivot) =>
+ new RectangleGeometry(bounds, bounds.Width / 2, bounds.Width / 2)
+ {
+ Transform = new RotateTransform(angle, pivot.X, pivot.Y),
+ };
+
+ private static Geometry Union(params Geometry[] parts) =>
+ parts.Aggregate((left, right) =>
+ Geometry.Combine(left, right, GeometryCombineMode.Union, null));
+
+ ///
+ /// A single-image .cur: the icon directory, then a 32-bit DIB whose height
+ /// counts the color rows and the mask rows together, both bottom-up. The
+ /// mask marks the fully transparent pixels, for anything that reads it
+ /// instead of the alpha channel.
+ ///
+ internal static byte[] Encode(BitmapSource image, (int X, int Y) hotSpot)
+ {
+ var size = image.PixelWidth;
+ var straight = new FormatConvertedBitmap(image, PixelFormats.Bgra32, null, 0);
+ var stride = size * 4;
+ var pixels = new byte[stride * size];
+ straight.CopyPixels(pixels, stride, 0);
+
+ var maskStride = (size + 31) / 32 * 4;
+ var mask = new byte[maskStride * size];
+ var color = new byte[pixels.Length];
+ for (var row = 0; row < size; row++)
+ {
+ var flipped = size - 1 - row;
+ Buffer.BlockCopy(pixels, row * stride, color, flipped * stride, stride);
+ for (var column = 0; column < size; column++)
+ {
+ if (pixels[row * stride + column * 4 + 3] == 0)
+ {
+ mask[flipped * maskStride + column / 8] |= (byte)(0x80 >> (column % 8));
+ }
+ }
+ }
+
+ const int HeaderSize = 40;
+ var imageSize = HeaderSize + color.Length + mask.Length;
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter(stream);
+ writer.Write((ushort)0);
+ writer.Write((ushort)2);
+ writer.Write((ushort)1);
+ writer.Write((byte)(size >= 256 ? 0 : size));
+ writer.Write((byte)(size >= 256 ? 0 : size));
+ writer.Write((byte)0);
+ writer.Write((byte)0);
+ writer.Write((ushort)hotSpot.X);
+ writer.Write((ushort)hotSpot.Y);
+ writer.Write(imageSize);
+ writer.Write(22);
+ writer.Write(HeaderSize);
+ writer.Write(size);
+ writer.Write(size * 2);
+ writer.Write((ushort)1);
+ writer.Write((ushort)32);
+ writer.Write(0);
+ writer.Write(color.Length + mask.Length);
+ writer.Write(0);
+ writer.Write(0);
+ writer.Write(0);
+ writer.Write(0);
+ writer.Write(color);
+ writer.Write(mask);
+ writer.Flush();
+ return stream.ToArray();
+ }
+}
diff --git a/src/SQLBI.Whiteboard/MainWindow.xaml b/src/SQLBI.Whiteboard/MainWindow.xaml
index 9b1adc4..a1e7683 100644
--- a/src/SQLBI.Whiteboard/MainWindow.xaml
+++ b/src/SQLBI.Whiteboard/MainWindow.xaml
@@ -578,7 +578,7 @@
private void StartShapeTextTyping(KeyEventArgs e)
{
- if (!_features.DesignTools ||
- _activeTool != BoardTool.Select ||
- Keyboard.Modifiers.HasFlag(ModifierKeys.Control) ||
- Keyboard.Modifiers.HasFlag(ModifierKeys.Alt) ||
- SessionBar.IsCommandRowOpen ||
- IsControlFocused() ||
- !IsTypingKey(e.Key) ||
- SingleSelected() is not { } shape)
+ if (ShapeTakingTypedKey(e.Key) is { } shape)
{
- return;
+ BeginShapeTextEdit(shape, replaceText: true);
}
-
- BeginShapeTextEdit(shape, replaceText: true);
}
+ private ShapeBoardObject? ShapeTakingTypedKey(Key key) =>
+ !_features.DesignTools ||
+ _activeTool != BoardTool.Select ||
+ Keyboard.Modifiers.HasFlag(ModifierKeys.Control) ||
+ Keyboard.Modifiers.HasFlag(ModifierKeys.Alt) ||
+ SessionBar.IsCommandRowOpen ||
+ IsControlFocused() ||
+ !IsTypingKey(key)
+ ? null
+ : SingleSelected();
+
///
/// Whether this key writes a character: the letters, the digits, the number
/// pad, and the punctuation keys, whatever they carry on this keyboard. The
@@ -5394,9 +5445,32 @@ private void SelectToolButton_Click(object sender, RoutedEventArgs e)
private void PanToolButton_Click(object sender, RoutedEventArgs e)
{
LeaveLaserIfActive();
+ if (_activeTool != BoardTool.Pan)
+ {
+ _toolBeforePan = _activeTool;
+ }
+
SetActiveTool(BoardTool.Pan);
}
+ // H goes back to the tool Pan was chosen from, so H twice is no change.
+ // The Laser is left first, as the toolbar button leaves it.
+ private void TogglePanTool()
+ {
+ if (_activeTool == BoardTool.Pan)
+ {
+ SetActiveTool(_toolBeforePan);
+ }
+ else
+ {
+ LeaveLaserIfActive();
+ _toolBeforePan = _activeTool;
+ SetActiveTool(BoardTool.Pan);
+ }
+
+ RefreshMouseCursor();
+ }
+
private BoardTool EffectiveTool =>
_spaceTemporaryPan ? BoardTool.Pan : _activeTool;
@@ -9665,6 +9739,22 @@ _shapeEditBefore is null &&
_toolBeforeSpace = _activeTool;
_spaceTemporaryPan = true;
SetActiveTool(BoardTool.Pan);
+ RefreshMouseCursor();
+ e.Handled = true;
+ }
+ else if (e.Key == Key.H &&
+ modifiers == ModifierKeys.None &&
+ !_spaceTemporaryPan &&
+ !IsControlFocused() &&
+ ShapeTakingTypedKey(e.Key) is null)
+ {
+ // A selected shape takes the letter as its text instead, as it
+ // takes every other letter.
+ if (!e.IsRepeat)
+ {
+ TogglePanTool();
+ }
+
e.Handled = true;
}
else
@@ -9785,6 +9875,7 @@ private void Window_PreviewKeyUp(object sender, KeyEventArgs e)
{
_spaceTemporaryPan = false;
SetActiveTool(_toolBeforeSpace);
+ RefreshMouseCursor();
e.Handled = true;
}
}
diff --git a/src/SQLBI.Whiteboard/MouseModeOfferWindow.xaml b/src/SQLBI.Whiteboard/MouseModeOfferWindow.xaml
index 762cb1d..5d61202 100644
--- a/src/SQLBI.Whiteboard/MouseModeOfferWindow.xaml
+++ b/src/SQLBI.Whiteboard/MouseModeOfferWindow.xaml
@@ -30,7 +30,7 @@
FontSize="13"
Foreground="#FF374151"
TextWrapping="Wrap"
- Text="The left button then uses the selected tool, and Ctrl with the left button moves and resizes a container. A mouse reports no pressure, so ink is drawn at an even width. Nothing about the pen changes." />
+ Text="The left button then uses the selected tool, and Ctrl with the left button moves and resizes a container. To pan, drag with the right or middle button, hold Space and drag, or press H for the Pan tool. A mouse reports no pressure, so ink is drawn at an even width. Nothing about the pen changes." />
Preferences > Input.";
+ ? "Mouse drawing is on, so the left button uses the tool selected on the toolbar. Hold Ctrl and drag to move or resize a container, and Ctrl with a double-click to fit one to the canvas. To pan, drag with the right or middle button, hold Space and drag, or press H for the Pan tool."
+ : "Mouse drawing is turned off, so a mouse pans, zooms, and moves containers, but does not draw. To pan, drag with the right or middle button, hold Space and drag, or press H for the Pan tool. Turn drawing on under Help > Preferences > Input.";
LimitsText.Visibility = mouseDrawingEnabled ? Visibility.Visible : Visibility.Collapsed;
}
diff --git a/tests/SQLBI.Whiteboard.SmokeTests/GrabCursorSmokeTests.cs b/tests/SQLBI.Whiteboard.SmokeTests/GrabCursorSmokeTests.cs
new file mode 100644
index 0000000..f35ec89
--- /dev/null
+++ b/tests/SQLBI.Whiteboard.SmokeTests/GrabCursorSmokeTests.cs
@@ -0,0 +1,138 @@
+using System.IO;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+
+namespace SQLBI.Whiteboard.SmokeTests;
+
+///
+/// The pan cursors are drawn and encoded here rather than shipped as files, so
+/// what is checked is that Windows accepts the result at every size a monitor
+/// asks for, and that the hand sits under the hot spot. Whether it looks like a
+/// hand has to be judged by eye, with the preview path.
+///
+internal static class GrabCursorSmokeTests
+{
+ public static void Run(string? previewPath)
+ {
+ Exception? failure = null;
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ Check();
+ if (!string.IsNullOrWhiteSpace(previewPath))
+ {
+ RenderPreview(previewPath);
+ }
+ }
+ catch (Exception exception)
+ {
+ failure = exception;
+ }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ thread.Join();
+ if (failure is not null)
+ {
+ throw new InvalidOperationException("Grab cursor smoke tests failed.", failure);
+ }
+ }
+
+ private static void Check()
+ {
+ Assert(GrabCursors.SizeFor(1) == 32, "At 100% the cursor is the 32 pixels Windows draws its own at.");
+ Assert(GrabCursors.SizeFor(1.5) == 48, "At 150% it is drawn at 48 pixels rather than stretched.");
+ Assert(GrabCursors.SizeFor(0.5) == 32, "It is never drawn smaller than at 100%.");
+
+ foreach (var scale in new[] { 1.0, 1.25, 1.5, 2.0, 3.0 })
+ {
+ Assert(GrabCursors.Open(scale) is Cursor, $"Windows should load the open hand at {scale:P0}.");
+ Assert(GrabCursors.Closed(scale) is Cursor, $"Windows should load the closed hand at {scale:P0}.");
+ }
+
+ Assert(
+ ReferenceEquals(GrabCursors.Open(1.5), GrabCursors.Open(1.5)),
+ "A cursor is built once per size, not on every mouse move.");
+
+ foreach (var closed in new[] { false, true })
+ {
+ var size = GrabCursors.SizeFor(1.5);
+ var pixels = Pixels(GrabCursors.Render(closed, size));
+ var (x, y) = GrabCursors.HotSpotFor(size);
+ Assert(
+ Alpha(pixels, size, x, y) == 255,
+ "The hot spot should be on the hand, so the board is held where the cursor shows it.");
+ Assert(
+ Alpha(pixels, size, 0, 0) == 0 && Alpha(pixels, size, size - 1, size - 1) == 0,
+ "The corners should be transparent, or the cursor would be drawn as a square.");
+ }
+
+ Assert(
+ !Pixels(GrabCursors.Render(closed: false, 32))
+ .SequenceEqual(Pixels(GrabCursors.Render(closed: true, 32))),
+ "The open and the closed hand should not be the same picture.");
+
+ var file = GrabCursors.Encode(GrabCursors.Render(closed: false, 32), GrabCursors.HotSpotFor(32));
+ Assert(
+ BitConverter.ToUInt16(file, 2) == 2 &&
+ BitConverter.ToUInt16(file, 10) == 16 &&
+ BitConverter.ToUInt16(file, 12) == 15,
+ "The file should say it is a cursor and carry the hot spot.");
+ }
+
+ private static byte[] Pixels(BitmapSource bitmap)
+ {
+ var straight = new FormatConvertedBitmap(bitmap, PixelFormats.Bgra32, null, 0);
+ var pixels = new byte[straight.PixelWidth * straight.PixelHeight * 4];
+ straight.CopyPixels(pixels, straight.PixelWidth * 4, 0);
+ return pixels;
+ }
+
+ private static byte Alpha(byte[] pixels, int size, int x, int y) =>
+ pixels[(y * size + x) * 4 + 3];
+
+ // Both hands at three sizes on light and dark, magnified so the pixels
+ // can be judged.
+ private static void RenderPreview(string path)
+ {
+ const int Magnify = 4;
+ int[] sizes = [32, 48, 64];
+ var cell = 64 * Magnify + 16;
+ var visual = new DrawingVisual();
+ RenderOptions.SetBitmapScalingMode(visual, BitmapScalingMode.NearestNeighbor);
+ using (var context = visual.RenderOpen())
+ {
+ for (var band = 0; band < 2; band++)
+ {
+ var background = band == 0 ? Brushes.WhiteSmoke : Brushes.DimGray;
+ context.DrawRectangle(background, null, new System.Windows.Rect(0, band * cell * 2, cell * sizes.Length, cell * 2));
+ for (var row = 0; row < 2; row++)
+ {
+ for (var column = 0; column < sizes.Length; column++)
+ {
+ context.DrawImage(
+ GrabCursors.Render(closed: row == 1, sizes[column]),
+ new System.Windows.Rect(column * cell + 8, (band * 2 + row) * cell + 8, sizes[column] * Magnify, sizes[column] * Magnify));
+ }
+ }
+ }
+ }
+
+ var bitmap = new RenderTargetBitmap(cell * sizes.Length, cell * 4, 96, 96, PixelFormats.Pbgra32);
+ bitmap.Render(visual);
+ var encoder = new PngBitmapEncoder();
+ encoder.Frames.Add(BitmapFrame.Create(bitmap));
+ using var stream = File.Create(path);
+ encoder.Save(stream);
+ }
+
+ private static void Assert(bool condition, string message)
+ {
+ if (!condition)
+ {
+ throw new InvalidOperationException(message);
+ }
+ }
+}
diff --git a/tests/SQLBI.Whiteboard.SmokeTests/Program.cs b/tests/SQLBI.Whiteboard.SmokeTests/Program.cs
index b65a5e4..3945d36 100644
--- a/tests/SQLBI.Whiteboard.SmokeTests/Program.cs
+++ b/tests/SQLBI.Whiteboard.SmokeTests/Program.cs
@@ -583,6 +583,7 @@ public function greet(string $name): string
Environment.GetEnvironmentVariable("SQLBI_WHITEBOARD_MERMAID_BROWSER") == "1",
Environment.GetEnvironmentVariable("SQLBI_WHITEBOARD_MERMAID_PREVIEW"));
SQLBI.Whiteboard.SmokeTests.GridSmokeTests.Run(Environment.GetEnvironmentVariable("SQLBI_WHITEBOARD_GRID_PREVIEW"));
+SQLBI.Whiteboard.SmokeTests.GrabCursorSmokeTests.Run(Environment.GetEnvironmentVariable("SQLBI_WHITEBOARD_GRAB_CURSOR_PREVIEW"));
SQLBI.Whiteboard.SmokeTests.LabelSmokeTests.Run();
SQLBI.Whiteboard.SmokeTests.DesignExportSmokeTests.Run();
SQLBI.Whiteboard.SmokeTests.PreferencesSmokeTests.Run();