Files
mmd-pdf/Controls/Viewer/PdfViewer.Zoom.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

360 lines
17 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.Controls
{
// Wheel zoom, wheel scroll, and the pointer gestures that start on the page surface.
//
// Moved from Shell/Zoom.cs; this namespace and class line are the only changes. It lives with
// the render pipeline because the two share the zoom state and the gesture routing
// (_activeCanvas / _gestureCanvas) that decides which page a press landed on.
//
// Window members referenced bare here resolve through PdfViewer.Bridge.cs.
public partial class PdfViewer
{
private readonly WheelPageFlipGate _wheelPageFlipGate = new();
// ============================================================
// Zoom
// ============================================================
// internal: PdfViewer's XAML binds this and forwards to it.
internal void PagePreview_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
// #209: match the standard Windows/browser gesture and reuse the same path as a
// physical tilt wheel. Wheel-down moves right; wheel-up moves left.
if (Keyboard.Modifiers == ModifierKeys.Shift)
{
e.Handled = true;
ScrollHorizontalExt(-e.Delta);
return;
}
if (Keyboard.Modifiers == ModifierKeys.Control)
{
e.Handled = true;
if (_viewMode == ViewMode.Grid) { GridZoomStep(e.Delta < 0); return; }
// Capture cursor position and scroll offsets BEFORE zoom changes so we can
// compute the new offsets that keep the point under the cursor stationary.
Point cursorInViewport = e.GetPosition(PagePreviewPanel);
double oldZoom = _zoomLevel;
double oldHOff = PagePreviewPanel.HorizontalOffset;
double oldVOff = PagePreviewPanel.VerticalOffset;
// Smooth wheel zoom. Two parts:
// 1) A multiplicative step - every notch changes the zoom by the same RATIO. The
// old additive ZoomStep was a ~50% jump when zoomed out and barely visible when
// zoomed in. The exponent scales with e.Delta, so a precision touchpad's small
// frequent deltas produce proportionally small ratios (a continuous glide).
// 2) A lite apply - only the ScaleTransform moves during the gesture (instant,
// flicker-free, same path as live window-resize); the expensive tile/link
// refresh and hi-res re-sharpen run ONCE when the wheel rests (settle timer)
// instead of on every notch, which is what made zooming feel steppy.
_fitMode = FitMode.None;
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax,
_zoomLevel * Math.Pow(WheelZoomFactor, e.Delta / 120.0)));
ApplyZoom(lite: true);
StartZoomSettleTimer();
// After layout settles, reposition the scroll so the cursor point stays fixed.
// Formula: newOffset = (oldOffset + cursorPos) * (newZoom / oldZoom) - cursorPos
double ratio = _zoomLevel / oldZoom;
double newHOff = (oldHOff + cursorInViewport.X) * ratio - cursorInViewport.X;
double newVOff = (oldVOff + cursorInViewport.Y) * ratio - cursorInViewport.Y;
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() =>
{
PagePreviewPanel.ScrollToHorizontalOffset(Math.Max(0, newHOff));
PagePreviewPanel.ScrollToVerticalOffset(Math.Max(0, newVOff));
}));
return;
}
// Regular scroll. Grid and Continuous are a single scroll over the WHOLE document, so the
// wheel must never be hijacked for page navigation there - it always scrolls. (Page-nav
// hijacking here was the old grid-refuses-to-scroll bug: right after a zoom/column change
// the extent can momentarily measure as zero and the nav fallback fired instead.)
if (_viewMode == ViewMode.Grid || _viewMode == ViewMode.Continuous)
{
ScrollWheel(e);
return;
}
// Single / Two-Page: a page often fits the viewport, so at the scroll boundary fall
// through to page navigation so the user can reach adjacent pages without the sidebar.
if (PagePreviewPanel.ScrollableHeight <= 0)
{
e.Handled = true;
if (_wheelPageFlipGate.TryConfirm(e.Delta, DateTime.UtcNow))
NavigatePageByWheel(e.Delta);
return;
}
bool atTop = PagePreviewPanel.VerticalOffset <= 0;
bool atBottom = PagePreviewPanel.VerticalOffset >= PagePreviewPanel.ScrollableHeight - 1;
if ((atTop && e.Delta > 0) || (atBottom && e.Delta < 0))
{
e.Handled = true;
if (_wheelPageFlipGate.TryConfirm(e.Delta, DateTime.UtcNow))
NavigatePageByWheel(e.Delta);
return;
}
_wheelPageFlipGate.NoteContentScroll(DateTime.UtcNow);
ScrollWheel(e);
}
// Zoom ratio per full wheel notch (e.Delta = 120) for Ctrl+scroll. 1.1 lands close to the
// old additive step at 100% zoom but stays a constant 10% everywhere on the range.
private const double WheelZoomFactor = 1.1;
// Wheel over the toolbar zoom dropdown: same multiplicative step as Ctrl+scroll, without
// the cursor anchoring (the cursor is on the toolbar, not the page). Handled is set so the
// ComboBox does not cycle its preset items under the wheel.
internal void ZoomBoxWheel(MouseWheelEventArgs e)
{
e.Handled = true;
if (_doc is null) return;
if (_viewMode == ViewMode.Grid) { GridZoomStep(e.Delta < 0); return; }
_fitMode = FitMode.None;
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax,
_zoomLevel * Math.Pow(WheelZoomFactor, e.Delta / 120.0)));
ApplyZoom(lite: true); // SyncZoomBox inside keeps the shown % live per notch
StartZoomSettleTimer();
}
// Debounced full zoom apply, shared by every Ctrl+scroll notch: while the wheel is moving
// only the lite ScaleTransform runs; once it rests for a beat, do the one full ApplyZoom
// (tile/link refresh, and the hi-res re-sharpen it queues) plus the status-bar update that
// SetZoom would have shown per notch.
private System.Windows.Threading.DispatcherTimer? _zoomSettleTimer;
private void StartZoomSettleTimer()
{
if (_zoomSettleTimer is null)
{
_zoomSettleTimer = new System.Windows.Threading.DispatcherTimer
{ Interval = TimeSpan.FromMilliseconds(200) };
_zoomSettleTimer.Tick += (_, _) =>
{
_zoomSettleTimer!.Stop();
if (_doc is null) return;
ApplyZoom();
if (_currentPage >= 0)
SetStatus(string.Format(Loc("Str_PageOf"), _currentPage + 1, _doc.PageCount) + $" - {DisplayZoomPct():F0}%");
};
}
_zoomSettleTimer.Stop();
_zoomSettleTimer.Start();
}
// The ScrollViewer default (3 lines = 48 DIP per wheel notch) feels slow on tall documents,
// so scroll WheelScrollFactor times that instead. e.Delta is +-120 per notch on a standard
// wheel (precision touchpads send smaller, more frequent deltas, which scale the same way).
// ScrollToVerticalOffset clamps to the valid range itself.
// internal: PageSelection.cs reuses it so the sidebar scrolls at the document's speed.
internal const double WheelScrollFactor = 3.0;
private void ScrollWheel(MouseWheelEventArgs e)
{
e.Handled = true;
PagePreviewPanel.ScrollToVerticalOffset(
PagePreviewPanel.VerticalOffset - e.Delta * (48.0 / 120.0) * WheelScrollFactor);
}
// #196: horizontal scroll fed from the window's WM_MOUSEHWHEEL hook (WPF surfaces no
// event for it). Same per-delta distance as the vertical wheel; positive = right.
internal void ScrollHorizontalExt(int delta)
{
if (_doc is null || PagePreviewPanel.Visibility != Visibility.Visible) return;
PagePreviewPanel.ScrollToHorizontalOffset(
PagePreviewPanel.HorizontalOffset + delta * (48.0 / 120.0) * WheelScrollFactor);
}
// Walks up the visual tree from the press's hit element to see if it landed on the scrollbar
// (thumb, track, or repeat buttons). Used to exempt scrollbar presses from pane pan/marquee/crop.
private static bool PressIsOnScrollBar(MouseButtonEventArgs e)
{
DependencyObject? d = e.OriginalSource as DependencyObject;
while (d is not null)
{
if (d is System.Windows.Controls.Primitives.ScrollBar) return true;
d = d is System.Windows.Media.Visual or System.Windows.Media.Media3D.Visual3D
? System.Windows.Media.VisualTreeHelper.GetParent(d)
: LogicalTreeHelper.GetParent(d);
}
return false;
}
internal void PagePreviewPanel_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
// A press that lands on the document scrollbar must reach the scrollbar itself (thumb drag,
// track paging). The pan/crop/marquee handling below otherwise claims the press first and sets
// e.Handled, so the thumb could never be grabbed. Let scrollbar presses fall through untouched.
if (PressIsOnScrollBar(e)) return;
bool spaceDown = Keyboard.IsKeyDown(Key.Space);
if (e.ChangedButton == MouseButton.Middle ||
(e.ChangedButton == MouseButton.Left && spaceDown))
{
_isPanning = true;
_panStart = e.GetPosition(PagePreviewPanel);
_panScrollH = PagePreviewPanel.HorizontalOffset;
_panScrollV = PagePreviewPanel.VerticalOffset;
PagePreviewPanel.CaptureMouse();
PagePreviewPanel.Cursor = Cursors.SizeAll;
e.Handled = true;
}
// Crop: allow starting the selection OUTSIDE the page - catch margin clicks, route them to the
// nearest page overlay, and clamp the start to the page edge so the crop rect stays on the page.
else if (e.ChangedButton == MouseButton.Left && !spaceDown
&& _currentTool == EditTool.Crop && _doc is not null)
{
Canvas? target = ResolveMarginOverlay(e);
if (target is not null && target.Width > 0 && target.Height > 0)
{
_activeCanvas = target;
// Pin the gesture surface/page so mouse-move/up resolve against this overlay
// (a margin crop start doesn't go through Canvas_MouseLeftButtonDown).
_gestureCanvas = target;
_gesturePage = target.Tag is int gt ? gt : _currentPage;
var p = e.GetPosition(target);
p.X = Math.Max(0, Math.Min(target.Width, p.X));
p.Y = Math.Max(0, Math.Min(target.Height, p.Y));
StartCropDraw(p);
e.Handled = true;
}
}
// Marquee select: start a selection rectangle in the margin so it can span onto the pages. Same
// routing as crop, but the start point is NOT clamped, so the box can begin off-page.
else if (e.ChangedButton == MouseButton.Left && !spaceDown
&& _currentTool == EditTool.Select && _doc is not null)
{
Canvas? target = ResolveMarginOverlay(e);
if (target is not null && target.Width > 0 && target.Height > 0)
{
StartMarqueeDraw(target, e.GetPosition(target));
e.Handled = true;
}
}
}
// Resolves which page overlay a margin (off-page) click attaches to, or null when the click is
// actually on a page (left to that page's own surface). Shared by off-page crop and marquee starts.
private Canvas? ResolveMarginOverlay(MouseButtonEventArgs e)
{
if (_viewMode == ViewMode.Continuous)
{
if (e.OriginalSource is DependencyObject osc && IsWithinPageOverlay(osc)) return null;
int pg = _currentPage;
if (pg < 0 || !_continuousCanvases.ContainsKey(pg))
pg = NearestContinuousPage(e.GetPosition(_continuousPanel).Y);
return pg >= 0 && _continuousCanvases.TryGetValue(pg, out var c) ? c : null;
}
bool onPrimary = e.OriginalSource is DependencyObject oss && IsDescendantOf(oss, _annotationCanvas);
bool onTile = e.OriginalSource is DependencyObject ost && IsWithinPageOverlay(ost);
return (!onPrimary && !onTile) ? _annotationCanvas : null;
}
// Begins a marquee anchored to refCanvas at posInRef (that page's coords, possibly off-page and
// un-clamped). The box draws on the cross-page MarqueeLayer; the existing move/up handlers finish it.
private void StartMarqueeDraw(Canvas refCanvas, Point posInRef)
{
_activeCanvas = refCanvas;
_gestureCanvas = refCanvas;
_gesturePage = refCanvas.Tag is int gt ? gt : _currentPage;
ClearSelection();
ClearTextSelection();
_isSelecting = true;
_selectStart = posInRef;
_selectRect = new Rectangle
{
Fill = AccentBrush(40),
Stroke = AccentBrush(150),
StrokeThickness = 1,
Width = 0, Height = 0,
IsHitTestVisible = false
};
MarqueeLayer.Children.Add(_selectRect);
UpdateMarquee(posInRef, posInRef);
refCanvas.CaptureMouse();
}
// Begin a crop selection on the active overlay at pos (render-dim coords).
private void StartCropDraw(Point pos)
{
_cropPageIndex = _activeCanvas.Tag is int cpi ? cpi : (_viewMode == ViewMode.Grid ? 0 : _currentPage);
ClearSelection();
_isDrawing = true;
_drawStart = pos;
// Draw the NEW box as a separate rect; the existing box, handles, and bar stay put until this
// draw is committed on mouse-up (so a mouse-down never wipes the current box or bar).
var cropDrawRect = new Rectangle
{
Stroke = Brushes.White,
StrokeThickness = 1.5,
StrokeDashArray = [5, 3],
Fill = AccentBrush(55),
Width = 0,
Height = 0,
IsHitTestVisible = false,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{ Color = Colors.Black, ShadowDepth = 0, BlurRadius = 3, Opacity = 0.7 },
};
Canvas.SetLeft(cropDrawRect, pos.X);
Canvas.SetTop(cropDrawRect, pos.Y);
Panel.SetZIndex(cropDrawRect, 2);
_activeCanvas.Children.Add(cropDrawRect);
_activePreview = cropDrawRect;
_activeCanvas.CaptureMouse();
}
private bool IsWithinPageOverlay(DependencyObject node)
{
var cur = node;
while (cur != null)
{
if (cur is Canvas c && _continuousCanvases.ContainsValue(c)) return true;
cur = VisualTreeHelper.GetParent(cur);
}
return false;
}
internal void PagePreviewPanel_PreviewMouseMove(object sender, MouseEventArgs e)
{
if (!_isPanning) return;
var pos = e.GetPosition(PagePreviewPanel);
PagePreviewPanel.ScrollToHorizontalOffset(_panScrollH - (pos.X - _panStart.X));
PagePreviewPanel.ScrollToVerticalOffset(_panScrollV - (pos.Y - _panStart.Y));
e.Handled = true;
}
internal void PagePreviewPanel_PreviewMouseUp(object sender, MouseButtonEventArgs e)
{
if (!_isPanning) return;
if (e.ChangedButton != MouseButton.Middle && e.ChangedButton != MouseButton.Left) return;
_isPanning = false;
PagePreviewPanel.ReleaseMouseCapture();
PagePreviewPanel.Cursor = _spaceHeld ? Cursors.Hand : Cursors.Arrow;
e.Handled = true;
}
}
}