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 _continuousSharpPages => _view.ContinuousSharpPages; private int _continuousSharpW { get => _view.ContinuousSharpW; set => _view.ContinuousSharpW = value; } private List _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> _annotations { get => ActiveViewer.AnnotationsRef; set => ActiveViewer.AnnotationsRef = value; } private Dictionary _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 _pageRotations { get => ActiveViewer.PageRotationsRef; set => ActiveViewer.PageRotationsRef = value; } // Form filling - text/check keyed by widget object number; radio keyed by field name private Dictionary _formTextValues { get => ActiveViewer.FormTextValuesRef; set => ActiveViewer.FormTextValuesRef = value; } private Dictionary _formCheckValues { get => ActiveViewer.FormCheckValuesRef; set => ActiveViewer.FormCheckValuesRef = value; } private Dictionary _formRadioValues { get => ActiveViewer.FormRadioValuesRef; set => ActiveViewer.FormRadioValuesRef = value; } private Dictionary _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 _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 _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 _navBack => ActiveViewer.NavBackRef; private Stack _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 _selectedSet => ActiveViewer.SelectedSetRef; private List _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? _systemFontNamesCache; internal static List 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? _resizeInkOrigPoints { get => ActiveViewer.ResizeInkOrigPointsRef; set => ActiveViewer.ResizeInkOrigPointsRef = value; } private Rect _resizeInkOrigBounds { get => ActiveViewer.ResizeInkOrigBoundsRef; set => ActiveViewer.ResizeInkOrigBoundsRef = value; } private List _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 _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 _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 _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 _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 _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 _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 _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(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(); 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(); } /// /// 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. /// 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 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 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(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(App.GetSetting("FitMode"), out var fm)) _fitMode = fm; } catch { /* best-effort */ } } // ============================================================ // Core helpers (localization, status, PDF object refs) // ============================================================ /// Look up a localized string. Falls back to the key name if missing. 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(); } /// /// Dereferences a PdfItem if it is an indirect reference (PdfReference is internal; /// we detect it by looking for a public "Value" property returning PdfObject). /// 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) // ============================================================ /// /// 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. /// private static string WordsToText(IEnumerable 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>(); 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)))); } } }