Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -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.
-->
<VersionPrefix>1.7.5</VersionPrefix>
<VersionPrefix>1.7.6</VersionPrefix>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions site/guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,7 @@ <h3>Navigation</h3>
<tr><td>Two fingers</td><td>Pinch-zooms</td><td>Unchanged</td></tr>
<tr><td>Mouse wheel</td><td>Zooms at the pointer</td><td>Unchanged</td></tr>
<tr><td>Middle mouse, right mouse held, or <kbd>Space</kbd></td><td>Pans while held</td><td>Unchanged</td></tr>
<tr><td><kbd>H</kbd></td><td>Switches to Pan, and back</td><td>Unchanged</td></tr>
<tr><td>Double-click a container</td><td>Centers it and fills the view</td><td>Unchanged</td></tr>
<tr><td>Double-click empty canvas</td><td>Frames the whole board, or resets an empty one</td><td>Unchanged</td></tr>
</tbody>
Expand Down
1 change: 1 addition & 0 deletions site/shortcuts.html
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ <h2><span class="secico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none"
<h2><span class="secico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M8 14h8"/></svg></span>Keyboard</h2>
<div class="keyrows">
<div class="keyrow"><span><kbd>Space</kbd></span><span class="act">Pan while held.</span></div>
<div class="keyrow"><span><kbd>H</kbd></span><span class="act">Switch to Pan, or back to the previous tool. With one shape selected, types into it instead.</span></div>
<div class="keyrow"><span><kbd>Ctrl</kbd><span class="plus">+</span><kbd>Z</kbd> <span class="plus">/</span> <kbd>Ctrl</kbd><span class="plus">+</span><kbd>Y</kbd></span><span class="act">Undo / redo.</span></div>
<div class="keyrow"><span><kbd>Ctrl</kbd><span class="plus">+</span><kbd>V</kbd></span><span class="act">Paste prefers an image, including a file on the clipboard, over text. A formatted answer pastes as Markdown, with its headings, lists, and tables.</span></div>
<div class="keyrow"><span><kbd>Ctrl</kbd><span class="plus">+</span><kbd>C</kbd></span><span class="act">Copy the selection. Copying a LiveView copies its last frame as a bitmap.</span></div>
Expand Down
187 changes: 187 additions & 0 deletions src/SQLBI.Whiteboard/GrabCursors.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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));

/// <summary>
/// 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.
/// </summary>
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();
}
}
2 changes: 1 addition & 1 deletion src/SQLBI.Whiteboard/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,7 @@
</ToggleButton>
<ToggleButton x:Name="PanToolButton"
Style="{StaticResource ToolbarIconButton}"
ToolTip="Pan"
ToolTip="Pan (H). Hold Space, or drag with the right or middle button, to pan with any tool"
Click="PanToolButton_Click">
<Path Width="20"
Height="20"
Expand Down
Loading
Loading