Ben, 2026-08-28: four changes, then build it, sign it, and make it deployable
through Intune without alerts.
1. The row of icons is a fifth shorter. Every toolbar label mode comes down
together so the proportions between them are unchanged: 34 -> 27 for a
caption beside the icon, 52/56 -> 42/45 for a caption under it, with the
padding reduced to match. The row measures 48px on screen where it was 60.
2. Scan sits in the main bar. It is a solid white button on the band, to the
left of the window buttons, so a person can pull pages off a copier whatever
the toolbar is showing. Behind it, Services/ScanService.cs talks to the
copier through WIA - the imaging service that ships with Windows, reached
late-bound so a machine without it fails at a check instead of at load - and
Features/Scan/ScanDialog picks the copier, the feed, the colour, the detail
and the paper. Pages come back as images and take the same route into a
document as an imported photograph. "Make the words searchable" runs the
text recognition the application already carries.
3. The official MMD lockup, in white, because the band is red now.
4. The MMD design system locked on 2026-08-28: ONE merged top bar carrying the
mark, the name, the tabs and the tools; 4px corners; 24px window buttons;
the bevel on title bars only. Colours are NOT adjusted - the Red Plate
silver and ink set already in Themes/MMD.xaml is untouched.
Also fixed, both found by looking at the running application at 800x600:
- The default 1000x700 window does not fit an 800x600 plant screen, and a
window taller than the screen puts its own title bar - and therefore Scan,
the window buttons and the tabs - above the top edge where nobody can reach
them. MainWindow now starts maximised when the screen cannot hold the
default.
- The close button was invisible: CaptionCloseBrush defaults to the danger
red, which on a red band is red on red. The window buttons are white now,
and close inverts to a white plate with a red cross on hover.
Built and run on mmd-win-test-01 at 800x600 through keyboard and mouse only.
Signed with the internal MMD code-signing certificate and timestamped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
878 lines
57 KiB
C#
878 lines
57 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 : Window
|
|
{
|
|
private PdfDocument? _doc { get => ActiveViewer?.DocumentRef; set { if (ActiveViewer != null) ActiveViewer.DocumentRef = value; } }
|
|
private string? _currentFile { get => ActiveViewer?.CurrentFileRef; set { if (ActiveViewer != null) ActiveViewer.CurrentFileRef = value; } }
|
|
private string? _originalFile { get => ActiveViewer?.OriginalFileRef; set { if (ActiveViewer != null) ActiveViewer.OriginalFileRef = value; } }
|
|
private Point _dragStartPoint;
|
|
|
|
// Zoom
|
|
private double _zoomLevel { get => _view.ZoomLevel; set => _view.ZoomLevel = value; }
|
|
private double _lastRenderZoom { get => _view.LastRenderZoom; set => _view.LastRenderZoom = value; }
|
|
private int _renderedPrimaryPage { get => _view.RenderedPrimaryPage; set => _view.RenderedPrimaryPage = value; }
|
|
// internal: the viewer control aliases these as its own consts (PdfViewer.Bridge.cs).
|
|
// They stay declared here because MainWindow.xaml.cs and KeyboardShortcuts.cs read them
|
|
// too, and a const costs nothing to alias but would drift if duplicated.
|
|
internal const double ZoomMin = 0.05;
|
|
internal const double ZoomMax = 5.0;
|
|
internal const double ZoomStep = 0.15;
|
|
private FitMode _fitMode { get => _view.Fit; set => _view.Fit = value; }
|
|
private System.Windows.Threading.DispatcherTimer? _rerenderTimer { get => _view.RerenderTimer; set => _view.RerenderTimer = value; }
|
|
private System.Threading.CancellationTokenSource? _secondaryRenderCts { get => _view.SecondaryRenderCts; set => _view.SecondaryRenderCts = value; }
|
|
// ── Split pane ─────────────────────────────────────────────────────────────────────
|
|
// The per-view state lives in one object (Models/ViewerState.cs) so a second pane can have
|
|
// its own, and the VIEWER owns it, not the window. The window reads it back through here,
|
|
// so the forwarding properties below (and the ~500 call sites behind them) stay as they
|
|
// are. See BACKLOG.md "Split pane (F10)".
|
|
// ActiveViewer, NOT Viewer: with two panes this has to be the FOCUSED pane's state, or every
|
|
// forwarding property behind it (view mode, zoom, page maps - ~500 call sites) would keep
|
|
// reporting pane A no matter which pane the user is working in.
|
|
private ViewerState _view => ActiveViewer.State;
|
|
|
|
private ViewMode _viewMode { get => _view.Mode; set => _view.Mode = value; }
|
|
private StackPanel _continuousPanel { get => _view.ContinuousPanel; set => _view.ContinuousPanel = value; }
|
|
private System.Threading.CancellationTokenSource? _continuousRenderCts { get => _view.ContinuousRenderCts; set => _view.ContinuousRenderCts = value; }
|
|
private System.Threading.CancellationTokenSource? _continuousSharpenCts { get => _view.ContinuousSharpenCts; set => _view.ContinuousSharpenCts = value; }
|
|
private HashSet<int> _continuousSharpPages => _view.ContinuousSharpPages;
|
|
private int _continuousSharpW { get => _view.ContinuousSharpW; set => _view.ContinuousSharpW = value; }
|
|
private List<double> _continuousTops => _view.ContinuousTops;
|
|
private int _gridScrollToPage { get => _view.GridScrollToPage; set => _view.GridScrollToPage = value; }
|
|
private int _continuousScrollTarget { get => _view.ContinuousScrollTarget; set => _view.ContinuousScrollTarget = value; }
|
|
private double _continuousPageW { get => _view.ContinuousPageW; set => _view.ContinuousPageW = value; }
|
|
|
|
// Editing
|
|
private EditTool _currentTool
|
|
{
|
|
get => ActiveViewer?.CurrentToolRef ?? EditTool.Select;
|
|
set { if (ActiveViewer != null) ActiveViewer.CurrentToolRef = value; }
|
|
}
|
|
// Per-document state. Not readonly: tab switching swaps these by reference so each
|
|
// open document keeps its own annotations, undo history, form values, and search hits.
|
|
private Dictionary<int, List<PageAnnotation>> _annotations { get => ActiveViewer.AnnotationsRef; set => ActiveViewer.AnnotationsRef = value; }
|
|
private Dictionary<int, (int w, int h)> _renderDims { get => ActiveViewer.RenderDimsRef; set => ActiveViewer.RenderDimsRef = value; }
|
|
// Stores the PDF /Rotate value for each page. The temp file used by Docnet has
|
|
// rotation stripped to zero so FPDF_GetPageWidth/Height returns MediaBox dims and
|
|
// the content isn't clipped; RotateBitmap is applied at render time instead.
|
|
private Dictionary<int, int> _pageRotations { get => ActiveViewer.PageRotationsRef; set => ActiveViewer.PageRotationsRef = value; }
|
|
|
|
// Form filling - text/check keyed by widget object number; radio keyed by field name
|
|
private Dictionary<int, string> _formTextValues { get => ActiveViewer.FormTextValuesRef; set => ActiveViewer.FormTextValuesRef = value; }
|
|
private Dictionary<int, bool> _formCheckValues { get => ActiveViewer.FormCheckValuesRef; set => ActiveViewer.FormCheckValuesRef = value; }
|
|
private Dictionary<string, string> _formRadioValues { get => ActiveViewer.FormRadioValuesRef; set => ActiveViewer.FormRadioValuesRef = value; }
|
|
private Dictionary<int, double> _formFontSizes { get => ActiveViewer.FormFontSizesRef; set => ActiveViewer.FormFontSizesRef = value; }
|
|
// Floating font-size stepper shown while a form text field is focused.
|
|
private Border? _formSizeBar { get => ActiveViewer.FormSizeBarRef; set => ActiveViewer.FormSizeBarRef = value; }
|
|
private TextBox? _activeFormTb { get => ActiveViewer.ActiveFormTbRef; set => ActiveViewer.ActiveFormTbRef = value; }
|
|
private int _activeFormObj { get => ActiveViewer.ActiveFormObjRef; set => ActiveViewer.ActiveFormObjRef = value; }
|
|
private double _activeFormScale { get => ActiveViewer.ActiveFormScaleRef; set => ActiveViewer.ActiveFormScaleRef = value; }
|
|
private const string FormOverlayTag = "FormFieldOverlay";
|
|
|
|
// Undo stack - each entry is either an annotation removal or a full document snapshot.
|
|
// AnnotationGroup removes a specific set of annotations in one step (a text edit = cover + text).
|
|
// UndoKind / UndoEntry live in Models/UndoTypes.cs, not here - the undo stack is pushed from
|
|
// Annotations.cs and TextEditing.cs, which live in the viewer control.
|
|
private Stack<UndoEntry> _undoStack { get => ActiveViewer.UndoStackRef; set => ActiveViewer.UndoStackRef = value; }
|
|
// Redo: inverses captured by Undo_Click land here; any NEW edit clears it (PushUndo).
|
|
// Swapped per tab alongside _undoStack (Tabs.cs) so redo can never replay another document.
|
|
private Stack<UndoEntry> _redoStack { get => ActiveViewer.RedoStackRef; set => ActiveViewer.RedoStackRef = value; }
|
|
// Jump history for Alt+Left / Alt+Right and the mouse back/forward buttons. Page-granular,
|
|
// recorded at the long-jump sites (bookmark, internal link, jump box, Home/End); cleared on
|
|
// document open and tab switch.
|
|
private Stack<int> _navBack => ActiveViewer.NavBackRef;
|
|
private Stack<int> _navForward => ActiveViewer.NavForwardRef;
|
|
private bool _isDrawing { get => ActiveViewer.IsDrawingRef; set => ActiveViewer.IsDrawingRef = value; }
|
|
private Point _drawStart { get => ActiveViewer.DrawStartRef; set => ActiveViewer.DrawStartRef = value; }
|
|
private UIElement? _activePreview { get => ActiveViewer.ActivePreviewRef; set => ActiveViewer.ActivePreviewRef = value; }
|
|
private InkAnnotation? _activeInk { get => ActiveViewer.ActiveInkRef; set => ActiveViewer.ActiveInkRef = value; }
|
|
private TextBox? _activeTextBox { get => ActiveViewer.ActiveTextBoxRef; set => ActiveViewer.ActiveTextBoxRef = value; }
|
|
private PageAnnotation? _selectedAnnotation { get => ActiveViewer.SelectedAnnotationRef; set => ActiveViewer.SelectedAnnotationRef = value; }
|
|
private Border? _selectionBorder { get => ActiveViewer.SelectionBorderRef; set => ActiveViewer.SelectionBorderRef = value; }
|
|
// Shift+click multi-selection (Select tool): extra annotations selected alongside the
|
|
// primary _selectedAnnotation. Each gets its own outline. Delete removes the whole set.
|
|
private List<PageAnnotation> _selectedSet => ActiveViewer.SelectedSetRef;
|
|
private List<Border> _selectionOutlines => ActiveViewer.SelectionOutlinesRef;
|
|
|
|
// Draw/Highlight settings
|
|
private Color _drawColor { get => ActiveViewer.DrawColorRef; set => ActiveViewer.DrawColorRef = value; }
|
|
private double _drawWidth { get => ActiveViewer.DrawWidthRef; set => ActiveViewer.DrawWidthRef = value; }
|
|
private byte _drawOpacity { get => ActiveViewer.DrawOpacityRef; set => ActiveViewer.DrawOpacityRef = value; }
|
|
private bool _lineLevel { get => ActiveViewer.LineLevelRef; set => ActiveViewer.LineLevelRef = value; }
|
|
private bool _highlightErase { get => ActiveViewer.HighlightEraseRef; set => ActiveViewer.HighlightEraseRef = value; }
|
|
private bool _drawErase { get => ActiveViewer.DrawEraseRef; set => ActiveViewer.DrawEraseRef = value; }
|
|
private Color _highlightColor { get => ActiveViewer.HighlightColorRef; set => ActiveViewer.HighlightColorRef = value; }
|
|
// Strikethrough / underline lines: opaque red by default.
|
|
private Color _lineAnnotColor { get => ActiveViewer.LineAnnotColorRef; set => ActiveViewer.LineAnnotColorRef = value; }
|
|
private Border? _drawSettingsBar;
|
|
|
|
// Text (typewriter) tool settings
|
|
private double _textFontSize { get => ActiveViewer.TextFontSizeRef; set => ActiveViewer.TextFontSizeRef = value; }
|
|
// Current text-tool typeface and style (mirrors the text bar; carried onto each new/edited box).
|
|
private string _textFontName { get => ActiveViewer.TextFontNameRef; set => ActiveViewer.TextFontNameRef = value; }
|
|
private bool _textBold { get => ActiveViewer.TextBoldRef; set => ActiveViewer.TextBoldRef = value; }
|
|
private bool _textItalic { get => ActiveViewer.TextItalicRef; set => ActiveViewer.TextItalicRef = value; }
|
|
private bool _textStrike { get => ActiveViewer.TextStrikeRef; set => ActiveViewer.TextStrikeRef = value; }
|
|
private bool _textUnderline { get => ActiveViewer.TextUnderlineRef; set => ActiveViewer.TextUnderlineRef = value; }
|
|
// Installed font-family names, sorted, computed once (the text bar rebuilds often).
|
|
private static List<string>? _systemFontNamesCache;
|
|
internal static List<string> SystemFontNames => _systemFontNamesCache ??=
|
|
[.. System.Windows.Media.Fonts.SystemFontFamilies
|
|
.Select(f => f.Source).Where(s => !string.IsNullOrWhiteSpace(s))
|
|
.Distinct().OrderBy(s => s, StringComparer.OrdinalIgnoreCase)];
|
|
// WPF renders a given point size visually ~25% larger than the source PDF text, so scale the
|
|
// detected size down when seeding an existing-text edit. The user can still fine-tune after.
|
|
private const double EditTextSizeCorrection = 0.8;
|
|
private bool _suppressSizeSync; // guards the slider<->size-box two-way binding from feedback loops
|
|
private TextAnnotation? _reeditOriginal { get => ActiveViewer.ReeditOriginalRef; set => ActiveViewer.ReeditOriginalRef = value; }
|
|
// The opaque cover dropped when starting an existing-text edit, awaiting its paired text commit.
|
|
// Held so the text commit can group both into one undo, and so cancel/empty removes the cover.
|
|
private CoverAnnotation? _pendingCover { get => ActiveViewer.PendingCoverRef; set => ActiveViewer.PendingCoverRef = value; }
|
|
// Dirty state captured before the cover was dropped, so undoing the grouped edit restores it.
|
|
private bool _pendingEditWasDirty { get => ActiveViewer.PendingEditWasDirtyRef; set => ActiveViewer.PendingEditWasDirtyRef = value; }
|
|
private Color _textColor { get => ActiveViewer.TextColorRef; set => ActiveViewer.TextColorRef = value; }
|
|
private byte _textOpacity { get => ActiveViewer.TextOpacityRef; set => ActiveViewer.TextOpacityRef = value; }
|
|
private Color _textFillColor { get => ActiveViewer.TextFillColorRef; set => ActiveViewer.TextFillColorRef = value; }
|
|
private const double TextBoxDefaultWidth = 220; // canvas-unit width of a freshly placed text box
|
|
private Border? _textSettingsBar { get => ActiveViewer.TextSettingsBarRef; set => ActiveViewer.TextSettingsBarRef = value; }
|
|
|
|
// Signature / image resize
|
|
private bool _isResizingSig { get => ActiveViewer.IsResizingSigRef; set => ActiveViewer.IsResizingSigRef = value; }
|
|
private Point _resizeSigStart { get => ActiveViewer.ResizeSigStartRef; set => ActiveViewer.ResizeSigStartRef = value; }
|
|
private double _resizeSigStartScale { get => ActiveViewer.ResizeSigStartScaleRef; set => ActiveViewer.ResizeSigStartScaleRef = value; }
|
|
private PlacedAnnotation? _resizeSigAnnot { get => ActiveViewer.ResizeSigAnnotRef; set => ActiveViewer.ResizeSigAnnotRef = value; }
|
|
private TextAnnotation? _resizeTextAnnot { get => ActiveViewer.ResizeTextAnnotRef; set => ActiveViewer.ResizeTextAnnotRef = value; }
|
|
private HighlightAnnotation? _resizeHlAnnot { get => ActiveViewer.ResizeHlAnnotRef; set => ActiveViewer.ResizeHlAnnotRef = value; }
|
|
private InkAnnotation? _resizeInkAnnot { get => ActiveViewer.ResizeInkAnnotRef; set => ActiveViewer.ResizeInkAnnotRef = value; }
|
|
private List<Point>? _resizeInkOrigPoints { get => ActiveViewer.ResizeInkOrigPointsRef; set => ActiveViewer.ResizeInkOrigPointsRef = value; }
|
|
private Rect _resizeInkOrigBounds { get => ActiveViewer.ResizeInkOrigBoundsRef; set => ActiveViewer.ResizeInkOrigBoundsRef = value; }
|
|
private List<Rectangle> _resizeHandles => ActiveViewer.ResizeHandlesRef;
|
|
private string _resizeCorner { get => ActiveViewer.ResizeCornerRef; set => ActiveViewer.ResizeCornerRef = value; }
|
|
private Point _resizeAnchor { get => ActiveViewer.ResizeAnchorRef; set => ActiveViewer.ResizeAnchorRef = value; }
|
|
|
|
// Mid-edit resize handles: 4 corners shown around the live editing TextBox so the user can
|
|
// resize the box (and continue typing) without committing and re-selecting first.
|
|
private List<Rectangle> _textEditHandles => ActiveViewer.TextEditHandlesRef;
|
|
private bool _draggingTextEditHandle { get => ActiveViewer.DraggingTextEditHandleRef; set => ActiveViewer.DraggingTextEditHandleRef = value; }
|
|
private string _tehCorner { get => ActiveViewer.TehCornerRef; set => ActiveViewer.TehCornerRef = value; }
|
|
private Point _tehAnchor { get => ActiveViewer.TehAnchorRef; set => ActiveViewer.TehAnchorRef = value; }
|
|
private TextBox? _tehBox { get => ActiveViewer.TehBoxRef; set => ActiveViewer.TehBoxRef = value; }
|
|
|
|
// Placed annotation drag-to-move
|
|
private bool _isDraggingAnnot { get => ActiveViewer.IsDraggingAnnotRef; set => ActiveViewer.IsDraggingAnnotRef = value; }
|
|
private Point _dragAnnotStart { get => ActiveViewer.DragAnnotStartRef; set => ActiveViewer.DragAnnotStartRef = value; }
|
|
|
|
// Middle-mouse / spacebar pan
|
|
private bool _spaceHeld;
|
|
private Point _dragAnnotOrigPos { get => ActiveViewer.DragAnnotOrigPosRef; set => ActiveViewer.DragAnnotOrigPosRef = value; }
|
|
private PageAnnotation? _dragAnnot { get => ActiveViewer.DragAnnotRef; set => ActiveViewer.DragAnnotRef = value; }
|
|
|
|
// Crop tool
|
|
private Rect _cropCanvasRect { get => ActiveViewer.CropCanvasRectRef; set => ActiveViewer.CropCanvasRectRef = value; }
|
|
private Rectangle? _cropPreviewRect { get => ActiveViewer.CropPreviewRectRef; set => ActiveViewer.CropPreviewRectRef = value; }
|
|
private Rectangle? _cropPreviewRectBorder { get => ActiveViewer.CropPreviewRectBorderRef; set => ActiveViewer.CropPreviewRectBorderRef = value; }
|
|
private List<System.Windows.Shapes.Path> _cropBrackets => ActiveViewer.CropBracketsRef;
|
|
private Border? _cropConfirmBar { get => ActiveViewer.CropConfirmBarRef; set => ActiveViewer.CropConfirmBarRef = value; }
|
|
private readonly Button _toolCropBtn = null!;
|
|
private readonly Button _toolRotateBtn = null!;
|
|
private List<Rectangle> _cropHandles => ActiveViewer.CropHandlesRef;
|
|
private string? _activeCropHandleTag { get => ActiveViewer.ActiveCropHandleTagRef; set => ActiveViewer.ActiveCropHandleTagRef = value; }
|
|
private Point _cropHandleDragStart { get => ActiveViewer.CropHandleDragStartRef; set => ActiveViewer.CropHandleDragStartRef = value; }
|
|
private Rect _cropRectAtHandleDrag { get => ActiveViewer.CropRectAtHandleDragRef; set => ActiveViewer.CropRectAtHandleDragRef = value; }
|
|
private TextBox? _cropXBox { get => ActiveViewer.CropXBoxRef; set => ActiveViewer.CropXBoxRef = value; }
|
|
private TextBox? _cropYBox { get => ActiveViewer.CropYBoxRef; set => ActiveViewer.CropYBoxRef = value; }
|
|
private TextBox? _cropWBox { get => ActiveViewer.CropWBoxRef; set => ActiveViewer.CropWBoxRef = value; }
|
|
private TextBox? _cropHBox { get => ActiveViewer.CropHBoxRef; set => ActiveViewer.CropHBoxRef = value; }
|
|
private TextBox? _cropRangeBox { get => ActiveViewer.CropRangeBoxRef; set => ActiveViewer.CropRangeBoxRef = value; }
|
|
private string _cropUnit { get => ActiveViewer.CropUnitRef; set => ActiveViewer.CropUnitRef = value; }
|
|
private bool _updatingCropInputs { get => ActiveViewer.UpdatingCropInputsRef; set => ActiveViewer.UpdatingCropInputsRef = value; }
|
|
|
|
// PDF link overlays (rendered on top of the annotation canvas)
|
|
|
|
// Sidebar + multi-page view
|
|
private bool _sidebarCollapsed;
|
|
private bool _sidebarRight; // false = sidebar on the left (default), true = on the right
|
|
private bool _sidebarShowingOutlines;
|
|
private bool _outlinesFitted = false;
|
|
private double _savedPagesWidth = 180;
|
|
private double _savedOutlinesWidth = 300;
|
|
private readonly Button _sidebarToggleBtn = null!;
|
|
private readonly Border _sidebarBorder = null!;
|
|
private ColumnDefinition _sidebarCol = null!; // sized column (left or right per _sidebarRight)
|
|
private WrapPanel _pageContentPanel { get => _view.PageContentPanel; set => _view.PageContentPanel = value; }
|
|
|
|
// Text selection
|
|
private Rectangle? _pairedCoverOutline { get => ActiveViewer.PairedCoverOutlineRef; set => ActiveViewer.PairedCoverOutlineRef = value; }
|
|
private Rectangle? _reeditCoverOutline { get => ActiveViewer.ReeditCoverOutlineRef; set => ActiveViewer.ReeditCoverOutlineRef = value; }
|
|
private string? _selectedText { get => ActiveViewer.SelectedTextRef; set => ActiveViewer.SelectedTextRef = value; }
|
|
|
|
// Search
|
|
private Border? _searchBar;
|
|
private TextBox? _searchBox;
|
|
private TextBlock? _searchStatus;
|
|
private readonly List<Rect> _searchHighlights = [];
|
|
|
|
// Signatures
|
|
private readonly SignatureStore _signatureStore = new();
|
|
private SavedSignature? _pendingSignature { get => ActiveViewer.PendingSignatureRef; set => ActiveViewer.PendingSignatureRef = value; }
|
|
private Border? _signaturePopup;
|
|
// Guided AcroForm signing: "pick once, reuse" - the chosen signature/initials are remembered
|
|
// and dropped into every matching field. _pendingSignField, when set, routes the next pick from
|
|
// the popup into that field instead of free placement.
|
|
private SavedSignature? _activeSignatureChoice;
|
|
private SavedSignature? _activeInitialsChoice;
|
|
private (bool Initials, int ObjNum, int Page, double X, double Y, double W, double H)? _pendingSignField;
|
|
// Form fields already signed, so re-clicking one offers change/remove instead of re-stamping.
|
|
private readonly Dictionary<int, SignatureAnnotation> _signedFields = [];
|
|
|
|
// Manual element refs. Tile-0's Image + overlay are built in code (BuildPrimaryTile) now that the
|
|
// primary page is no longer a hardcoded XAML singleton - both are reassignable.
|
|
private Canvas _annotationCanvas { get => _view.AnnotationCanvas; set => _view.AnnotationCanvas = value; }
|
|
private Image PageImage { get => _view.PageImage; set => _view.PageImage = value; }
|
|
// Active annotation surface. Single view: always _annotationCanvas. Continuous view:
|
|
// set on mouse-down to the clicked page's overlay. Shared handlers target this.
|
|
private Canvas _activeCanvas { get => _view.ActiveCanvas; set => _view.ActiveCanvas = value; }
|
|
// The page surface a pointer gesture started on, captured on mouse-down. Kept separate
|
|
// from _activeCanvas because RenderAllAnnotations reuses _activeCanvas as its render
|
|
// target; in Grid view tiles stream in asynchronously and each one re-points _activeCanvas
|
|
// mid-gesture, which previously committed annotations to the wrong page and broke
|
|
// select/delete. Mouse-move/up resolve the gesture page and surface from these instead.
|
|
private Canvas? _gestureCanvas { get => _view.GestureCanvas; set => _view.GestureCanvas = value; }
|
|
private int _gesturePage { get => _view.GesturePage; set => _view.GesturePage = value; }
|
|
// Per-page overlay canvases for Continuous view, keyed by page index.
|
|
private Dictionary<int, Canvas> _continuousCanvases => _view.ContinuousCanvases;
|
|
// Unified page -> overlay map covering EVERY rendered page, the primary included (unlike
|
|
// _continuousCanvases, which holds only secondary tiles and is driven by the tile-recycling
|
|
// machinery). This is the single source of truth the canvas accessors read from, so the
|
|
// primary stops being a special case in routing/search/links.
|
|
private Dictionary<int, Canvas> _pages => _view.Pages;
|
|
private Grid _pageContentGrid { get => _view.PageContentGrid; set => _view.PageContentGrid = value; }
|
|
// The page this view is showing. See ViewerState.CurrentPage for why this exists at all
|
|
// (reading the sidebar's SelectedIndex as the current page cannot survive a second pane).
|
|
// The setter drives the sidebar, which is what actually triggers navigation today via
|
|
// PageList_SelectionChanged; the handler mirrors the value
|
|
// straight back, so the two never disagree.
|
|
private int _currentPage
|
|
{
|
|
get => _view.CurrentPage;
|
|
set { _view.CurrentPage = value; if (PageList.SelectedIndex != value) PageList.SelectedIndex = value; }
|
|
}
|
|
private readonly Button _toolSelectBtn = null!;
|
|
private readonly Button _toolTextBtn = null!;
|
|
private readonly Button _toolHighlightBtn = null!;
|
|
private readonly Button _toolUnderlineBtn = null!;
|
|
private readonly Button _toolDrawBtn = null!;
|
|
private readonly Button _toolShapeBtn = null!;
|
|
private readonly Button _toolSignatureBtn = null!;
|
|
private readonly Button _toolImageBtn = null!;
|
|
private readonly Button _saveAsBtnRef = null!;
|
|
private readonly Button _closeFileBtnRef = null!;
|
|
private readonly ComboBox _zoomBox = null!;
|
|
private readonly Grid _portableBadge = null!;
|
|
private readonly TextBox _pageJumpBox = null!;
|
|
private readonly TextBlock _pageTotalLabel = null!;
|
|
|
|
// Dirty / unsaved-change tracking
|
|
private bool _isDirty { get => ActiveViewer.IsDirtyRef; set => ActiveViewer.IsDirtyRef = value; }
|
|
|
|
// Whole-document search results now live on SearchController (Features/Search); Tabs.cs
|
|
// parks and restores them per tab through its AllSearchRects/ResultPages/PageCursor.
|
|
|
|
public MainWindow()
|
|
{
|
|
InitializeComponent();
|
|
var v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
|
|
if (v != null) VersionLabel.Text = $"v{v.Major}.{v.Minor}.{v.Build}";
|
|
// Accept dropped files/folders/archives anywhere on the window (not just the empty drop zone),
|
|
// so dropping onto an open document works too. The empty-state DropZone marks its own drop
|
|
// handled, so a drop there isn't processed twice.
|
|
AllowDrop = true;
|
|
DragOver += DropZone_DragOver;
|
|
Drop += DropZone_Drop;
|
|
// Safety net: if the window loses focus mid-drag/resize (e.g. Alt-Tab away to type elsewhere),
|
|
// the mouse-up can be lost and the dragged annotation would stay glued to the cursor with the
|
|
// canvas still holding mouse capture. End any in-progress gesture on deactivate so control is
|
|
// restored the moment the user comes back.
|
|
Deactivated += (_, _) => { if (_isDraggingAnnot || _isResizingSig) FinishStuckGesture(); };
|
|
// These three live inside the PdfViewer control, and a UserControl is its own
|
|
// namescope - FindName would return NULL SILENTLY rather than throw, so the failure
|
|
// would surface much later as an unrelated NullReference. Take them off the control
|
|
// directly; they land in ViewerState through the forwarding properties.
|
|
// Both panes get an Owner and pane A becomes the active one (Shell/SplitPane.cs).
|
|
// Replaces the single `Viewer.Owner = this` - ViewerB exists from startup, collapsed,
|
|
// so its bridge would NullReference the moment anything touched it otherwise.
|
|
InitSplitPanes();
|
|
WirePageListEdgeFades(); // sidebar page-list edge fades (SidebarLayout.cs)
|
|
HookExternalLangReload(); // #211: --lang-file live reload rebuilds code-built captions
|
|
_pageContentGrid = ActiveViewer.PageGrid;
|
|
_pageContentPanel = ActiveViewer.PageHost;
|
|
_continuousPanel = ActiveViewer.ContinuousHost;
|
|
_toolSelectBtn = (Button)FindName("ToolSelectBtn")!;
|
|
_toolTextBtn = (Button)FindName("ToolTextBtn")!;
|
|
_toolHighlightBtn = (Button)FindName("ToolHighlightBtn")!;
|
|
_toolUnderlineBtn = (Button)FindName("ToolUnderlineBtn")!;
|
|
_toolDrawBtn = (Button)FindName("ToolDrawBtn")!;
|
|
_toolShapeBtn = (Button)FindName("ToolShapeBtn")!;
|
|
_toolSignatureBtn = (Button)FindName("ToolSignatureBtn")!;
|
|
_toolImageBtn = (Button)FindName("ToolImageBtn")!;
|
|
_toolCropBtn = (Button)FindName("ToolCropBtn")!;
|
|
_toolRotateBtn = (Button)FindName("ToolRotateBtn")!;
|
|
_sidebarToggleBtn = (Button)FindName("SidebarToggleBtn")!;
|
|
_sidebarBorder = (Border)FindName("SidebarBorder")!;
|
|
_sidebarCol = (ColumnDefinition)FindName("SidebarCol")!;
|
|
// BuildPrimaryTile + _activeCanvas were here. Both panes now do it for themselves in
|
|
// InitSplitPanes above (PdfViewer.InitTiles) - calling it here as well would build pane
|
|
// A a SECOND tile.
|
|
_saveAsBtnRef = (Button)FindName("SaveAsBtn")!;
|
|
_closeFileBtnRef = (Button)FindName("CloseFileBtn")!;
|
|
_zoomBox = (ComboBox)FindName("ZoomBox")!;
|
|
// Read-only editable combo: hide its text-selection highlight so the displayed % never
|
|
// looks like selected text after a pick.
|
|
_zoomBox.Loaded += (_, _) =>
|
|
{
|
|
if (_zoomBox.Template?.FindName("PART_EditableTextBox", _zoomBox) is TextBox etb)
|
|
etb.SelectionBrush = System.Windows.Media.Brushes.Transparent;
|
|
};
|
|
_portableBadge = (Grid)FindName("PortableBadge")!;
|
|
_pageJumpBox = (TextBox)FindName("PageJumpBox")!;
|
|
_pageTotalLabel = (TextBlock)FindName("PageTotalLabel")!;
|
|
// Both panes: each tracks the page under its own viewport. Pane B was never wired, so
|
|
// its page counter, jump box and sidebar selection never moved as it scrolled.
|
|
Viewer.WireScrollChanged(); // handler moved into the control with the render pipeline
|
|
ViewerB.WireScrollChanged();
|
|
PreviewMouseDown += NavHistory_PreviewMouseDown; // mouse back/forward buttons retrace jumps
|
|
MainContentGrid.SizeChanged += (_, _) => ScheduleFadeRefresh();
|
|
// The sidebar column resizes via the splitter / collapse; track its width so the tab-strip
|
|
// shadow gradient stays clipped to the document column.
|
|
if (FindName("SidebarOuterGrid") is FrameworkElement sidebarOuter)
|
|
sidebarOuter.SizeChanged += (_, _) => ScheduleFadeRefresh();
|
|
// The footer shadow tracks the document pane's actual position; re-anchor when it (or the
|
|
// tab strip, which shifts the document) changes size.
|
|
DocPaneBorder.SizeChanged += (_, _) => ScheduleFadeRefresh();
|
|
Viewer.SizeChanged += (_, _) => ScheduleFadeRefresh();
|
|
ViewerB.SizeChanged += (_, _) => ScheduleFadeRefresh();
|
|
TabStripBorder.SizeChanged += (_, _) => { ScheduleFadeRefresh(); ScheduleTabReflow(); };
|
|
// After a sidebar-splitter drag, snap fully closed if dragged too narrow, else save the width.
|
|
SidebarSplitter.PreviewMouseLeftButtonUp += (_, _) => OnSidebarResized();
|
|
// Grabbing the splitter while collapsed reveals the page list so it can be pulled open.
|
|
SidebarSplitter.PreviewMouseLeftButtonDown += (_, _) => { _sidebarWantClose = false; OnSidebarSplitterPress(); };
|
|
// Pull the splitter well past the minimum mid-drag to close (the column itself can't clip).
|
|
SidebarSplitter.PreviewMouseMove += OnSidebarSplitterMove;
|
|
// If a drag is interrupted (alt-tab, focus loss, taking a screenshot), finalize it so the
|
|
// sidebar can't get stuck half-open with its content hidden.
|
|
SidebarSplitter.LostMouseCapture += (_, _) => OnSidebarResized();
|
|
if (Enum.TryParse<ViewMode>(App.GetSetting("ViewMode"), out var savedVm))
|
|
_viewMode = savedVm;
|
|
InitToolbarStyle(); // two-axis toolbar appearance (+ migration from the old five-way key)
|
|
InitAppScale(); // AppScale.cs: restore the app-wide size (scroll the logo to change it)
|
|
// #135: document dark mode. Per PANE now; the saved setting seeds the primary pane
|
|
// (a split's second pane starts normal - inverting it is a per-pane choice).
|
|
Viewer.DocInvert = App.GetSetting("DocInvert") == "1";
|
|
BitmapHelpers.DocInvertImages = App.GetSetting("DocInvertImages") == "1"; // moon right-click opt-in
|
|
DocInvertBtn.Tag = Viewer.DocInvert ? "on" : null; // rail moon lit while active
|
|
// #146: the privacy toggle lives in the About window; init once - only its own
|
|
// handler changes it afterwards (change-guarded, so this init is a no-op there).
|
|
NoRecentCheck.IsChecked = App.GetSetting(App.NoRecentFilesSetting) == "1";
|
|
// Same deal for the link-confirm toggle beside it (default off).
|
|
LinkConfirmCheck.IsChecked = App.GetSetting(ConfirmLinksSetting) == "1";
|
|
if (string.Equals(App.GetSetting("SidebarSide"), "Right", StringComparison.OrdinalIgnoreCase))
|
|
_sidebarRight = true;
|
|
RestoreToolSettings(); // Draw + Text tool styles carry across sessions
|
|
Loaded += (_, _) => AdjustZoomBoxWidth(); // fit the zoom box to the longest localized term
|
|
IndexToolbarButtons();
|
|
OutlineTree.SelectedItemChanged += OutlineTree_SelectedItemChanged;
|
|
LoadSignatures();
|
|
BuildContextMenu();
|
|
SetTool(EditTool.Select);
|
|
ApplyGrainTexture();
|
|
ApplyToolNumberTooltips(); // append the 1-9 toolbar positions to the tool tooltips
|
|
BuildShortcutsOverlay(); // generate the shortcuts card from the single-source table (ShortcutsOverlay.cs)
|
|
SourceInitialized += MainWindow_SourceInitialized;
|
|
Closed += (_, _) => { _continuousRenderCts?.Cancel(); _doc?.Close(); CloseLinkPdfiumDoc(); App.CleanupSessionTemps(); };
|
|
|
|
// Open a file passed via command-line / file association (e.g. double-clicking a .pdf)
|
|
// Also show the portable badge when running outside the install location.
|
|
bool contentRevealed = false;
|
|
bool startupSidebarSynced = false;
|
|
ContentRendered += (_, _) =>
|
|
{
|
|
Services.StartupTrace.Mark("MainWindow first ContentRendered");
|
|
Services.ThemeManager.RefreshIcons();
|
|
// Startup can restore pane B, process a relaunch/open-file handoff, and change
|
|
// ActiveViewer several times before the first frame. Each transition is valid on
|
|
// its own, but the shared sidebar ItemsSource can still be the preceding pane's
|
|
// cache (or null) when layout finally wins the race. A click appeared to "bring
|
|
// thumbnails back" because FocusPane performs this same synchronization. Do it
|
|
// once here for the pane whose focus halo actually reaches the screen.
|
|
if (!startupSidebarSynced)
|
|
{
|
|
startupSidebarSynced = true;
|
|
RestorePageListForActivePane();
|
|
}
|
|
// Final pass once the layout has real widths. The tab-strip / footer shadow gradients
|
|
// were intermittently blank at startup (their feather mask + margin were computed
|
|
// before the sidebar column had measured), and only a manual sidebar tweak forced a
|
|
// correct re-layout. Re-running it here reproduces that fix automatically.
|
|
UpdateTabStripFade();
|
|
// The content is held invisible (RootClipGrid.Opacity=0 in XAML) until this final
|
|
// positioning pass has run; fade it in once so the brief unpositioned first frame
|
|
// (the "load deform" - shadows/toolbars snapping into place) is never visible.
|
|
if (!contentRevealed)
|
|
{
|
|
contentRevealed = true;
|
|
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() =>
|
|
{
|
|
var reveal = new System.Windows.Media.Animation.DoubleAnimation(0, 1,
|
|
new Duration(TimeSpan.FromMilliseconds(140)));
|
|
RootClipGrid.BeginAnimation(OpacityProperty, reveal);
|
|
Services.StartupTrace.Mark("MainWindow ready");
|
|
}));
|
|
}
|
|
};
|
|
Services.ThemeManager.ThemeChanged += OnThemeChanged;
|
|
|
|
Loaded += (_, _) =>
|
|
{
|
|
RestoreWindowSettings();
|
|
ApplySidebarSide(); // place the sidebar on the saved side (default left)
|
|
BuildToolbarMenu(); // right-click appearance picker on the toolbar
|
|
// Unconditional: the XAML default is small icons / no text, but the family default
|
|
// is Large/Under, so a first run needs the apply pass too.
|
|
ApplyToolbarAppearance();
|
|
|
|
FlushPendingExternalOpen(); // a forward that landed before the panes were wired
|
|
|
|
var args = Environment.GetCommandLineArgs();
|
|
if (args.Length > 1 && (System.IO.File.Exists(args[1]) ||
|
|
Services.ProtocolRegistrar.TryGetTargetUrl(args[1], out _)))
|
|
{
|
|
OpenFromExternal(args[1]);
|
|
}
|
|
else
|
|
{
|
|
// Reopen every tab from the last session (falls back to the single LastFile for
|
|
// settings written before multi-tab restore existed).
|
|
var saved = App.GetSetting("OpenTabs");
|
|
string[] paths = !string.IsNullOrEmpty(saved)
|
|
? saved!.Split('|')
|
|
: (App.GetSetting("LastFile") is { Length: > 0 } lf ? [lf] : []);
|
|
// Lazy restore: create a placeholder tab for each saved file but load only the
|
|
// focused one. The rest materialize (load + render) the first time they're clicked,
|
|
// so startup cost no longer scales with how many tabs were open last session.
|
|
// Built as a local list and handed to the pane, rather than mutating _sessions
|
|
// in place: the session list belongs to a PdfViewer, so the restore has to say
|
|
// WHICH pane. `OpenTabs` / `ActiveTab` are pane A; pane B is restored from
|
|
// `OpenTabsB` / `ActiveTabB` by RestorePaneB below, once the split is reopened.
|
|
var restored = new List<Controls.PdfViewer.DocumentSession>();
|
|
foreach (var f in paths)
|
|
if (!string.IsNullOrEmpty(f) && System.IO.File.Exists(f))
|
|
restored.Add(Controls.PdfViewer.MakeDeferredSession(f));
|
|
|
|
if (restored.Count == 0)
|
|
{
|
|
SetRestoredSessions(restored, null);
|
|
PopulateRecentFilesList(); // empty state: show the recent list
|
|
EnsureInitialSession();
|
|
RebuildTabStrip();
|
|
}
|
|
else
|
|
{
|
|
var wantActive = App.GetSetting("ActiveTab");
|
|
var activeTarget = (!string.IsNullOrEmpty(wantActive)
|
|
? restored.FirstOrDefault(ss => string.Equals(ss.OriginalFile, wantActive, StringComparison.OrdinalIgnoreCase))
|
|
: null)
|
|
?? restored[0];
|
|
SetRestoredSessions(restored, activeTarget);
|
|
ApplySessionState(activeTarget);
|
|
MaterializeDeferred(activeTarget); // load + render only the focused tab
|
|
RebuildTabStrip();
|
|
}
|
|
RestorePaneB();
|
|
}
|
|
|
|
if (App.IsPortable())
|
|
_portableBadge.Visibility = Visibility.Visible;
|
|
|
|
// Start with the sidebar collapsed when no PDF is open (nothing to show); a document
|
|
// opened above will have expanded it via FinishOpenFile.
|
|
SyncSidebarToDocState(hasDoc: _doc != null, startup: true);
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// Maximize-respects-taskbar fix (WindowStyle=None needs WM_GETMINMAXINFO)
|
|
// ============================================================
|
|
|
|
private void MainWindow_SourceInitialized(object? sender, EventArgs e)
|
|
{
|
|
var hwnd = new WindowInteropHelper(this).Handle;
|
|
HwndSource.FromHwnd(hwnd)?.AddHook(WndProc);
|
|
ThemeManager.ApplyDwm(hwnd);
|
|
// Snapping moves the window without changing WindowState, so re-evaluate the rounded vs
|
|
// squared chrome on every move (and once now that the handle exists).
|
|
LocationChanged += OnWindowLocationChanged;
|
|
UpdateWindowChrome();
|
|
FitToSmallScreen();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Plant machines run at 800 by 600. The default 1000 by 700 window does not fit one, and
|
|
/// a window taller than the screen puts its own title bar - and therefore Scan, the window
|
|
/// buttons and the tabs - above the top edge where nobody can reach them. On a screen that
|
|
/// cannot hold the default, start maximised instead.
|
|
/// </summary>
|
|
private void FitToSmallScreen()
|
|
{
|
|
if (WindowState != WindowState.Normal) return;
|
|
var work = System.Windows.SystemParameters.WorkArea;
|
|
if (work.Width <= 0 || work.Height <= 0) return;
|
|
if (Width <= work.Width && Height <= work.Height) return;
|
|
WindowState = WindowState.Maximized;
|
|
}
|
|
|
|
// ============================================================
|
|
// Settings persistence (window size, zoom, last file)
|
|
// ============================================================
|
|
|
|
private void SaveWindowSettings()
|
|
{
|
|
try
|
|
{
|
|
App.SetSetting("WindowState", WindowState.ToString());
|
|
if (WindowState == WindowState.Normal)
|
|
{
|
|
App.SetSetting("WindowWidth", ((int)ActualWidth).ToString());
|
|
App.SetSetting("WindowHeight", ((int)ActualHeight).ToString());
|
|
App.SetSetting("WindowTop", ((int)Top).ToString());
|
|
App.SetSetting("WindowLeft", ((int)Left).ToString());
|
|
}
|
|
App.SetSetting("FitMode", _fitMode.ToString());
|
|
App.SetSetting("ZoomLevel", _zoomLevel.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
// #105: honor the "remember open files" privacy choice. Unset or "1" = remember
|
|
// (default, preserves prior behavior); "0" = forget the session so nothing persists.
|
|
bool rememberFiles = App.GetSetting("RememberOpenFiles") != "0";
|
|
if (rememberFiles)
|
|
{
|
|
if (_currentFile is not null)
|
|
App.SetSetting("LastFile", _currentFile);
|
|
else
|
|
App.RemoveSetting("LastFile");
|
|
// Remember every open tab so the whole session restores next launch. Manually-closed
|
|
// tabs are already gone from _sessions, so they won't come back (Issue #75 still holds).
|
|
// Saved PER PANE. `OpenTabs` / `ActiveTab` keep their old meaning - pane A - so
|
|
// settings written before the split existed still restore; pane B gets its own
|
|
// `OpenTabsB` / `ActiveTabB`, and the split itself gets `SplitOpen` and the
|
|
// divider position. Without this a split window reopened with everything
|
|
// stacked in pane A.
|
|
static List<string> FilesOf(Controls.PdfViewer pane) => pane.SessionsRef
|
|
.Select(ss => ss.OriginalFile)
|
|
.Where(f => !string.IsNullOrEmpty(f) && System.IO.File.Exists(f))
|
|
.Distinct()
|
|
.Select(f => f!)
|
|
.ToList();
|
|
|
|
static void SavePane(string tabsKey, string activeKey,
|
|
List<string> files, Controls.PdfViewer pane)
|
|
{
|
|
if (files.Count > 0) App.SetSetting(tabsKey, string.Join("|", files));
|
|
else App.RemoveSetting(tabsKey);
|
|
if (pane.ActiveSessionRef?.OriginalFile is { Length: > 0 } af
|
|
&& System.IO.File.Exists(af)) App.SetSetting(activeKey, af);
|
|
else App.RemoveSetting(activeKey);
|
|
}
|
|
|
|
SavePane("OpenTabs", "ActiveTab", FilesOf(Viewer), Viewer);
|
|
SavePane("OpenTabsB", "ActiveTabB", FilesOf(ViewerB), ViewerB);
|
|
|
|
if (IsSplit)
|
|
{
|
|
App.SetSetting("SplitOpen", "1");
|
|
// The pixel width of pane A - the fixed column; pane B is star-sized and
|
|
// takes the remainder, so one number describes the divider whatever the
|
|
// window is resized to. (Used to save pane B's width instead, which is the
|
|
// derived/remainder side and not what the restore path needs to seed pane A
|
|
// with before the window has laid out - #161, split pane not remembering
|
|
// its size across a restart.)
|
|
double aw = Viewer.ActualWidth;
|
|
if (aw > 0) App.SetSetting("SplitPaneAWidth",
|
|
aw.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
}
|
|
else
|
|
{
|
|
App.RemoveSetting("SplitOpen");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Privacy: drop any remembered session so no file paths linger on disk.
|
|
App.RemoveSetting("LastFile");
|
|
App.RemoveSetting("OpenTabs");
|
|
App.RemoveSetting("ActiveTab");
|
|
App.RemoveSetting("OpenTabsB");
|
|
App.RemoveSetting("ActiveTabB");
|
|
App.RemoveSetting("SplitOpen");
|
|
}
|
|
PersistToolSettings();
|
|
// The active tab may not have been captured yet at exit; persist its view state directly.
|
|
if (_active != null)
|
|
SaveDocState(_originalFile, _fitMode, _zoomLevel, _viewMode,
|
|
PageList.SelectedIndex >= 0 ? PageList.SelectedIndex : 0);
|
|
}
|
|
catch { /* best-effort */ }
|
|
}
|
|
|
|
// Persist the Draw and Text tool styles so they carry across sessions. The eraser toggle is
|
|
// deliberately NOT saved (it's a transient mode, not a style).
|
|
private void PersistToolSettings()
|
|
{
|
|
try
|
|
{
|
|
App.SetSetting("DrawColor", ToolColorHex(_drawColor));
|
|
App.SetSetting("DrawWidth", _drawWidth.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
App.SetSetting("DrawOpacity", _drawOpacity.ToString());
|
|
App.SetSetting("TextFontSize", _textFontSize.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
App.SetSetting("TextFontName", _textFontName);
|
|
App.SetSetting("TextBold", _textBold ? "1" : "0");
|
|
App.SetSetting("TextItalic", _textItalic ? "1" : "0");
|
|
App.SetSetting("TextStrike", _textStrike ? "1" : "0");
|
|
App.SetSetting("TextUnderline", _textUnderline ? "1" : "0");
|
|
App.SetSetting("TextColor", ToolColorHex(_textColor));
|
|
App.SetSetting("TextOpacity", _textOpacity.ToString());
|
|
App.SetSetting("TextFillColor", ToolColorHexA(_textFillColor));
|
|
}
|
|
catch { /* best-effort */ }
|
|
}
|
|
|
|
private void RestoreToolSettings()
|
|
{
|
|
try
|
|
{
|
|
if (ParseToolColor(App.GetSetting("DrawColor")) is Color dc) _drawColor = dc;
|
|
if (double.TryParse(App.GetSetting("DrawWidth"), System.Globalization.NumberStyles.Float,
|
|
System.Globalization.CultureInfo.InvariantCulture, out double dw) && dw > 0) _drawWidth = dw;
|
|
if (byte.TryParse(App.GetSetting("DrawOpacity"), out byte dop)) _drawOpacity = dop;
|
|
|
|
if (double.TryParse(App.GetSetting("TextFontSize"), System.Globalization.NumberStyles.Float,
|
|
System.Globalization.CultureInfo.InvariantCulture, out double tfs) && tfs > 0) _textFontSize = tfs;
|
|
if (App.GetSetting("TextFontName") is { Length: > 0 } tfn) _textFontName = tfn;
|
|
_textBold = App.GetSetting("TextBold") == "1";
|
|
_textItalic = App.GetSetting("TextItalic") == "1";
|
|
_textStrike = App.GetSetting("TextStrike") == "1";
|
|
_textUnderline = App.GetSetting("TextUnderline") == "1";
|
|
if (ParseToolColor(App.GetSetting("TextColor")) is Color tc) _textColor = tc;
|
|
if (byte.TryParse(App.GetSetting("TextOpacity"), out byte top)) _textOpacity = top;
|
|
if (ParseToolColorA(App.GetSetting("TextFillColor")) is Color tfc) _textFillColor = tfc;
|
|
|
|
// Keep each color's alpha in sync with its opacity byte (the bars store them coupled).
|
|
_drawColor = Color.FromArgb(_drawOpacity, _drawColor.R, _drawColor.G, _drawColor.B);
|
|
_textColor = Color.FromArgb(_textOpacity, _textColor.R, _textColor.G, _textColor.B);
|
|
}
|
|
catch { /* best-effort */ }
|
|
}
|
|
|
|
private static string ToolColorHex(Color c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}";
|
|
private static string ToolColorHexA(Color c) => $"#{c.A:X2}{c.R:X2}{c.G:X2}{c.B:X2}";
|
|
|
|
private static Color? ParseToolColor(string? s)
|
|
{
|
|
if (s is null || s.Length != 7 || s[0] != '#') return null;
|
|
try
|
|
{
|
|
return Color.FromRgb(Convert.ToByte(s.Substring(1, 2), 16),
|
|
Convert.ToByte(s.Substring(3, 2), 16),
|
|
Convert.ToByte(s.Substring(5, 2), 16));
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
private static Color? ParseToolColorA(string? s)
|
|
{
|
|
if (s is null || s.Length != 9 || s[0] != '#') return null;
|
|
try
|
|
{
|
|
return Color.FromArgb(Convert.ToByte(s.Substring(1, 2), 16),
|
|
Convert.ToByte(s.Substring(3, 2), 16),
|
|
Convert.ToByte(s.Substring(5, 2), 16),
|
|
Convert.ToByte(s.Substring(7, 2), 16));
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
private void RestoreWindowSettings()
|
|
{
|
|
try
|
|
{
|
|
if (int.TryParse(App.GetSetting("WindowWidth"), out int w) &&
|
|
int.TryParse(App.GetSetting("WindowHeight"), out int h) && w > 200 && h > 200)
|
|
{
|
|
Width = w;
|
|
Height = h;
|
|
}
|
|
if (int.TryParse(App.GetSetting("WindowTop"), out int savedTop) &&
|
|
int.TryParse(App.GetSetting("WindowLeft"), out int savedLeft))
|
|
{
|
|
// Verify the saved position is visible on the virtual desktop
|
|
// (covers all monitors). Falls back to CenterScreen (XAML default)
|
|
// if the monitor it was on is no longer connected.
|
|
double vLeft = SystemParameters.VirtualScreenLeft;
|
|
double vTop = SystemParameters.VirtualScreenTop;
|
|
double vRight = vLeft + SystemParameters.VirtualScreenWidth;
|
|
double vBottom = vTop + SystemParameters.VirtualScreenHeight;
|
|
bool onScreen = savedLeft + 100 < vRight && savedLeft + Width > vLeft
|
|
&& savedTop + 50 < vBottom && savedTop + Height > vTop;
|
|
if (onScreen)
|
|
{
|
|
Left = savedLeft;
|
|
Top = savedTop;
|
|
}
|
|
}
|
|
if (Enum.TryParse<WindowState>(App.GetSetting("WindowState"), out var ws) &&
|
|
ws == WindowState.Maximized)
|
|
{
|
|
WindowState = WindowState.Maximized;
|
|
}
|
|
if (double.TryParse(App.GetSetting("ZoomLevel"),
|
|
System.Globalization.NumberStyles.Float,
|
|
System.Globalization.CultureInfo.InvariantCulture, out double z) && z > 0)
|
|
{
|
|
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax, z));
|
|
}
|
|
if (Enum.TryParse<FitMode>(App.GetSetting("FitMode"), out var fm))
|
|
_fitMode = fm;
|
|
}
|
|
catch { /* best-effort */ }
|
|
}
|
|
|
|
// ============================================================
|
|
// Core helpers (localization, status, PDF object refs)
|
|
// ============================================================
|
|
|
|
/// <summary>Look up a localized string. Falls back to the key name if missing.</summary>
|
|
private string Loc(string key)
|
|
=> Application.Current.TryFindResource(key) as string ?? key;
|
|
|
|
// A "held" status message briefly wins over routine updates: scrolling the logo to
|
|
// resize the app must show "App size N%", but the chrome resize immediately re-runs
|
|
// the fit pipeline, whose "Page x of y - Fit Page" status stomped it the same frame.
|
|
// While the hold is active, plain SetStatus calls are ignored; the hold refreshes on
|
|
// every wheel notch and expires on its own, after which normal statuses flow again.
|
|
private DateTime _statusHoldUntil = DateTime.MinValue;
|
|
|
|
private void SetStatus(string text)
|
|
{
|
|
if (DateTime.UtcNow < _statusHoldUntil) return; // a held message is showing
|
|
StatusText.Text = text;
|
|
CrashReporter.PushStatusMessage(text);
|
|
}
|
|
|
|
private void SetStatusHeld(string text, int holdMs = 1200)
|
|
{
|
|
_statusHoldUntil = DateTime.UtcNow.AddMilliseconds(holdMs);
|
|
StatusText.Text = text;
|
|
CrashReporter.PushStatusMessage(text);
|
|
}
|
|
|
|
// Clicking the status line flashes the open document's file size for a beat, then puts
|
|
// back whatever was showing (requested on Reddit). Held so page-change chatter can't
|
|
// overwrite it mid-read; the restore stands down if a newer held message took over.
|
|
private void StatusText_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
|
=> ShowCurrentFileSize();
|
|
|
|
private void ShowCurrentFileSize()
|
|
{
|
|
string? path = _originalFile ?? _currentFile;
|
|
if (path is null || !System.IO.File.Exists(path)) return;
|
|
string prior = StatusText.Text;
|
|
long bytes = new System.IO.FileInfo(path).Length;
|
|
string size = bytes >= 1L << 20 ? $"{bytes / (double)(1 << 20):0.##} MB"
|
|
: bytes >= 1L << 10 ? $"{bytes / (double)(1 << 10):0.#} KB"
|
|
: $"{bytes} B";
|
|
SetStatusHeld($"{System.IO.Path.GetFileName(path)} - {size}", 2500);
|
|
var restore = new System.Windows.Threading.DispatcherTimer
|
|
{ Interval = TimeSpan.FromMilliseconds(2550) };
|
|
restore.Tick += (_, _) =>
|
|
{
|
|
restore.Stop();
|
|
if (DateTime.UtcNow >= _statusHoldUntil) SetStatus(prior);
|
|
};
|
|
restore.Start();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dereferences a PdfItem if it is an indirect reference (PdfReference is internal;
|
|
/// we detect it by looking for a public "Value" property returning PdfObject).
|
|
/// </summary>
|
|
private static PdfItem DerefItem(PdfItem item)
|
|
{
|
|
var valueProp = item.GetType().GetProperty("Value",
|
|
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
|
|
if (valueProp?.GetValue(item) is PdfObject resolved)
|
|
return resolved;
|
|
return item;
|
|
}
|
|
|
|
// GetObjectNumber lives in Services/PdfScrub.cs (MmdPdfUI refactor), beside
|
|
// DerefItemStatic - the same reflection-over-PdfReference family.
|
|
|
|
// ============================================================
|
|
// Search (Ctrl+F)
|
|
// ============================================================
|
|
|
|
/// <summary>
|
|
/// Converts a collection of PdfPig words to a properly ordered string.
|
|
/// Sorts top-to-bottom then left-to-right, groups into lines using a
|
|
/// dynamic threshold (~40% of average word height) so words at slightly
|
|
/// different baselines still land on the correct line.
|
|
/// </summary>
|
|
private static string WordsToText(IEnumerable<UglyToad.PdfPig.Content.Word> source)
|
|
{
|
|
var words = source
|
|
.OrderByDescending(w => w.BoundingBox.Top)
|
|
.ThenBy(w => w.BoundingBox.Left)
|
|
.ToList();
|
|
if (words.Count == 0) return string.Empty;
|
|
|
|
// Dynamic threshold: 40% of average word height, minimum 4 PDF units
|
|
double avgH = words.Average(w => w.BoundingBox.Height);
|
|
double thresh = Math.Max(4.0, avgH * 0.4);
|
|
|
|
var lines = new List<List<UglyToad.PdfPig.Content.Word>>();
|
|
double lineY = double.MaxValue;
|
|
foreach (var w in words)
|
|
{
|
|
if (Math.Abs(w.BoundingBox.Top - lineY) > thresh)
|
|
{
|
|
lines.Add([]);
|
|
lineY = w.BoundingBox.Top;
|
|
}
|
|
lines[^1].Add(w);
|
|
}
|
|
|
|
// Re-sort each line by X in case the top-Y sort caused any grouping
|
|
// to pull words into the wrong order within a line.
|
|
return string.Join("\n", lines.Select(l =>
|
|
string.Join(" ", l.OrderBy(w => w.BoundingBox.Left).Select(w => w.Text))));
|
|
}
|
|
}
|
|
}
|