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 @@

Navigation

Two fingersPinch-zoomsUnchanged Mouse wheelZooms at the pointerUnchanged Middle mouse, right mouse held, or SpacePans while heldUnchanged + HSwitches to Pan, and backUnchanged Double-click a containerCenters it and fills the viewUnchanged Double-click empty canvasFrames the whole board, or resets an empty oneUnchanged diff --git a/site/shortcuts.html b/site/shortcuts.html index 9643398..0fc244b 100644 --- a/site/shortcuts.html +++ b/site/shortcuts.html @@ -116,6 +116,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();