Files
mmd-pdf/Shell/KeyboardShortcuts.cs
T
kua-agent 6610acfa44 feat: rebrand to MMD PDF, single corporate theme, and remove original text under an edit
- Services/PdfRedactText.cs: strip text whose origin falls inside a CoverAnnotation
  from the page content stream at save time, hooked into PdfBurn.DrawAnnotationsIntoDoc.
  Fixes edited values staying recoverable by text extraction.
- Themes/MMD.xaml replaces all thirteen themes; picker and accent strip removed; no dark mode.
- Rename KillerPDF -> MMD PDF across code, resources, packaging and locale strings; new icon.
- Remove the upstream author credit and the in-app install button.
2026-08-27 07:37:09 +02:00

706 lines
36 KiB
C#

using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using Docnet.Core;
using Docnet.Core.Models;
using Microsoft.Win32;
using PdfSharpCore.Drawing;
using PdfSharpCore.Pdf;
using PdfSharpCore.Pdf.IO;
using MmdPdf.Services;
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
namespace MmdPdf
{
public partial class MainWindow
{
// ============================================================
// Keyboard shortcuts
// ============================================================
protected override void OnPreviewKeyDown(KeyEventArgs e)
{
base.OnPreviewKeyDown(e);
// Keyboard view of the shortcuts overlay: holding Ctrl / Shift / Alt previews that layer.
KbSyncLayerFromModifiers();
// Bold / italic / underline while a text annotation is being edited. This has to come
// BEFORE the early return below, which hands every other key to the edit box: that
// return is exactly why Ctrl+B never reached the text tool and collapsed the sidebar
// instead. The two meanings of Ctrl+B cannot collide, because the sidebar binding is
// only reachable when no edit box has focus and this one only when it does.
if (_activeTextBox is not null && _activeTextBox.IsFocused &&
Keyboard.Modifiers == ModifierKeys.Control &&
e.Key is Key.B or Key.I or Key.U)
{
switch (e.Key)
{
case Key.B: _textBold = !_textBold; break;
case Key.I: _textItalic = !_textItalic; break;
case Key.U: _textUnderline = !_textUnderline; break;
}
ApplyTextStyleToSelection(); // TextSettingsBar.cs, same pair the toggle buttons call
ShowTextSettings();
e.Handled = true;
return;
}
// Don't intercept keys when typing in an editable TextBox (typewriter tool or form field).
// The zoom ComboBox is editable-but-read-only; after using it, focus parks on its inner
// TextBox and would otherwise swallow every shortcut (e.g. Ctrl+F) until the user clicked away.
if (e.OriginalSource is TextBox tbSrc && !tbSrc.IsReadOnly) return;
if (_activeTextBox is not null && _activeTextBox.IsFocused) return;
if (e.Key == Key.C && Keyboard.Modifiers == ModifierKeys.Control)
{
// An annotation selection copies the annotation(s); otherwise copy page text.
if (_selectedAnnotation is not null || _selectedSet.Count > 0) CopySelectedAnnotations();
else CopySelectedText();
e.Handled = true;
}
else if (e.Key == Key.V && Keyboard.Modifiers == ModifierKeys.Control)
{
// Internal annotation clipboard takes priority over an OS-clipboard image paste.
if (_annotationClipboard.Count > 0) PasteAnnotations(PageList.SelectedIndex);
else PasteFromClipboard();
e.Handled = true;
}
else if (e.Key == Key.A && Keyboard.Modifiers == ModifierKeys.Control)
{
// Prefer selecting all annotations (shows where everything is, makes stacked annotations
// editable); fall back to selecting page text when there are none on screen.
if (!SelectAllAnnotations()) SelectAllText();
e.Handled = true;
}
else if (e.Key == Key.F && Keyboard.Modifiers == ModifierKeys.Control)
{
ToggleSearchBar();
e.Handled = true;
}
else if (e.Key == Key.F3 && (Keyboard.Modifiers == ModifierKeys.None || Keyboard.Modifiers == ModifierKeys.Shift))
{
// F3 / Shift+F3 - next / previous search match, the Find Next convention (Acrobat and
// Sumatra do the same). With the search bar closed, F3 opens it like Ctrl+F.
if (_searchBar is null || _searchBar.Visibility != Visibility.Visible)
ToggleSearchBar();
else if (Search.HasResults)
{
if (Keyboard.Modifiers == ModifierKeys.Shift) SearchPrevResult();
else SearchNextResult();
}
e.Handled = true;
}
else if (e.Key == Key.Escape && _shapePolyPoints.Count > 0)
{
// Shapes tool (#127 Phase 3): abandon the in-progress polygon.
CancelShapePolygon();
e.Handled = true;
}
else if (e.Key == Key.Back && _shapePolyPoints.Count > 0)
{
// Remove the last placed polygon vertex.
ShapePolyBackspace();
e.Handled = true;
}
else if (e.Key == Key.Enter && _shapePolyPoints.Count >= 3)
{
// Close the polygon from the keyboard.
CommitShapePolygon();
e.Handled = true;
}
else if (e.Key == Key.Enter && _currentTool == EditTool.Crop && _cropConfirmBar is not null)
{
ApplyCrop([PageList.SelectedIndex]);
e.Handled = true;
}
else if (e.Key == Key.Escape && _currentTool == EditTool.Crop && _cropConfirmBar is not null)
{
HideCropConfirmBar();
e.Handled = true;
}
else if (e.Key == Key.Escape && ShortcutOverlay.Visibility == Visibility.Visible)
{
FadeOverlayOut(ShortcutOverlay);
e.Handled = true;
}
else if (e.Key == Key.Escape && AboutOverlay.Visibility == Visibility.Visible)
{
CloseAboutOverlay();
e.Handled = true;
}
else if (e.Key == Key.Escape && _searchBar is not null && _searchBar.Visibility == Visibility.Visible)
{
CloseSearchBar();
e.Handled = true;
}
else if (e.Key == Key.Escape && _busyCts is not null)
{
// A cancellable long operation (OCR, repair) is running behind the busy overlay - offer to
// cancel it instead of letting Escape fall through to the app-exit handler below.
if (KillerDialog.Show(this, string.Format(Loc("Str_Dlg_CancelBusy"), _busyOpLabel), "MmdPdf",
MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes)
_busyCts?.Cancel();
e.Handled = true;
}
// #153: matched by the character the key TYPES, not its position. On a German layout
// "?" is Shift+ss, so the US-positional OemQuestion check never fired (and the exact
// modifier equality failed too, since typing "?" holds Shift). The VK test stays as a
// fast path for layouts where it already worked.
else if (Services.KeyLayout.IsCtrlChar(e.Key, '?')
|| (e.Key == Key.OemQuestion && Keyboard.Modifiers == ModifierKeys.Control))
{
if (ShortcutOverlay.Visibility == Visibility.Visible) FadeOverlayOut(ShortcutOverlay);
else ShowShortcutsOverlayExclusive();
e.Handled = true;
}
else if (e.Key == Key.F1)
{
// Toggle the shortcuts overlay (conventional Help key, alongside Ctrl+?).
if (ShortcutOverlay.Visibility == Visibility.Visible) FadeOverlayOut(ShortcutOverlay);
else ShowShortcutsOverlayExclusive();
e.Handled = true;
}
else if (e.Key == Key.F12)
{
// Toggle the About dialog. (Moved off F2, which now belongs to rename-bookmark in the
// outline panel, #133 - Windows convention. Document Info moved to F4 / Ctrl+D below.)
if (AboutOverlay.Visibility == Visibility.Visible) CloseAboutOverlay();
else ShowAboutOverlay();
e.Handled = true;
}
else if (e.Key == Key.F4 && Keyboard.Modifiers == ModifierKeys.None)
{
// Document Info - the advertised single-key shortcut (F1 help, F4 info, F5-F8 views,
// F11 full screen, F12 about). Looked it up on the shortcuts overlay and pressed it?
// The cheat sheet gets out of the way first.
if (ShortcutOverlay.Visibility == Visibility.Visible) FadeOverlayOut(ShortcutOverlay);
OpenDocumentInfo();
e.Handled = true;
}
else if (e.Key == Key.D && Keyboard.Modifiers == ModifierKeys.Control)
{
// Compatibility alias: Ctrl+D is Document Properties in Acrobat, Foxit, and SumatraPDF.
if (ShortcutOverlay.Visibility == Visibility.Visible) FadeOverlayOut(ShortcutOverlay);
OpenDocumentInfo();
e.Handled = true;
}
else if (e.Key == Key.F5) { SetViewMode(ViewMode.Continuous); e.Handled = true; }
else if (e.Key == Key.F6) { SetViewMode(ViewMode.Single); e.Handled = true; }
else if (e.Key == Key.F7) { SetViewMode(ViewMode.TwoPage); e.Handled = true; }
else if (e.Key == Key.F8) { SetViewMode(ViewMode.Grid); e.Handled = true; }
// F10 = split pane. Matched as Key.System + SystemKey == Key.F10, NOT e.Key == Key.F10:
// F10 is a system key in WPF (it activates the menu bar), so it never arrives as
// Key.F10 and a plain check silently never fires. The Shift+F10 branch further down has
// the same shape; F11 below does not need it, because F11 is not a system key.
// Shift is excluded here because Shift+F10 is the context menu - without that guard the
// two would both fire off one press.
else if (e.Key == Key.System && e.SystemKey == Key.F10
&& !Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
{
ToggleSplit();
e.Handled = true;
}
// #215: reading view - toolbar off. Alt+M arrives as a system key (Alt+letter), same
// shape as the F10 branches above.
else if (e.Key == Key.System && e.SystemKey == Key.M && Keyboard.Modifiers == ModifierKeys.Alt)
{ ToggleToolbarHidden(); e.Handled = true; }
else if (e.Key == Key.F11) { ToggleFullScreen(); e.Handled = true; }
else if (e.Key == Key.Escape && _fullScreen) { ToggleFullScreen(); e.Handled = true; }
else if (e.Key == Key.F4 && Keyboard.Modifiers == ModifierKeys.Shift && _doc is not null)
{
ShowCurrentFileSize();
e.Handled = true;
}
// PgDn / PgUp navigate to the next / previous page - they never reorder pages (that's the
// toolbar Move Up/Down buttons). Handled at the window level with e.Handled so it behaves the
// same whether the page canvas or a sidebar thumbnail has focus; without this, a focused
// PageList (ListBox) would page its own selection instead. The TextBox guard at the top of this
// handler already exempts typing in a form field / typewriter box.
else if (e.Key == Key.PageDown && Keyboard.Modifiers == ModifierKeys.None)
{
NavigatePageStep(1); // one page; one SPREAD in Two-Page mode (#120)
e.Handled = true;
}
else if (e.Key == Key.PageUp && Keyboard.Modifiers == ModifierKeys.None)
{
NavigatePageStep(-1);
e.Handled = true;
}
else if (e.Key == Key.P && Keyboard.Modifiers == ModifierKeys.Control)
{
Print_Click(this, e);
e.Handled = true;
}
else if (e.Key == Key.Delete && (_selectedAnnotation is not null || _selectedSet.Count > 0))
{
DeleteSelected();
e.Handled = true;
}
else if (e.Key == Key.Z && Keyboard.Modifiers == ModifierKeys.Control)
{
if (!e.IsRepeat) Undo_Click(this, e); // ignore key auto-repeat so one press = one undo
e.Handled = true;
}
else if (e.Key == Key.S && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
SaveAs_Click(this, e);
e.Handled = true;
}
else if (e.Key == Key.S && Keyboard.Modifiers == ModifierKeys.Control)
{
SaveInPlace();
e.Handled = true;
}
else if (e.Key == Key.W && Keyboard.Modifiers == ModifierKeys.Control)
{
CloseTab(_active);
e.Handled = true;
}
else if (e.Key == Key.W && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
CloseOtherTabs(_active);
e.Handled = true;
}
else if (e.Key == Key.Q && Keyboard.Modifiers == ModifierKeys.Control)
{
CloseAllTabs();
e.Handled = true;
}
else if (e.Key == Key.Tab && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
CycleTab(-1);
e.Handled = true;
}
else if (e.Key == Key.Tab && Keyboard.Modifiers == ModifierKeys.Control)
{
CycleTab(1);
e.Handled = true;
}
else if (e.Key == Key.O && Keyboard.Modifiers == ModifierKeys.Control)
{
Open_Click(this, e);
e.Handled = true;
}
else if (e.Key == Key.O && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
OcrPageToClipboard(PageList.SelectedIndex);
e.Handled = true;
}
else if (e.Key == Key.I && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
BeginOcrRegion();
e.Handled = true;
}
else if (e.Key == Key.B && Keyboard.Modifiers == ModifierKeys.None && _doc is not null
&& _viewMode == ViewMode.TwoPage
&& ShortcutOverlay.Visibility != Visibility.Visible
&& AboutOverlay.Visibility != Visibility.Visible)
{
// #193: bare B = book layout toggle, only while Two-Page is active (single-key
// house style; the sidebar is F9). Same guards as the bare-key switches.
ToggleBookMode();
e.Handled = true;
}
else if (e.Key == Key.N && Keyboard.Modifiers == ModifierKeys.None && _doc is not null
&& ShortcutOverlay.Visibility != Visibility.Visible
&& AboutOverlay.Visibility != Visibility.Visible)
{
// Bare N = invert document colors (night mode), #135. Moved off Ctrl+I in 1.6.6 so
// the conventional italic chord is free while editing text; single-key house style.
// Same guards as the bare-key tool switches below (doc open, no overlay, not typing).
ToggleDocInvert(!ActiveViewer.DocInvert); // per pane: flips the focused pane only
e.Handled = true;
}
else if (e.Key == Key.N && Keyboard.Modifiers == ModifierKeys.Shift && _doc is not null
&& ShortcutOverlay.Visibility != Visibility.Visible
&& AboutOverlay.Visibility != Visibility.Visible)
{
// Shift+N pairs with bare N: toggles whether night mode inverts pictures too
// (the moon's right-click option). Same guards as N.
ToggleInvertImages(!BitmapHelpers.DocInvertImages);
e.Handled = true;
}
// App-wide accessibility size (AppScale.cs), distinct from the Ctrl+wheel page zoom:
// Ctrl+Shift +/- steps the whole-app size, Ctrl+Shift+0 resets it. Works with no
// mouse; the wheel-over-logo gesture drives the same scale.
else if ((e.Key == Key.OemPlus || e.Key == Key.Add) && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
ApplyAppScale(_appScale + AppScaleStep, persist: true);
e.Handled = true;
}
else if ((e.Key == Key.OemMinus || e.Key == Key.Subtract) && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
ApplyAppScale(_appScale - AppScaleStep, persist: true);
e.Handled = true;
}
else if ((e.Key == Key.D0 || e.Key == Key.NumPad0) && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
ApplyAppScale(1.0, persist: true);
e.Handled = true;
}
// Toolbar appearance, mirroring the bar's right-click menu top to bottom: Ctrl+Shift+1/2
// pick the icon size, Ctrl+Shift+3..6 pick where the text goes. Number row only - the
// numpad digits stay clear of the tool shortcuts' numpad mappings.
else if (e.Key is >= Key.D1 and <= Key.D6 && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
switch (e.Key)
{
case Key.D1: SetToolbarIconSize(ToolbarIconSize.Small); break;
case Key.D2: SetToolbarIconSize(ToolbarIconSize.Large); break;
case Key.D3: SetToolbarLabelMode(ToolbarLabelMode.None); break;
case Key.D4: SetToolbarLabelMode(ToolbarLabelMode.Beside);break;
case Key.D5: SetToolbarLabelMode(ToolbarLabelMode.Under); break;
case Key.D6: SetToolbarLabelMode(ToolbarLabelMode.Only); break;
}
e.Handled = true;
}
else if (e.Key == Key.F9 && Keyboard.Modifiers == ModifierKeys.Shift)
{
ToggleSidebarSide(); // left/right, pairing with F9's collapse toggle
e.Handled = true;
}
else if (e.Key == Key.N && Keyboard.Modifiers == ModifierKeys.Control)
{
NewDocument();
e.Handled = true;
}
else if (e.Key == Key.F9 && Keyboard.Modifiers == ModifierKeys.None)
{
// The sidebar gets the single key, house style. F9 used to jog the view mode, which
// was the one redundant F-key: F5-F8 already jump straight to all four modes and the
// wheel over the view still cycles them. There is deliberately no Ctrl+B alias:
// Ctrl+B is bold, which is what users pressing it were expecting all along.
SidebarToggle_Click(this, e);
e.Handled = true;
}
else if (e.Key == Key.Home && Keyboard.Modifiers == ModifierKeys.None && _doc is not null)
{
// First / last page (the Acrobat / Sumatra convention).
RecordNavJump();
PageList.SelectedIndex = 0;
e.Handled = true;
}
else if (e.Key == Key.End && Keyboard.Modifiers == ModifierKeys.None && _doc is not null)
{
RecordNavJump();
PageList.SelectedIndex = _doc.PageCount - 1;
e.Handled = true;
}
else if (e.Key == Key.D1 && Keyboard.Modifiers == ModifierKeys.Control && _doc is not null)
{
_fitMode = FitMode.None;
SetTrueZoom(1.0); // actual size (Acrobat Ctrl+1); Ctrl+0 stays the 100% reset
e.Handled = true;
}
else if (e.Key == Key.D2 && Keyboard.Modifiers == ModifierKeys.Control && _doc is not null)
{
App.SetSetting("DefaultFitMode", FitMode.Width.ToString());
FitToWidth(); // (Acrobat Ctrl+2)
e.Handled = true;
}
else if (e.Key == Key.D3 && Keyboard.Modifiers == ModifierKeys.Control && _doc is not null)
{
App.SetSetting("DefaultFitMode", FitMode.Page.ToString());
FitToPage();
e.Handled = true;
}
else if (e.Key == Key.Y && Keyboard.Modifiers == ModifierKeys.Control)
{
if (!e.IsRepeat) Redo_Click(this, e);
e.Handled = true;
}
else if (e.Key == Key.Z && Keyboard.Modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
{
if (!e.IsRepeat) Redo_Click(this, e); // Ctrl+Shift+Z, the editor-style redo
e.Handled = true;
}
else if (e.Key == Key.System && e.SystemKey == Key.Left && Keyboard.Modifiers == ModifierKeys.Alt)
{
NavHistoryGo(back: true); // retrace bookmark / link / jump-box jumps
e.Handled = true;
}
else if (e.Key == Key.System && e.SystemKey == Key.Right && Keyboard.Modifiers == ModifierKeys.Alt)
{
NavHistoryGo(back: false);
e.Handled = true;
}
else if ((e.Key == Key.Apps
|| (e.Key == Key.System && e.SystemKey == Key.F10 && Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)))
&& _doc is not null)
{
// Windows accessibility convention: the Menu key / Shift+F10 opens the context menu
// at the current selection without the mouse.
OpenContextMenuAtSelection();
e.Handled = true;
}
// Bare-key tool switches. Only when a document is open, no modifier is held, and no
// overlay is up (and not while typing - guarded at the top of this handler).
else if (Keyboard.Modifiers == ModifierKeys.None && _doc is not null
&& ShortcutOverlay.Visibility != Visibility.Visible
&& AboutOverlay.Visibility != Visibility.Visible
&& TryToolShortcut(e.Key))
{
e.Handled = true;
}
// Left/Right move one page - one two-page SPREAD in Two-Page mode (#120), so a press
// always changes what's on screen instead of stepping through both pages of a spread.
else if (e.Key == Key.Left && Keyboard.Modifiers == ModifierKeys.None)
{
if (NavigatePageStep(-1)) e.Handled = true;
}
else if (e.Key == Key.Right && Keyboard.Modifiers == ModifierKeys.None)
{
if (NavigatePageStep(1)) e.Handled = true;
}
// Up/Down scroll the view like the mouse wheel instead of jumping a page; at the top/
// bottom edge (or when the page fits the viewport, where there's nothing to scroll)
// they flip to the previous/next page, so at fit-to-page zoom this behaves exactly
// like the old page navigation. Left/Right and PgUp/PgDn stay hard page jumps.
// Handled at the window level so a focused sidebar thumbnail doesn't move its own
// selection instead (same reasoning as PgUp/PgDn above).
else if ((e.Key == Key.Up || e.Key == Key.Down) && Keyboard.Modifiers == ModifierKeys.None)
{
if (_doc is not null)
{
ScrollOrFlipByKey(up: e.Key == Key.Up);
e.Handled = true;
}
}
// #153: "the key that types + or =", whatever position that is on this layout. The
// Ctrl+Shift app-scale chords above are tested FIRST, so ignoring Shift here cannot
// swallow them - which is why this ordering matters and must not be rearranged.
else if (Services.KeyLayout.IsCtrlChar(e.Key, '+', '=')
|| ((e.Key == Key.OemPlus || e.Key == Key.Add) && Keyboard.Modifiers == ModifierKeys.Control))
{
if (_viewMode == ViewMode.Grid) GridZoomStep(false); else SetZoom(_zoomLevel + ZoomStep);
e.Handled = true;
}
else if (Services.KeyLayout.IsCtrlChar(e.Key, '-')
|| ((e.Key == Key.OemMinus || e.Key == Key.Subtract) && Keyboard.Modifiers == ModifierKeys.Control))
{
if (_viewMode == ViewMode.Grid) GridZoomStep(true); else SetZoom(_zoomLevel - ZoomStep);
e.Handled = true;
}
else if (e.Key == Key.D0 && Keyboard.Modifiers == ModifierKeys.Control)
{
SetTrueZoom(1.0);
e.Handled = true;
}
else if (e.Key == Key.Escape && _doc is not null && _currentTool != EditTool.Select)
{
// 1.6.6 (#127 Phase 3): Esc with nothing left to cancel drops back to the Select
// tool, Acrobat-style. With Select already active it keeps falling through to the
// app-exit handler below, unchanged.
SetTool(EditTool.Select);
e.Handled = true;
}
else if (e.Key == Key.Escape)
{
// No overlay active - ESC exits the app
Close();
e.Handled = true;
}
else if (e.Key == Key.Space && !_spaceHeld)
{
_spaceHeld = true;
PagePreviewPanel.Cursor = Cursors.Hand;
e.Handled = true;
}
}
// Full-window overlays are mutually exclusive: opening the shortcuts overlay dismisses
// About instead of stacking - ShowAboutOverlay does the converse.
private void ShowShortcutsOverlayExclusive()
{
if (AboutOverlay.Visibility == Visibility.Visible) FadeOverlayOut(AboutOverlay);
ApplyPersistedShortcutView();
FadeOverlayIn(ShortcutOverlay);
}
// ── Jump history (Alt+Left / Alt+Right / mouse back-forward buttons) ─────────────────────
// Page-granular: recorded at the long-jump sites (bookmark click, internal link, the page
// jump box, Home/End) so a reader thrown 30 pages by a bookmark can retrace the hop.
/// <summary>Records the CURRENT page onto the back stack. Call BEFORE performing a jump.</summary>
private void RecordNavJump()
{
if (_doc is null) return;
int cur = Math.Max(0, PageList.SelectedIndex);
if (_navBack.Count > 0 && _navBack.Peek() == cur) { _navForward.Clear(); return; }
_navBack.Push(cur);
_navForward.Clear(); // a fresh jump invalidates the forward chain, like a browser
}
private void NavHistoryGo(bool back)
{
if (_doc is null) return;
var from = back ? _navBack : _navForward;
var to = back ? _navForward : _navBack;
if (from.Count == 0) return;
int cur = Math.Max(0, PageList.SelectedIndex);
int target = from.Pop();
to.Push(cur);
if (target >= 0 && target < _doc.PageCount)
PageList.SelectedIndex = target;
}
// Mouse back / forward buttons (XButton1 / XButton2) retrace the same history, like a
// browser. Registered on the window in the MainWindow constructor.
private void NavHistory_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.XButton1) { NavHistoryGo(back: true); e.Handled = true; }
else if (e.ChangedButton == MouseButton.XButton2) { NavHistoryGo(back: false); e.Handled = true; }
}
/// <summary>Keyboard access to the right-click menu (Menu key / Shift+F10): the selected
/// annotation's menu at its bounds, or the page-level menu centered on the current page's
/// canvas. Placement is set to Center just for this open and restored on close, so the
/// mouse path keeps its open-at-cursor behavior.</summary>
private void OpenContextMenuAtSelection()
{
if (_doc is null || _annotationCanvas.ContextMenu is not ContextMenu cm) return;
int pg = Math.Max(0, PageList.SelectedIndex);
Point pt;
if (_selectedAnnotation is not null)
{
pg = _selectedAnnotation.PageIndex;
var b = AnnotBounds(_selectedAnnotation);
pt = new Point(b.X + b.Width / 2, b.Y + b.Height / 2);
}
else
pt = _renderDims.TryGetValue(pg, out var rd)
? new Point(rd.w / 2.0, rd.h / 2.0)
: new Point(0, 0);
var canvas = VisibleCanvasForPage(pg) ?? CanvasForPage(pg);
if (canvas is null) return;
_activeCanvas = canvas;
PopulateContextMenu(pt, pg);
cm.PlacementTarget = canvas;
var prevPlacement = cm.Placement;
cm.Placement = System.Windows.Controls.Primitives.PlacementMode.Center;
void Restore(object? s, RoutedEventArgs a) { cm.Placement = prevPlacement; cm.Closed -= Restore; }
cm.Closed += Restore;
cm.IsOpen = true;
}
// One Up/Down arrow press scrolls this many DIP; key auto-repeat makes holding the key a
// smooth continuous scroll. Kept smaller than a wheel notch (144 DIP, see WheelScrollFactor)
// for fine reading control.
private const double ArrowScrollStep = 48.0;
// Up/Down arrow behavior, mirroring PagePreview_PreviewMouseWheel exactly: Grid and
// Continuous are one scroll over the whole document, so the keys always scroll; Single/
// Two-Page scroll within the page and flip to the previous/next page at the edges.
private void ScrollOrFlipByKey(bool up)
{
double step = up ? -ArrowScrollStep : ArrowScrollStep;
if (_viewMode == ViewMode.Grid || _viewMode == ViewMode.Continuous)
{
PagePreviewPanel.ScrollToVerticalOffset(PagePreviewPanel.VerticalOffset + step);
return;
}
// NavigatePageByWheel treats positive delta as "previous page" (wheel-up), so reuse
// the same convention here.
if (PagePreviewPanel.ScrollableHeight <= 0)
{
NavigatePageByWheel(up ? 120 : -120);
return;
}
bool atTop = PagePreviewPanel.VerticalOffset <= 0;
bool atBottom = PagePreviewPanel.VerticalOffset >= PagePreviewPanel.ScrollableHeight - 1;
if ((atTop && up) || (atBottom && !up))
{
NavigatePageByWheel(up ? 120 : -120);
return;
}
PagePreviewPanel.ScrollToVerticalOffset(PagePreviewPanel.VerticalOffset + step);
}
protected override void OnPreviewKeyUp(KeyEventArgs e)
{
base.OnPreviewKeyUp(e);
KbSyncLayerFromModifiers(); // releasing a modifier drops the keyboard view back a layer
if (e.Key == Key.Space && _spaceHeld)
{
_spaceHeld = false;
if (!ActiveViewer.IsPanning)
PagePreviewPanel.Cursor = Cursors.Arrow;
e.Handled = true;
}
}
// Maps a bare key to an editing tool. Returns false for any other key so the caller's
// shortcut chain continues. Mirrors the toolbar tool buttons exactly - Signature routes
// through its button handler so the signature picker opens (a bare SetTool would only arm
// the tool without showing the menu).
private bool TryToolShortcut(Key key)
{
switch (key)
{
// Tools are reachable by their toolbar position (digits 1-9, 0 mirror the toolbar,
// left to right); the original letter keys stay as fallbacks. Both the number-row and
// numpad digits map. 1.6.6 remap (#127 Phase 3): Select is V-only (the Photoshop /
// Illustrator / Figma convention) - its digit went to Text so Shapes could take 4 and
// the digits keep mirroring the toolbar order.
case Key.V: SetTool(EditTool.Select); return true;
case Key.T: case Key.D1: case Key.NumPad1: SetTool(EditTool.Text); return true;
case Key.H: case Key.D2: case Key.NumPad2: SetTool(EditTool.Highlight); return true;
case Key.L: case Key.U: case Key.D3: case Key.NumPad3: SetTool(EditTool.Line); return true;
case Key.D4: case Key.NumPad4: SetTool(EditTool.Shape); return true;
case Key.D: case Key.D5: case Key.NumPad5: SetTool(EditTool.Draw); return true;
case Key.I: case Key.D6: case Key.NumPad6: SetTool(EditTool.Image); return true;
case Key.G: case Key.D7: case Key.NumPad7: ToolSignature_Click(this, new RoutedEventArgs()); return true;
case Key.C: case Key.D8: case Key.NumPad8: SetTool(EditTool.Crop); return true;
case Key.R: case Key.D9: case Key.NumPad9: ToolRotate_Click(this, new RoutedEventArgs()); return true;
case Key.S: case Key.D0: case Key.NumPad0: ToolStamp_Click(this, new RoutedEventArgs()); return true;
default: return false;
}
}
// Appends each tool's key to its tooltip, e.g. "Highlight (2)". Digits mirror the toolbar
// position (1-9, 0); Select shows its letter (V) since the 1.6.6 remap made it V-only.
// Re-resolves the localized base text so a language switch keeps the right wording (from SelectLocale).
private void ApplyToolNumberTooltips()
{
// n null or empty = no key to advertise, so the tooltip is just the localized text
// rather than a stray empty bracket pair.
void Set(System.Windows.Controls.Button btn, string key, string? n)
{
if (btn == null || TryFindResource(key) is not string s) return;
btn.ToolTip = string.IsNullOrEmpty(n) ? s : $"{s} ({n})";
}
Set(ToolSelectBtn, "Str_TT_SelectTool", "V");
Set(ToolTextBtn, "Str_TT_TextTool", "1");
Set(ToolHighlightBtn, "Str_TT_HighlightTool", "2");
Set(ToolUnderlineBtn, "Str_TT_LineTool", "3"); // repurposed to the Line tool
Set(ToolShapeBtn, "Str_TT_ShapeTool", "4");
Set(ToolDrawBtn, "Str_TT_DrawTool", "5");
Set(ToolImageBtn, "Str_TT_ImageTool", "6");
Set(ToolSignatureBtn, "Str_TT_SignatureTool", "7");
Set(ToolCropBtn, "Str_TT_CropTool", "8");
Set(_toolRotateBtn, "Str_TT_RotateTool", "9");
// Not a tool, but the same treatment. Cycling has no key any more: F9 went to the
// sidebar, so the wheel over the view (or the button itself) is how you jog modes,
// and advertising a dead key here is worse than advertising none.
Set(ViewModeBtn, "Str_TT_ViewMode", null);
}
// Opens the online help / how-to page in the user's default browser.
private void OnlineHelp_Click(object sender, RoutedEventArgs e)
{
try { Process.Start(new ProcessStartInfo("https://baobab-ts.com/help.html") { UseShellExecute = true }); }
catch { }
}
}
}