vendor: import KillerPDF 1.7.5 source (GPL-3.0) as the base for MMD PDF
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using PdfSharpCore.Pdf;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// The viewer's outward surface: what the window still calls into.
|
||||
///
|
||||
/// WHY A FACADE RATHER THAN WIDENING. Roughly 55 of these are private members of the seven
|
||||
/// moved files. Making them internal in place would mean 55 edits scattered through code that
|
||||
/// is otherwise VERBATIM - and "verbatim" is the property that makes the move reviewable by
|
||||
/// diff. This file is part of the same partial class, so it can see those privates and
|
||||
/// re-expose them without touching a line of the moved code.
|
||||
///
|
||||
/// The `Ext` suffix exists only because a wrapper cannot share a name with the member it wraps
|
||||
/// inside one class. Members that were already internal (RenderAllAnnotations, ClearSelection,
|
||||
/// ClearTextSelection, AccentBrush) are absent here - the window calls those directly.
|
||||
///
|
||||
/// Every entry is one call site away from deletion: when a caller moves into the viewer, its
|
||||
/// line here goes with it.
|
||||
/// </summary>
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ── Annotations: selection, hit-testing, geometry ────────────────────────────────────
|
||||
internal void AddAnnotationExt(PageAnnotation a) => AddAnnotation(a);
|
||||
internal Rect AnnotBoundsExt(PageAnnotation a) => AnnotBounds(a);
|
||||
internal static Point AnnotGetPosExt(PageAnnotation a) => AnnotGetPos(a);
|
||||
internal static void AnnotSetPosExt(PageAnnotation a, Point pos) => AnnotSetPos(a, pos);
|
||||
internal Point ClampAnnotPosExt(PageAnnotation a) => ClampAnnotPos(a);
|
||||
internal bool HitTestAnnotationExt(PageAnnotation a, Point pos, out Rect bounds)
|
||||
=> HitTestAnnotation(a, pos, out bounds);
|
||||
internal static bool IsDraggableExt(PageAnnotation a) => IsDraggable(a);
|
||||
internal void SelectAnnotationExt(PageAnnotation a, Rect bounds) => SelectAnnotation(a, bounds);
|
||||
internal void ToggleMultiSelectExt(PageAnnotation a, Rect bounds, Canvas canvas)
|
||||
=> ToggleMultiSelect(a, bounds, canvas);
|
||||
internal void SelectGroupExt(PageAnnotation lead) => SelectGroup(lead);
|
||||
internal PageAnnotation? SelectedPairedExt() => SelectedPaired();
|
||||
internal int SelectionCountExt() => SelectionCount();
|
||||
internal void ReattachSelectionVisualsExt() => ReattachSelectionVisuals();
|
||||
internal void UnpairSelectedExt() => UnpairSelected();
|
||||
internal void GroupSelectedExt() => GroupSelected();
|
||||
internal void UngroupAnnotationExt(PageAnnotation a) => UngroupAnnotation(a);
|
||||
internal void RemoveFromGroupExt(PageAnnotation a) => RemoveFromGroup(a);
|
||||
internal void DeleteSelectedExt() => DeleteSelected();
|
||||
internal bool SelectAllAnnotationsExt() => SelectAllAnnotations();
|
||||
internal void HideBrushPreviewExt() => HideBrushPreview();
|
||||
internal void FinishStuckGestureExt() => FinishStuckGesture();
|
||||
internal void RefreshSelectionAccentExt() => RefreshSelectionAccent();
|
||||
|
||||
// ── Page canvases ────────────────────────────────────────────────────────────────────
|
||||
internal Canvas CanvasForPageExt(int page) => CanvasForPage(page);
|
||||
internal Canvas? VisibleCanvasForPageExt(int page) => VisibleCanvasForPage(page);
|
||||
internal IEnumerable<Canvas> AllPageCanvasesExt() => AllPageCanvases();
|
||||
|
||||
// ── Undo / commands bound from MainWindow.xaml and the context menu ──────────────────
|
||||
internal void PushDocUndoExt() => PushDocUndo();
|
||||
internal void PushPageSnapshotUndoExt(int pageIdx) => PushPageSnapshotUndo(pageIdx);
|
||||
internal void UndoClickExt(object sender, RoutedEventArgs e) => Undo_Click(sender, e);
|
||||
internal void RedoClickExt(object sender, RoutedEventArgs e) => Redo_Click(sender, e);
|
||||
internal void ClearAnnotationsClickExt(object sender, RoutedEventArgs e) => ClearAnnotations_Click(sender, e);
|
||||
internal void ClearAllAnnotationsClickExt(object sender, RoutedEventArgs e) => ClearAllAnnotations_Click(sender, e);
|
||||
|
||||
// ── Text editing ─────────────────────────────────────────────────────────────────────
|
||||
internal void CommitActiveTextBoxExt() => CommitActiveTextBox();
|
||||
internal void RemoveTextEditHandlesExt() => RemoveTextEditHandles();
|
||||
internal void EditTextAtPositionExt(Point canvasPos, int pageIdx) => EditTextAtPosition(canvasPos, pageIdx);
|
||||
internal void PlaceTextBoxExt(Point pos, int pageIdx) => PlaceTextBox(pos, pageIdx);
|
||||
internal Brush TextEditBackgroundExt() => TextEditBackground();
|
||||
internal static ControlTemplate FlatTextBoxTemplateExt() => FlatTextBoxTemplate();
|
||||
|
||||
// ── Text selection ───────────────────────────────────────────────────────────────────
|
||||
internal void CopySelectedTextExt() => CopySelectedText();
|
||||
internal void SelectAllTextExt() => SelectAllText();
|
||||
|
||||
// ── Crop ─────────────────────────────────────────────────────────────────────────────
|
||||
internal void ApplyCropExt(int[] pageIndices) => ApplyCrop(pageIndices);
|
||||
internal void HideCropConfirmBarExt() => HideCropConfirmBar();
|
||||
internal void ShowDefaultCropBoxExt() => ShowDefaultCropBox();
|
||||
internal void RebuildCropBarForLocaleExt() => RebuildCropBarForLocale();
|
||||
|
||||
// ── Links ────────────────────────────────────────────────────────────────────────────
|
||||
internal void CloseLinkPdfiumDocExt() => CloseLinkPdfiumDoc();
|
||||
internal void AddLinkMenuItemsExt(ContextMenu menu, object target, int annotIndex, int pageIndex)
|
||||
=> AddLinkMenuItems(menu, target, annotIndex, pageIndex);
|
||||
internal int? ResolveDestExt(PdfItem? destItem) => ResolveDest(destItem);
|
||||
internal bool IsPanning => _isPanning;
|
||||
internal EditTool CurrentToolRef { get => _currentTool; set => _currentTool = value; }
|
||||
internal PdfDocument? DocumentRef { get => _doc; set => _doc = value; }
|
||||
internal string? CurrentFileRef { get => _currentFile; set => _currentFile = value; }
|
||||
internal Dictionary<int, List<PageAnnotation>> AnnotationsRef { get => _annotations; set => _annotations = value; }
|
||||
internal Dictionary<int, (int w, int h)> RenderDimsRef { get => _renderDims; set => _renderDims = value; }
|
||||
internal Dictionary<int, int> PageRotationsRef { get => _pageRotations; set => _pageRotations = value; }
|
||||
internal bool IsDrawingRef { get => _isDrawing; set => _isDrawing = value; }
|
||||
internal Point DrawStartRef { get => _drawStart; set => _drawStart = value; }
|
||||
internal UIElement? ActivePreviewRef { get => _activePreview; set => _activePreview = value; }
|
||||
internal System.Windows.Shapes.Rectangle? CropPreviewRectRef { get => _cropPreviewRect; set => _cropPreviewRect = value; }
|
||||
internal Border? CropConfirmBarRef { get => _cropConfirmBar; set => _cropConfirmBar = value; }
|
||||
internal PageAnnotation? SelectedAnnotationRef { get => _selectedAnnotation; set => _selectedAnnotation = value; }
|
||||
internal Border? SelectionBorderRef { get => _selectionBorder; set => _selectionBorder = value; }
|
||||
internal List<PageAnnotation> SelectedSetRef => _selectedSet;
|
||||
internal List<Border> SelectionOutlinesRef => _selectionOutlines;
|
||||
internal System.Windows.Shapes.Rectangle? PairedCoverOutlineRef { get => _pairedCoverOutline; set => _pairedCoverOutline = value; }
|
||||
internal System.Windows.Shapes.Rectangle? ReeditCoverOutlineRef { get => _reeditCoverOutline; set => _reeditCoverOutline = value; }
|
||||
internal string? SelectedTextRef { get => _selectedText; set => _selectedText = value; }
|
||||
internal List<(PageAnnotation a, Point orig)> DragGroupOrigRef => _dragGroupOrig;
|
||||
internal Color DrawColorRef { get => _drawColor; set => _drawColor = value; }
|
||||
internal double DrawWidthRef { get => _drawWidth; set => _drawWidth = value; }
|
||||
internal byte DrawOpacityRef { get => _drawOpacity; set => _drawOpacity = value; }
|
||||
internal bool LineLevelRef { get => _lineLevel; set => _lineLevel = value; }
|
||||
internal bool HighlightEraseRef { get => _highlightErase; set => _highlightErase = value; }
|
||||
internal bool DrawEraseRef { get => _drawErase; set => _drawErase = value; }
|
||||
internal Color HighlightColorRef { get => _highlightColor; set => _highlightColor = value; }
|
||||
internal Color LineAnnotColorRef { get => _lineAnnotColor; set => _lineAnnotColor = value; }
|
||||
internal InkAnnotation? ActiveInkRef { get => _activeInk; set => _activeInk = value; }
|
||||
internal TextBox? ActiveTextBoxRef { get => _activeTextBox; set => _activeTextBox = value; }
|
||||
internal double TextFontSizeRef { get => _textFontSize; set => _textFontSize = value; }
|
||||
internal string TextFontNameRef { get => _textFontName; set => _textFontName = value; }
|
||||
internal bool TextBoldRef { get => _textBold; set => _textBold = value; }
|
||||
internal bool TextItalicRef { get => _textItalic; set => _textItalic = value; }
|
||||
internal bool TextStrikeRef { get => _textStrike; set => _textStrike = value; }
|
||||
internal bool TextUnderlineRef { get => _textUnderline; set => _textUnderline = value; }
|
||||
internal Color TextColorRef { get => _textColor; set => _textColor = value; }
|
||||
internal byte TextOpacityRef { get => _textOpacity; set => _textOpacity = value; }
|
||||
internal Color TextFillColorRef { get => _textFillColor; set => _textFillColor = value; }
|
||||
internal TextAnnotation? ReeditOriginalRef { get => _reeditOriginal; set => _reeditOriginal = value; }
|
||||
internal CoverAnnotation? PendingCoverRef { get => _pendingCover; set => _pendingCover = value; }
|
||||
internal bool PendingEditWasDirtyRef { get => _pendingEditWasDirty; set => _pendingEditWasDirty = value; }
|
||||
internal Border? TextSettingsBarRef { get => _textSettingsBar; set => _textSettingsBar = value; }
|
||||
internal bool IsResizingSigRef { get => _isResizingSig; set => _isResizingSig = value; }
|
||||
internal Point ResizeSigStartRef { get => _resizeSigStart; set => _resizeSigStart = value; }
|
||||
internal double ResizeSigStartScaleRef { get => _resizeSigStartScale; set => _resizeSigStartScale = value; }
|
||||
internal PlacedAnnotation? ResizeSigAnnotRef { get => _resizeSigAnnot; set => _resizeSigAnnot = value; }
|
||||
internal TextAnnotation? ResizeTextAnnotRef { get => _resizeTextAnnot; set => _resizeTextAnnot = value; }
|
||||
internal HighlightAnnotation? ResizeHlAnnotRef { get => _resizeHlAnnot; set => _resizeHlAnnot = value; }
|
||||
internal InkAnnotation? ResizeInkAnnotRef { get => _resizeInkAnnot; set => _resizeInkAnnot = value; }
|
||||
internal List<Point>? ResizeInkOrigPointsRef { get => _resizeInkOrigPoints; set => _resizeInkOrigPoints = value; }
|
||||
internal Rect ResizeInkOrigBoundsRef { get => _resizeInkOrigBounds; set => _resizeInkOrigBounds = value; }
|
||||
internal List<System.Windows.Shapes.Rectangle> ResizeHandlesRef => _resizeHandles;
|
||||
internal string ResizeCornerRef { get => _resizeCorner; set => _resizeCorner = value; }
|
||||
internal Point ResizeAnchorRef { get => _resizeAnchor; set => _resizeAnchor = value; }
|
||||
internal List<System.Windows.Shapes.Rectangle> TextEditHandlesRef => _textEditHandles;
|
||||
internal bool DraggingTextEditHandleRef { get => _draggingTextEditHandle; set => _draggingTextEditHandle = value; }
|
||||
internal string TehCornerRef { get => _tehCorner; set => _tehCorner = value; }
|
||||
internal Point TehAnchorRef { get => _tehAnchor; set => _tehAnchor = value; }
|
||||
internal TextBox? TehBoxRef { get => _tehBox; set => _tehBox = value; }
|
||||
internal bool IsDraggingAnnotRef { get => _isDraggingAnnot; set => _isDraggingAnnot = value; }
|
||||
internal Point DragAnnotStartRef { get => _dragAnnotStart; set => _dragAnnotStart = value; }
|
||||
internal Point DragAnnotOrigPosRef { get => _dragAnnotOrigPos; set => _dragAnnotOrigPos = value; }
|
||||
internal PageAnnotation? DragAnnotRef { get => _dragAnnot; set => _dragAnnot = value; }
|
||||
internal Rect CropCanvasRectRef { get => _cropCanvasRect; set => _cropCanvasRect = value; }
|
||||
internal System.Windows.Shapes.Rectangle? CropPreviewRectBorderRef { get => _cropPreviewRectBorder; set => _cropPreviewRectBorder = value; }
|
||||
internal List<System.Windows.Shapes.Path> CropBracketsRef => _cropBrackets;
|
||||
internal List<System.Windows.Shapes.Rectangle> CropHandlesRef => _cropHandles;
|
||||
internal string? ActiveCropHandleTagRef { get => _activeCropHandleTag; set => _activeCropHandleTag = value; }
|
||||
internal Point CropHandleDragStartRef { get => _cropHandleDragStart; set => _cropHandleDragStart = value; }
|
||||
internal Rect CropRectAtHandleDragRef { get => _cropRectAtHandleDrag; set => _cropRectAtHandleDrag = value; }
|
||||
internal TextBox? CropXBoxRef { get => _cropXBox; set => _cropXBox = value; }
|
||||
internal TextBox? CropYBoxRef { get => _cropYBox; set => _cropYBox = value; }
|
||||
internal TextBox? CropWBoxRef { get => _cropWBox; set => _cropWBox = value; }
|
||||
internal TextBox? CropHBoxRef { get => _cropHBox; set => _cropHBox = value; }
|
||||
internal TextBox? CropRangeBoxRef { get => _cropRangeBox; set => _cropRangeBox = value; }
|
||||
internal string CropUnitRef { get => _cropUnit; set => _cropUnit = value; }
|
||||
internal bool UpdatingCropInputsRef { get => _updatingCropInputs; set => _updatingCropInputs = value; }
|
||||
internal Dictionary<int, string> FormTextValuesRef { get => _formTextValues; set => _formTextValues = value; }
|
||||
internal Dictionary<int, bool> FormCheckValuesRef { get => _formCheckValues; set => _formCheckValues = value; }
|
||||
internal Dictionary<string, string> FormRadioValuesRef { get => _formRadioValues; set => _formRadioValues = value; }
|
||||
internal Dictionary<int, double> FormFontSizesRef { get => _formFontSizes; set => _formFontSizes = value; }
|
||||
internal Border? FormSizeBarRef { get => _formSizeBar; set => _formSizeBar = value; }
|
||||
internal TextBox? ActiveFormTbRef { get => _activeFormTb; set => _activeFormTb = value; }
|
||||
internal int ActiveFormObjRef { get => _activeFormObj; set => _activeFormObj = value; }
|
||||
internal double ActiveFormScaleRef { get => _activeFormScale; set => _activeFormScale = value; }
|
||||
internal Stack<UndoEntry> UndoStackRef { get => _undoStack; set => _undoStack = value; }
|
||||
internal Stack<UndoEntry> RedoStackRef { get => _redoStack; set => _redoStack = value; }
|
||||
internal bool IsDirtyRef { get => _isDirty; set => _isDirty = value; }
|
||||
internal string? OriginalFileRef { get => _originalFile; set => _originalFile = value; }
|
||||
internal bool OpenedFromProtectedRef { get => _openedFromProtected; set => _openedFromProtected = value; }
|
||||
internal bool AsyncOpenPendingRef { get => _asyncOpenPending; set => _asyncOpenPending = value; }
|
||||
internal Stack<int> NavBackRef => _navBack;
|
||||
internal Stack<int> NavForwardRef => _navForward;
|
||||
internal bool OcrRegionModeRef { get => _ocrRegionMode; set => _ocrRegionMode = value; }
|
||||
internal SavedSignature? PendingSignatureRef { get => _pendingSignature; set => _pendingSignature = value; }
|
||||
internal List<Point> ShapePolyPointsRef => _shapePolyPoints;
|
||||
internal EditTool? AnnotBarToolRef { get => _annotBarTool; set => _annotBarTool = value; }
|
||||
internal bool AnnotBarMinimizedRef { get => _annotBarMinimized; set => _annotBarMinimized = value; }
|
||||
internal List<FrameworkElement> AnnotBarDragInnersRef => _annotBarDragInners;
|
||||
|
||||
// ── Save paths ───────────────────────────────────────────────────────────────────────
|
||||
internal void DrawAnnotationsOnDocumentExt(int? onlyPage = null) => DrawAnnotationsOnDocument(onlyPage);
|
||||
internal void WriteFormValuesToDocumentExt() => WriteFormValuesToDocument();
|
||||
|
||||
// ── Handlers bound from MainWindow.xaml ──────────────────────────────────────────────
|
||||
// WPF resolves Click="X" against the XAML root's code-behind, which is still MainWindow, so
|
||||
// these keep working only because MainWindowViewerStubs.cs re-declares each name and points
|
||||
// it here. Moving them without that would throw XamlParseException at startup.
|
||||
internal void PageJumpBoxKeyDownExt(object sender, KeyEventArgs e) => PageJumpBox_KeyDown(sender, e);
|
||||
internal void PageJumpBoxGotFocusExt(object sender, RoutedEventArgs e) => PageJumpBox_GotFocus(sender, e);
|
||||
internal void PageListSelectionChangedExt(object sender, SelectionChangedEventArgs e)
|
||||
=> PageList_SelectionChanged(sender, e);
|
||||
internal void ShortcutHelpClickExt(object sender, RoutedEventArgs e) => ShortcutHelp_Click(sender, e);
|
||||
internal void ShortcutOverlayMouseDownExt(object sender, MouseButtonEventArgs e)
|
||||
=> ShortcutOverlay_MouseLeftButtonDown(sender, e);
|
||||
internal void ShortcutOverlayCardMouseDownExt(object sender, MouseButtonEventArgs e)
|
||||
=> ShortcutOverlayCard_MouseLeftButtonDown(sender, e);
|
||||
internal void ShortcutOverlayCloseClickExt(object sender, RoutedEventArgs e)
|
||||
=> ShortcutOverlayClose_Click(sender, e);
|
||||
internal void HyperlinkRequestNavigateExt(object sender, System.Windows.Navigation.RequestNavigateEventArgs e)
|
||||
=> Hyperlink_RequestNavigate(sender, e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
using PdfSharpCore.Pdf;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// Transitional forwards used by the moved render pipeline while the remaining bridge surface
|
||||
/// is converted to IViewerHost and per-document state.
|
||||
///
|
||||
/// WHY THIS FILE EXISTS. PdfViewer.Viewport.cs and PdfViewer.Zoom.cs were moved across VERBATIM -
|
||||
/// roughly 2,100 lines carrying about 700 references to window members spelled bare (PageList,
|
||||
/// _doc, Loc, RenderAllAnnotations...). Rewriting those 700 sites in the same change that moved
|
||||
/// the files would have been unreviewable. Declaring the names here instead means the moved
|
||||
/// files did not change a character of logic, and the entire coupling surface between viewer
|
||||
/// and window is one readable list.
|
||||
///
|
||||
/// THIS IS SCAFFOLDING, NOT THE DESTINATION. Read the groups below as a to-do list:
|
||||
/// - Group B is per-DOCUMENT state that belongs in DocumentSession. When the viewer holds
|
||||
/// its own active session, that block deletes itself.
|
||||
/// - Group C members live in files that have not moved into the viewer. Each deletes itself
|
||||
/// as its defining file arrives; the defining file is named against every one.
|
||||
/// - Group A is the only group meant to survive, and it should end up expressed as
|
||||
/// IViewerHost rather than as raw Owner reach.
|
||||
///
|
||||
/// Host is null only between construction and the window wiring it up, which happens in the
|
||||
/// MainWindow constructor before any of this can run. The null-forgiving operator is therefore
|
||||
/// deliberate: a null here is a wiring bug and should throw loudly, not render nothing.
|
||||
/// </summary>
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ── The view's own state ─────────────────────────────────────────────────────────────
|
||||
// Not forwards: this viewer OWNS its ViewerState (see PdfViewer.xaml.cs). These mirror the
|
||||
// window's forwarding properties one for one, so the moved code reads identically.
|
||||
private ViewMode _viewMode { get => State.Mode; set => State.Mode = value; }
|
||||
private ViewMode? _pendingViewMode { get => State.Pending; set => State.Pending = value; }
|
||||
private double _zoomLevel { get => State.ZoomLevel; set => State.ZoomLevel = value; }
|
||||
private double _lastRenderZoom { get => State.LastRenderZoom; set => State.LastRenderZoom = value; }
|
||||
private int _renderedPrimaryPage { get => State.RenderedPrimaryPage; set => State.RenderedPrimaryPage = value; }
|
||||
private FitMode _fitMode { get => State.Fit; set => State.Fit = value; }
|
||||
private System.Windows.Threading.DispatcherTimer? _rerenderTimer { get => State.RerenderTimer; set => State.RerenderTimer = value; }
|
||||
private System.Threading.CancellationTokenSource? _secondaryRenderCts { get => State.SecondaryRenderCts; set => State.SecondaryRenderCts = value; }
|
||||
private System.Threading.CancellationTokenSource? _continuousRenderCts { get => State.ContinuousRenderCts; set => State.ContinuousRenderCts = value; }
|
||||
private System.Threading.CancellationTokenSource? _continuousSharpenCts { get => State.ContinuousSharpenCts; set => State.ContinuousSharpenCts = value; }
|
||||
private HashSet<int> _continuousSharpPages => State.ContinuousSharpPages;
|
||||
private int _continuousSharpW { get => State.ContinuousSharpW; set => State.ContinuousSharpW = value; }
|
||||
private List<double> _continuousTops => State.ContinuousTops;
|
||||
private int _gridScrollToPage { get => State.GridScrollToPage; set => State.GridScrollToPage = value; }
|
||||
private int _continuousScrollTarget { get => State.ContinuousScrollTarget; set => State.ContinuousScrollTarget = value; }
|
||||
private double _continuousPageW { get => State.ContinuousPageW; set => State.ContinuousPageW = value; }
|
||||
private Dictionary<int, Canvas> _pages => State.Pages;
|
||||
private Dictionary<int, Canvas> _continuousCanvases => State.ContinuousCanvases;
|
||||
private Canvas _annotationCanvas { get => State.AnnotationCanvas; set => State.AnnotationCanvas = value; }
|
||||
private Canvas _activeCanvas { get => State.ActiveCanvas; set => State.ActiveCanvas = value; }
|
||||
private Canvas? _gestureCanvas { get => State.GestureCanvas; set => State.GestureCanvas = value; }
|
||||
private int _gesturePage { get => State.GesturePage; set => State.GesturePage = value; }
|
||||
private Image PageImage { get => State.PageImage; set => State.PageImage = value; }
|
||||
private StackPanel _continuousPanel { get => State.ContinuousPanel; set => State.ContinuousPanel = value; }
|
||||
private WrapPanel _pageContentPanel { get => State.PageContentPanel; set => State.PageContentPanel = value; }
|
||||
private Grid _pageContentGrid { get => State.PageContentGrid; set => State.PageContentGrid = value; }
|
||||
private int _currentPage
|
||||
{
|
||||
get => State.CurrentPage;
|
||||
set
|
||||
{
|
||||
State.CurrentPage = value;
|
||||
Host?.ViewerPageChanged(this, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group A: host chrome and services ────────────────────────────────────────────────
|
||||
// One toolbar, one sidebar, one status line serving both panes. These are the forwards
|
||||
// meant to survive, and they should become direct IViewerHost calls.
|
||||
|
||||
private string Loc(string key) => Host!.Loc(key);
|
||||
private void SetStatus(string text) => Host!.SetStatus(text);
|
||||
private void RepositionAnnotationBars() => Host!.RepositionAnnotationBars();
|
||||
|
||||
private EditTool _currentTool = EditTool.Select;
|
||||
private bool _fullScreen => Host!.FullScreen;
|
||||
private bool _vScrollVisible { get => Host!.VerticalScrollVisible; set => Host!.VerticalScrollVisible = value; }
|
||||
private bool _spaceHeld => Host!.SpaceHeld;
|
||||
|
||||
// Zoom limits stay defined on the window: MainWindow.xaml.cs and KeyboardShortcuts.cs read
|
||||
// them too, and a const aliases at no cost rather than being duplicated.
|
||||
private const double ZoomMin = MainWindow.ZoomMin;
|
||||
private const double ZoomMax = MainWindow.ZoomMax;
|
||||
private const double ZoomStep = MainWindow.ZoomStep;
|
||||
|
||||
// ── Group B: per-document state ──────────────────────────────────────────────────────
|
||||
// NOT host services. Every one of these already rides in DocumentSession, which tab
|
||||
// switching swaps by reference. They forward for now because the window still owns the
|
||||
// active session; when the viewer holds its own, this whole block goes.
|
||||
private PdfDocument? _doc;
|
||||
private string? _currentFile;
|
||||
// Settable: the tab switch rebinds all three by reference.
|
||||
private Dictionary<int, List<PageAnnotation>> _annotations = [];
|
||||
private Dictionary<int, (int w, int h)> _renderDims = [];
|
||||
private Dictionary<int, int> _pageRotations = [];
|
||||
// _active is NOT forwarded: the session list lives in this class, so this pane owns its own
|
||||
// active document. The window reads it back via ActiveSession.
|
||||
private readonly List<Canvas> _linkOverlays = [];
|
||||
// _continuousLinks is no longer forwarded - the field itself arrived with Links.cs and the
|
||||
// viewer owns it now. ContextMenu.cs and FileOperations.cs read it from the window side
|
||||
// through MainWindowViewerBridge's ContinuousLinksRef, which points back here.
|
||||
|
||||
// Live gesture state shared with the annotation and crop tools, which have not moved yet.
|
||||
private bool _isPanning;
|
||||
private Point _panStart;
|
||||
private double _panScrollH;
|
||||
private double _panScrollV;
|
||||
private bool _isDrawing;
|
||||
private Point _drawStart;
|
||||
private UIElement? _activePreview;
|
||||
private bool _isSelecting;
|
||||
private Point _selectStart;
|
||||
private Rectangle? _selectRect;
|
||||
private int _cropPageIndex = -1;
|
||||
// Crop.cs and Annotations.cs ASSIGN both, so these go through the settable pair on the
|
||||
// window side rather than a get-only forward.
|
||||
private Rectangle? _cropPreviewRect;
|
||||
private Border? _cropConfirmBar;
|
||||
|
||||
// ── Group C: methods in partials that have NOT moved yet ─────────────────────────────
|
||||
// Only four are left - the ones whose defining files stay on the window. RenderAllAnnotations,
|
||||
// ClearSelection, UpdateMarquee, IsDescendantOf, the four Canvas_Mouse* handlers,
|
||||
// ClearTextSelection, AccentBrush, RenderPageLinks, AddSecondaryPageLinks and the
|
||||
// PageList_SelectionChanged delegate are real members of this class now.
|
||||
private void PopulateContextMenu(Point pt, int page) => Host!.PopulateContextMenu(this, pt, page);
|
||||
private void RefreshPageList() => Host!.RefreshPageList(this);
|
||||
private void LoadOutlines() => Host!.LoadOutlines(this);
|
||||
private Cursor CursorForTool(EditTool t) => Host!.CursorForTool(t);
|
||||
|
||||
// The render cache is not forwarded either - TryGetCachedRender / CacheRender are real
|
||||
// members of this class. They work per-pane unchanged: the cache is keyed
|
||||
// (page, bucket, rot) and both accessors take the session explicitly, so
|
||||
// two panes at different zooms simply occupy different buckets of their own session's cache.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// Editing state and narrow shell-facing adapters owned by each viewer instance. Annotations,
|
||||
/// text editing, crop, forms, links, selection and the current tool remain independent between
|
||||
/// panes; only window chrome is routed through the host.
|
||||
/// </summary>
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ── Selection ────────────────────────────────────────────────────────────────────────
|
||||
private PageAnnotation? _selectedAnnotation;
|
||||
private Border? _selectionBorder;
|
||||
private readonly List<PageAnnotation> _selectedSet = [];
|
||||
private readonly List<Border> _selectionOutlines = [];
|
||||
private Rectangle? _pairedCoverOutline;
|
||||
private Rectangle? _reeditCoverOutline;
|
||||
private string? _selectedText;
|
||||
private readonly List<(PageAnnotation a, Point orig)> _dragGroupOrig = [];
|
||||
|
||||
// ── Draw / highlight tool ────────────────────────────────────────────────────────────
|
||||
private Color _drawColor = Colors.Red;
|
||||
private double _drawWidth = 3;
|
||||
private byte _drawOpacity = 255;
|
||||
private bool _lineLevel = true;
|
||||
private bool _highlightErase;
|
||||
private bool _drawErase;
|
||||
private Color _highlightColor = Color.FromArgb(80, 255, 255, 0);
|
||||
private Color _lineAnnotColor = Color.FromArgb(255, 220, 38, 38);
|
||||
private InkAnnotation? _activeInk;
|
||||
|
||||
// ── Text (typewriter) tool ───────────────────────────────────────────────────────────
|
||||
private TextBox? _activeTextBox;
|
||||
private double _textFontSize = 24;
|
||||
private string _textFontName = "Segoe UI";
|
||||
private bool _textBold;
|
||||
private bool _textItalic;
|
||||
private bool _textStrike;
|
||||
private bool _textUnderline;
|
||||
private Color _textColor = Colors.Black;
|
||||
private byte _textOpacity = 255;
|
||||
private Color _textFillColor = Color.FromArgb(0, 255, 255, 255);
|
||||
private TextAnnotation? _reeditOriginal;
|
||||
private CoverAnnotation? _pendingCover;
|
||||
private bool _pendingEditWasDirty;
|
||||
private Border? _textSettingsBar;
|
||||
private const double EditTextSizeCorrection = 0.8;
|
||||
private const double TextBoxDefaultWidth = 220;
|
||||
|
||||
// ── Resize handles ───────────────────────────────────────────────────────────────────
|
||||
private bool _isResizingSig;
|
||||
private Point _resizeSigStart;
|
||||
private double _resizeSigStartScale;
|
||||
private PlacedAnnotation? _resizeSigAnnot;
|
||||
private TextAnnotation? _resizeTextAnnot;
|
||||
private HighlightAnnotation? _resizeHlAnnot;
|
||||
private InkAnnotation? _resizeInkAnnot;
|
||||
private List<Point>? _resizeInkOrigPoints;
|
||||
private Rect _resizeInkOrigBounds;
|
||||
private readonly List<Rectangle> _resizeHandles = [];
|
||||
private string _resizeCorner = "SE";
|
||||
private Point _resizeAnchor;
|
||||
|
||||
private readonly List<Rectangle> _textEditHandles = [];
|
||||
private bool _draggingTextEditHandle;
|
||||
private string _tehCorner = "SE";
|
||||
private Point _tehAnchor;
|
||||
private TextBox? _tehBox;
|
||||
|
||||
// ── Drag-to-move ─────────────────────────────────────────────────────────────────────
|
||||
private bool _isDraggingAnnot;
|
||||
private Point _dragAnnotStart;
|
||||
private Point _dragAnnotOrigPos;
|
||||
private PageAnnotation? _dragAnnot;
|
||||
|
||||
// ── Crop tool ────────────────────────────────────────────────────────────────────────
|
||||
private Rect _cropCanvasRect;
|
||||
private Rectangle? _cropPreviewRectBorder;
|
||||
private readonly List<System.Windows.Shapes.Path> _cropBrackets = [];
|
||||
private readonly List<Rectangle> _cropHandles = [];
|
||||
private string? _activeCropHandleTag;
|
||||
private Point _cropHandleDragStart;
|
||||
private Rect _cropRectAtHandleDrag;
|
||||
private TextBox? _cropXBox;
|
||||
private TextBox? _cropYBox;
|
||||
private TextBox? _cropWBox;
|
||||
private TextBox? _cropHBox;
|
||||
private TextBox? _cropRangeBox;
|
||||
private string _cropUnit = "pt";
|
||||
private bool _updatingCropInputs;
|
||||
|
||||
// ── Form filling ─────────────────────────────────────────────────────────────────────
|
||||
private Dictionary<int, string> _formTextValues = [];
|
||||
private Dictionary<int, bool> _formCheckValues = [];
|
||||
private Dictionary<string, string> _formRadioValues = [];
|
||||
private Dictionary<int, double> _formFontSizes = [];
|
||||
private Border? _formSizeBar;
|
||||
private TextBox? _activeFormTb;
|
||||
private int _activeFormObj;
|
||||
private double _activeFormScale = 1;
|
||||
private const string FormOverlayTag = "FormFieldOverlay";
|
||||
|
||||
// ── Undo / dirty ─────────────────────────────────────────────────────────────────────
|
||||
private Stack<UndoEntry> _undoStack = new();
|
||||
private Stack<UndoEntry> _redoStack = new();
|
||||
private bool _isDirty;
|
||||
|
||||
// ── State owned by files that did not move ───────────────────────────────────────────
|
||||
private Border? _searchBar => Host!.SearchBar;
|
||||
private Features.SearchController Search => Host!.Search;
|
||||
private bool _ocrRegionMode;
|
||||
private SavedSignature? _pendingSignature;
|
||||
private readonly List<Point> _shapePolyPoints = [];
|
||||
private EditTool? _annotBarTool;
|
||||
private bool _annotBarMinimized;
|
||||
private readonly List<FrameworkElement> _annotBarDragInners = [];
|
||||
private SolidColorBrush _swatchDimBorder => Host!.SwatchDimBorder;
|
||||
|
||||
// ══ What Tabs.cs reaches for, now that it lives here ════════════════════════════════
|
||||
private string? _originalFile;
|
||||
private bool _openedFromProtected;
|
||||
private bool _asyncOpenPending;
|
||||
// This pane's own loader token, NOT the window's - see ThumbCts in PdfViewer.TabsApi.cs.
|
||||
private System.Threading.CancellationTokenSource? _thumbCts { get => ThumbCts; set => ThumbCts = value; }
|
||||
private bool _sidebarShowingOutlines => Host!.SidebarShowingOutlines;
|
||||
private readonly System.Collections.Generic.Stack<int> _navBack = new();
|
||||
private readonly System.Collections.Generic.Stack<int> _navForward = new();
|
||||
|
||||
private TextBlock FileNameLabel => Host!.FileNameLabel;
|
||||
private TreeView OutlineTree => Host!.OutlineTree;
|
||||
private Button SidebarOutlinesTab => Host!.SidebarOutlinesTab;
|
||||
|
||||
private ContextMenu MakeThemedMenu() => Host!.MakeThemedMenu();
|
||||
private void CloseSearchBar() => Host!.CloseSearchBar();
|
||||
private void HideSignaturePopup() => Host!.HideSignaturePopup();
|
||||
private void PopulateRecentFilesList() => Host!.PopulateRecentFilesList(this);
|
||||
private void SwitchSidebarToPagesTab() => Host!.SwitchSidebarToPagesTab();
|
||||
private void SyncSidebarToDocState(bool hasDoc, bool startup) => Host!.SyncSidebarToDocState(hasDoc, startup);
|
||||
private void OpenFile(string path) => Host!.OpenFile(path);
|
||||
private void UpdateFooterFade() => Host!.UpdateFooterFade();
|
||||
private void UpdateTabStripFade() => Host!.UpdateTabStripFade();
|
||||
|
||||
// ── Chrome ───────────────────────────────────────────────────────────────────────────
|
||||
private TextBlock StatusText => Host!.StatusText;
|
||||
private FrameworkElement ShortcutOverlay => Host!.ShortcutOverlay;
|
||||
private CheckBox LinkConfirmCheck => Host!.LinkConfirmCheck;
|
||||
|
||||
// ── Methods still on the window ──────────────────────────────────────────────────────
|
||||
private void MarkDirty(bool dirty = true) => Host!.MarkDirty(dirty);
|
||||
private void SetTool(EditTool t) => Host!.SetTool(t);
|
||||
private void SaveTempAndReload(bool keepAnnotations = false, bool preserveZoom = false)
|
||||
=> Host!.SaveTempAndReload(keepAnnotations, preserveZoom);
|
||||
private void RecordNavJump() => Host!.RecordNavJump();
|
||||
private PageAnnotation? CloneAnnotation(PageAnnotation a) => Host!.CloneAnnotation(a);
|
||||
private PageAnnotation? PairPartner(PageAnnotation a) => Host!.PairPartner(a);
|
||||
private void RenderStamps(int page) => Host!.RenderStamps(page);
|
||||
private void OpenStampTool() => Host!.OpenStampTool();
|
||||
private bool StampHitTest(int page, Point pos) => Host!.StampHitTest(page, pos);
|
||||
private void ApplySearchHighlights(int page, Canvas canvas) => Host!.ApplySearchHighlights(page, canvas);
|
||||
private void HighlightSearchResultsOnCurrentPage() => Host!.HighlightSearchResultsOnCurrentPage();
|
||||
private void ShowTextSettings() => Host!.ShowTextSettings();
|
||||
private void HideTextSettings() => Host!.HideTextSettings();
|
||||
private void StyleEditBox(TextBox tb) => Host!.StyleEditBox(tb);
|
||||
private void ApplyTextStyleToSelection() => Host!.ApplyTextStyleToSelection();
|
||||
private TextDecorationCollection? BuildDecorations(bool underline, bool strike)
|
||||
=> Host!.BuildDecorations(underline, strike);
|
||||
private void ShowDrawSettings(EditTool t) => Host!.ShowDrawSettings(t);
|
||||
private void HideDrawSettings() => Host!.HideDrawSettings();
|
||||
private Border MakeBarGrip(int dotCount = 3) => Host!.MakeBarGrip(dotCount);
|
||||
private FrameworkElement BuildBarHost(FrameworkElement content) => Host!.BuildBarHost(content);
|
||||
private void PlaceAnnotationBar(Border bar, Border grip, bool fadeIn = false)
|
||||
=> Host!.PlaceAnnotationBar(bar, grip, fadeIn);
|
||||
private System.Windows.Media.Effects.DropShadowEffect AnnotBarShadow() => Host!.AnnotBarShadow();
|
||||
private void PlaceImageFromDialog(Point pos, int pageIdx) => Host!.PlaceImageFromDialog(pos, pageIdx);
|
||||
private void PlaceSignature(Point pos, int pageIdx) => Host!.PlaceSignature(pos, pageIdx);
|
||||
private void ShowSignaturePopup() => Host!.ShowSignaturePopup();
|
||||
private void FillSignField(bool initials, int objNum, int pageIndex,
|
||||
double x, double y, double w, double h)
|
||||
=> Host!.FillSignField(initials, objNum, pageIndex, x, y, w, h);
|
||||
private void ShapeToolMouseDown(int pageIdx, Point pos, MouseButtonEventArgs e)
|
||||
=> Host!.ShapeToolMouseDown(pageIdx, pos, e);
|
||||
private void CommitShapeDrag(int pageIdx) => Host!.CommitShapeDrag(pageIdx);
|
||||
private void UpdateShapePolyRubber(MouseEventArgs e) => Host!.UpdateShapePolyRubber(e);
|
||||
private void OcrRegion(int pageIdx, Rect canvasBounds) => Host!.OcrRegion(pageIdx, canvasBounds);
|
||||
private void ShowShortcutsOverlayExclusive() => Host!.ShowShortcutsOverlayExclusive();
|
||||
private void FadeOverlayOut(UIElement el) => Host!.FadeOverlayOut(el);
|
||||
private void FadeOutAndRemoveBar(Border? bar) => Host!.FadeOutAndRemoveBar(bar);
|
||||
private PdfSharpCore.Pdf.PdfItem DerefItem(PdfSharpCore.Pdf.PdfItem item) => Host!.DerefItem(item);
|
||||
private string WordsToText(IEnumerable<UglyToad.PdfPig.Content.Word> src) => Host!.WordsToText(src);
|
||||
private MenuItem MakeMenuItem(string header, RoutedEventHandler click,
|
||||
string? gesture = null, string? glyph = null)
|
||||
=> Host!.MakeMenuItem(header, click, gesture, glyph);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
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 KillerPDF.Services;
|
||||
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// Moved from Shell/Crop.cs; the namespace and class line are the only changes. Window members
|
||||
// spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ============================================================
|
||||
// Crop tool
|
||||
// ============================================================
|
||||
|
||||
// Crop coordinate helpers
|
||||
//
|
||||
// The rendered canvas already incorporates the user-applied rotation stored
|
||||
// in _pageRotations. These helpers invert / apply the same transforms that
|
||||
// the link-overlay code uses (lines ~1925-1957), so canvas<->PDF coords are
|
||||
// consistent with how Docnet drew the bitmap.
|
||||
//
|
||||
// rot=0: canvas_x = native_x * cW/pW, canvas_y = (pH - native_y) * cH/pH
|
||||
// rot=90: canvas_x = native_y * cW/pH, canvas_y = native_x * cH/pW
|
||||
// rot=180: canvas_x = (pW - nx) * cW/pW, canvas_y = (pH - ny) * cH/pH
|
||||
// rot=270: canvas_x = (pH - ny) * cW/pH, canvas_y = (pW - nx) * cH/pW
|
||||
|
||||
/// <summary>
|
||||
/// Convert a canvas-space <see cref="Rect"/> to PDF CropBox coordinates
|
||||
/// (bottom-left origin, points) with rotation awareness.
|
||||
/// </summary>
|
||||
private static (double x1, double y1, double x2, double y2) CanvasToPdfRect(
|
||||
Rect cr, double pdfW, double pdfH, double canvasW, double canvasH, int rot)
|
||||
{
|
||||
double cx = cr.X, cy = cr.Y, cw = cr.Width, ch = cr.Height;
|
||||
return rot switch
|
||||
{
|
||||
90 => (cy * pdfW / canvasH,
|
||||
cx * pdfH / canvasW,
|
||||
(cy + ch) * pdfW / canvasH,
|
||||
(cx + cw) * pdfH / canvasW),
|
||||
|
||||
180 => (pdfW - (cx + cw) * pdfW / canvasW,
|
||||
pdfH - (cy + ch) * pdfH / canvasH,
|
||||
pdfW - cx * pdfW / canvasW,
|
||||
pdfH - cy * pdfH / canvasH),
|
||||
|
||||
270 => (pdfW - (cy + ch) * pdfW / canvasH,
|
||||
pdfH - (cx + cw) * pdfH / canvasW,
|
||||
pdfW - cy * pdfW / canvasH,
|
||||
pdfH - cx * pdfH / canvasW),
|
||||
|
||||
_ => (cx * pdfW / canvasW, // 0 deg
|
||||
pdfH - (cy + ch) * pdfH / canvasH,
|
||||
(cx + cw) * pdfW / canvasW,
|
||||
pdfH - cy * pdfH / canvasH),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="CanvasToPdfRect"/> - map PDF CropBox coords back to a canvas-space
|
||||
/// <see cref="Rect"/>.
|
||||
/// </summary>
|
||||
private static Rect PdfToCanvasRect(
|
||||
double x1, double y1, double x2, double y2,
|
||||
double pdfW, double pdfH, double canvasW, double canvasH, int rot)
|
||||
{
|
||||
double cx, cy, cw, ch;
|
||||
switch (rot)
|
||||
{
|
||||
case 90:
|
||||
cx = y1 * canvasW / pdfH;
|
||||
cy = x1 * canvasH / pdfW;
|
||||
cw = (y2 - y1) * canvasW / pdfH;
|
||||
ch = (x2 - x1) * canvasH / pdfW;
|
||||
break;
|
||||
case 180:
|
||||
cx = (pdfW - x2) * canvasW / pdfW;
|
||||
cy = (pdfH - y2) * canvasH / pdfH;
|
||||
cw = (x2 - x1) * canvasW / pdfW;
|
||||
ch = (y2 - y1) * canvasH / pdfH;
|
||||
break;
|
||||
case 270:
|
||||
cx = (pdfH - y2) * canvasW / pdfH;
|
||||
cy = (pdfW - x2) * canvasH / pdfW;
|
||||
cw = (y2 - y1) * canvasW / pdfH;
|
||||
ch = (x2 - x1) * canvasH / pdfW;
|
||||
break;
|
||||
default: // 0 deg
|
||||
cx = x1 * canvasW / pdfW;
|
||||
cy = (pdfH - y2) * canvasH / pdfH;
|
||||
cw = (x2 - x1) * canvasW / pdfW;
|
||||
ch = (y2 - y1) * canvasH / pdfH;
|
||||
break;
|
||||
}
|
||||
return new Rect(Math.Max(0, cx), Math.Max(0, cy),
|
||||
Math.Max(10, cw), Math.Max(10, ch));
|
||||
}
|
||||
|
||||
// The displayed page's point dimensions (width across the canvas, height down it). For a 90/270
|
||||
// rotation the rendered bitmap is turned, so the point dims are swapped relative to the raw page.
|
||||
private (double dispW, double dispH, double sx, double sy) CropDisplayDims(int pi, (double w, double h) dims)
|
||||
{
|
||||
_pageRotations.TryGetValue(pi, out int rot);
|
||||
var page = _doc!.Pages[pi];
|
||||
double pdfW = page.Width.Point, pdfH = page.Height.Point;
|
||||
bool swap = rot == 90 || rot == 270;
|
||||
double dispW = swap ? pdfH : pdfW;
|
||||
double dispH = swap ? pdfW : pdfH;
|
||||
return (dispW, dispH, dispW / dims.w, dispH / dims.h); // sx,sy: page-points per canvas-unit
|
||||
}
|
||||
|
||||
// points -> the active display unit (relative to the page dimension for "%").
|
||||
private double FromPoints(double pts, double pageDim) => _cropUnit switch
|
||||
{
|
||||
"in" => pts / 72.0,
|
||||
"%" => pageDim > 0 ? pts / pageDim * 100.0 : 0,
|
||||
_ => pts,
|
||||
};
|
||||
|
||||
// active display unit -> points.
|
||||
private double ToPoints(double val, double pageDim) => _cropUnit switch
|
||||
{
|
||||
"in" => val * 72.0,
|
||||
"%" => val / 100.0 * pageDim,
|
||||
_ => val,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Push <see cref="_cropCanvasRect"/> into the X/Y/W/H boxes as a top-left origin rectangle
|
||||
/// (GIMP style) in the active unit. No-ops when the bar isn't showing.
|
||||
/// </summary>
|
||||
private void SyncCropBoxInputs()
|
||||
{
|
||||
if (_cropXBox is null || _doc is null) return;
|
||||
int pi = _currentPage;
|
||||
if (pi < 0 || !_renderDims.TryGetValue(pi, out var dims)) return;
|
||||
var (dispW, dispH, sx, sy) = CropDisplayDims(pi, dims);
|
||||
|
||||
var r = _cropCanvasRect;
|
||||
double xPt = Math.Max(0, r.X) * sx;
|
||||
double yPt = Math.Max(0, r.Y) * sy;
|
||||
double wPt = r.Width * sx;
|
||||
double hPt = r.Height * sy;
|
||||
xPt = Math.Min(xPt, dispW); yPt = Math.Min(yPt, dispH);
|
||||
wPt = Math.Min(wPt, dispW - xPt); hPt = Math.Min(hPt, dispH - yPt);
|
||||
|
||||
string fmt = _cropUnit == "in" ? "F2" : "F1";
|
||||
_updatingCropInputs = true;
|
||||
_cropXBox.Text = FromPoints(xPt, dispW).ToString(fmt);
|
||||
_cropYBox!.Text = FromPoints(yPt, dispH).ToString(fmt);
|
||||
_cropWBox!.Text = FromPoints(wPt, dispW).ToString(fmt);
|
||||
_cropHBox!.Text = FromPoints(hPt, dispH).ToString(fmt);
|
||||
_updatingCropInputs = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the X/Y/W/H boxes (top-left origin, active unit) -> update <see cref="_cropCanvasRect"/>.
|
||||
/// Called on Enter or LostFocus inside a box.
|
||||
/// </summary>
|
||||
private void CommitCropBoxInput()
|
||||
{
|
||||
if (_updatingCropInputs || _cropXBox is null || _doc is null) return;
|
||||
int pi = _currentPage;
|
||||
if (pi < 0 || !_renderDims.TryGetValue(pi, out var dims)) return;
|
||||
if (!double.TryParse(_cropXBox.Text, out double x)) return;
|
||||
if (!double.TryParse(_cropYBox!.Text, out double y)) return;
|
||||
if (!double.TryParse(_cropWBox!.Text, out double w)) return;
|
||||
if (!double.TryParse(_cropHBox!.Text, out double h)) return;
|
||||
|
||||
var (dispW, dispH, sx, sy) = CropDisplayDims(pi, dims);
|
||||
double xPt = ToPoints(x, dispW), yPt = ToPoints(y, dispH);
|
||||
double wPt = ToPoints(w, dispW), hPt = ToPoints(h, dispH);
|
||||
|
||||
xPt = Math.Max(0, Math.Min(dispW - 1, xPt));
|
||||
yPt = Math.Max(0, Math.Min(dispH - 1, yPt));
|
||||
wPt = Math.Max(1, Math.Min(dispW - xPt, wPt));
|
||||
hPt = Math.Max(1, Math.Min(dispH - yPt, hPt));
|
||||
|
||||
_cropCanvasRect = new Rect(xPt / sx, yPt / sy, Math.Max(10, wPt / sx), Math.Max(10, hPt / sy));
|
||||
UpdateCropRectVisuals();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a page-range string like "1-3,5,7-9" (1-based) into a zero-based index array.
|
||||
/// Returns <c>null</c> on parse error or if no valid pages are produced.
|
||||
/// </summary>
|
||||
private static int[]? ParsePageRange(string input, int pageCount)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input)) return null;
|
||||
var result = new System.Collections.Generic.HashSet<int>();
|
||||
foreach (var part in input.Split([','], StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var seg = part.Trim();
|
||||
if (seg.Contains('-'))
|
||||
{
|
||||
var halves = seg.Split('-');
|
||||
if (halves.Length == 2 &&
|
||||
int.TryParse(halves[0].Trim(), out int lo) &&
|
||||
int.TryParse(halves[1].Trim(), out int hi))
|
||||
{
|
||||
for (int p = lo; p <= hi; p++)
|
||||
if (p >= 1 && p <= pageCount) result.Add(p - 1);
|
||||
}
|
||||
else return null;
|
||||
}
|
||||
else if (int.TryParse(seg, out int pg))
|
||||
{
|
||||
if (pg >= 1 && pg <= pageCount) result.Add(pg - 1);
|
||||
}
|
||||
else return null;
|
||||
}
|
||||
return result.Count == 0 ? null : [.. result.OrderBy(x => x)];
|
||||
}
|
||||
|
||||
// Entering the Crop tool drops a default crop box (inset from the page edges) and shows the bar
|
||||
// straight away, so the box is visible without the user having to draw one first.
|
||||
private void ShowDefaultCropBox()
|
||||
{
|
||||
if (_doc is null) return;
|
||||
int pi = _currentPage;
|
||||
if (pi < 0) return;
|
||||
var canvas = VisibleCanvasForPage(pi) ?? CanvasForPage(pi);
|
||||
if (canvas is null || canvas.Width <= 0 || canvas.Height <= 0) return;
|
||||
|
||||
_cropPageIndex = pi;
|
||||
_activeCanvas = canvas;
|
||||
_gestureCanvas = canvas;
|
||||
_gesturePage = pi;
|
||||
|
||||
double w = canvas.Width, h = canvas.Height;
|
||||
double mx = w * 0.08, my = h * 0.08;
|
||||
_cropCanvasRect = new Rect(mx, my, Math.Max(10, w - 2 * mx), Math.Max(10, h - 2 * my));
|
||||
|
||||
_cropPreviewRect = new Rectangle
|
||||
{
|
||||
Stroke = Brushes.White, StrokeThickness = 1.5, StrokeDashArray = [5, 3],
|
||||
Fill = AccentBrush(55), Width = _cropCanvasRect.Width, Height = _cropCanvasRect.Height,
|
||||
IsHitTestVisible = false,
|
||||
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, ShadowDepth = 0, BlurRadius = 3, Opacity = 0.7 }
|
||||
};
|
||||
Canvas.SetLeft(_cropPreviewRect, _cropCanvasRect.X);
|
||||
Canvas.SetTop(_cropPreviewRect, _cropCanvasRect.Y);
|
||||
Panel.SetZIndex(_cropPreviewRect, 1);
|
||||
canvas.Children.Add(_cropPreviewRect);
|
||||
|
||||
ShowCropConfirmBar();
|
||||
}
|
||||
|
||||
// Rebuilds the crop bar (and its box) from the current rect so a language switch picks up the new
|
||||
// locale - the bar is built once with Loc() snapshots and would otherwise stay in the old language.
|
||||
// No-op if the bar isn't showing.
|
||||
private void RebuildCropBarForLocale()
|
||||
{
|
||||
if (_cropConfirmBar is null) return;
|
||||
var rect = _cropCanvasRect;
|
||||
var canvas = _activeCanvas;
|
||||
HideCropConfirmBar();
|
||||
if (canvas is null || canvas.Width <= 0 || rect.Width <= 1 || rect.Height <= 1) return;
|
||||
_cropCanvasRect = rect;
|
||||
_cropPreviewRect = new Rectangle
|
||||
{
|
||||
Stroke = Brushes.White, StrokeThickness = 1.5, StrokeDashArray = [5, 3],
|
||||
Fill = Brushes.Transparent, Width = rect.Width, Height = rect.Height,
|
||||
IsHitTestVisible = false,
|
||||
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, ShadowDepth = 0, BlurRadius = 3, Opacity = 0.7 }
|
||||
};
|
||||
Canvas.SetLeft(_cropPreviewRect, rect.X);
|
||||
Canvas.SetTop(_cropPreviewRect, rect.Y);
|
||||
Panel.SetZIndex(_cropPreviewRect, 1);
|
||||
canvas.Children.Add(_cropPreviewRect);
|
||||
ShowCropConfirmBar();
|
||||
}
|
||||
|
||||
private void ShowCropConfirmBar()
|
||||
{
|
||||
if (_doc is null) return;
|
||||
if (_cropPreviewRect is not null)
|
||||
{
|
||||
// Committed box: outline only, but darker + a touch thicker so it reads on light scans.
|
||||
_cropPreviewRect.Fill = Brushes.Transparent;
|
||||
_cropPreviewRect.Stroke = new SolidColorBrush(Color.FromRgb(0x20, 0x20, 0x20));
|
||||
_cropPreviewRect.StrokeThickness = 2;
|
||||
}
|
||||
|
||||
// Bar already up: do NOT rebuild it (that flickers it and wipes the Pages/All inputs). Just
|
||||
// refresh the corner handles and the X/Y/W/H fields so the values track the box.
|
||||
if (_cropConfirmBar is not null)
|
||||
{
|
||||
AddCropHandles();
|
||||
SyncCropBoxInputs();
|
||||
return;
|
||||
}
|
||||
|
||||
int currentPage = _cropPageIndex >= 0 ? _cropPageIndex : _currentPage;
|
||||
|
||||
// Build the bar exactly like the other annotate bars: a drag grip first, then the controls,
|
||||
// wrapped by the shared BuildBarHost and placed with PlaceAnnotationBar so it attaches to the top
|
||||
// and slides left/right like Draw/Text/Highlight - no bespoke host, grain, drag, or positioning.
|
||||
_annotBarDragInners.Clear();
|
||||
var outer = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 2, 8, 2), Background = Brushes.Transparent };
|
||||
var grip = MakeBarGrip();
|
||||
outer.Children.Add(grip);
|
||||
static Button CropBtn(Button b) { b.Padding = new Thickness(10, 4, 10, 4); b.Margin = new Thickness(0, 0, 5, 0); return b; }
|
||||
|
||||
TextBlock LocalizedText(string key, FontWeight? weight = null)
|
||||
{
|
||||
var text = new TextBlock
|
||||
{
|
||||
FontFamily = UiKit.UiFont,
|
||||
FontSize = 11,
|
||||
FontWeight = weight ?? FontWeights.Normal,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
text.SetResourceReference(TextBlock.TextProperty, key);
|
||||
text.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
|
||||
return text;
|
||||
}
|
||||
|
||||
// label + themed field. Enter applies the crop; LostFocus just updates the rect from the values.
|
||||
TextBox AddField(string lbl, double width)
|
||||
{
|
||||
var label = new TextBlock
|
||||
{
|
||||
Text = lbl, FontFamily = UiKit.UiFont, FontSize = 11,
|
||||
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 3, 0)
|
||||
};
|
||||
label.SetResourceReference(TextBlock.ForegroundProperty, "MutedTextBrush");
|
||||
outer.Children.Add(label);
|
||||
var tb = new TextBox
|
||||
{
|
||||
Width = width, Height = 22, FontFamily = UiKit.UiFont, FontSize = 11,
|
||||
BorderThickness = new Thickness(1), Padding = new Thickness(3, 1, 3, 1),
|
||||
VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 8, 0), Style = (Style)FindResource("FormFieldTextBox")
|
||||
};
|
||||
tb.SetResourceReference(TextBox.BackgroundProperty, "PaneBrush");
|
||||
tb.SetResourceReference(TextBox.ForegroundProperty, "TextBrush");
|
||||
tb.SetResourceReference(TextBox.BorderBrushProperty, "CardBorderBrush");
|
||||
tb.KeyDown += (_, e) => { if (e.Key == Key.Enter) { CommitCropBoxInput(); ApplyCrop([currentPage]); e.Handled = true; } };
|
||||
tb.LostFocus += (_, _) => CommitCropBoxInput();
|
||||
outer.Children.Add(tb);
|
||||
return tb;
|
||||
}
|
||||
|
||||
// Group labels (GIMP-style "Position" / "Size") so the single-letter fields read clearly.
|
||||
void GroupLabel(string key, double leftPad)
|
||||
{
|
||||
var label = LocalizedText(key, FontWeights.SemiBold);
|
||||
label.Margin = new Thickness(leftPad, 0, 6, 0);
|
||||
outer.Children.Add(label);
|
||||
}
|
||||
|
||||
GroupLabel("Str_Crop_Position", 0);
|
||||
_cropXBox = AddField("X", 50);
|
||||
_cropYBox = AddField("Y", 50);
|
||||
GroupLabel("Str_Crop_Size", 6); // padding after the Y box, before the size group
|
||||
_cropWBox = AddField("W", 50);
|
||||
_cropHBox = AddField("H", 50);
|
||||
|
||||
// Unit picker (pt / in / %); re-formats the fields on change.
|
||||
var unitCombo = new ComboBox
|
||||
{
|
||||
Width = 54, Height = 22, Margin = new Thickness(0, 0, 8, 0),
|
||||
VerticalContentAlignment = VerticalAlignment.Center,
|
||||
Style = (Style)FindResource("DarkComboBox")
|
||||
};
|
||||
foreach (var u in new[] { "pt", "in", "%" }) unitCombo.Items.Add(u);
|
||||
unitCombo.SelectedItem = _cropUnit;
|
||||
unitCombo.SelectionChanged += (_, _) => { _cropUnit = unitCombo.SelectedItem as string ?? "pt"; SyncCropBoxInputs(); };
|
||||
outer.Children.Add(unitCombo);
|
||||
|
||||
// Divider before the action buttons.
|
||||
var divider = new Border { Width = 1, Margin = new Thickness(2, 0, 8, 0), VerticalAlignment = VerticalAlignment.Stretch };
|
||||
divider.SetResourceReference(Border.BackgroundProperty, "CardBorderBrush");
|
||||
outer.Children.Add(divider);
|
||||
|
||||
// Pages range + "All" checkbox, then a single Crop button on the far right. Crop logic:
|
||||
// All checked -> every page; else a typed range like "1-3,5"; else just the current page.
|
||||
var pagesLabel = LocalizedText("Str_Crop_Pages");
|
||||
pagesLabel.Margin = new Thickness(0, 0, 3, 0);
|
||||
outer.Children.Add(pagesLabel);
|
||||
_cropRangeBox = new TextBox
|
||||
{
|
||||
Width = 64, Height = 22, FontFamily = UiKit.UiFont, FontSize = 11,
|
||||
BorderThickness = new Thickness(1), Padding = new Thickness(3, 1, 3, 1),
|
||||
VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 8, 0),
|
||||
Style = (Style)FindResource("FormFieldTextBox")
|
||||
};
|
||||
_cropRangeBox.SetResourceReference(FrameworkElement.ToolTipProperty, "Str_Crop_RangeTip");
|
||||
_cropRangeBox.SetResourceReference(TextBox.BackgroundProperty, "PaneBrush");
|
||||
_cropRangeBox.SetResourceReference(TextBox.ForegroundProperty, "TextBrush");
|
||||
_cropRangeBox.SetResourceReference(TextBox.BorderBrushProperty, "CardBorderBrush");
|
||||
outer.Children.Add(_cropRangeBox);
|
||||
|
||||
// "All" checkbox - the same compact look as the annotate-bar toggles (no WPF CheckBox chrome).
|
||||
bool cropAll = false;
|
||||
var allTick = new TextBlock { Text = "✓", Foreground = Brushes.White, FontSize = 10, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Visibility = Visibility.Collapsed };
|
||||
var allBox = new Border { Width = 15, Height = 15, CornerRadius = new CornerRadius(3), BorderThickness = new Thickness(1), BorderBrush = _swatchDimBorder, Background = Brushes.Transparent, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), Child = allTick };
|
||||
var allRow = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Cursor = Cursors.Hand, Margin = new Thickness(0, 0, 10, 0) };
|
||||
allRow.SetResourceReference(FrameworkElement.ToolTipProperty, "Str_Crop_AllTip");
|
||||
allRow.Children.Add(allBox);
|
||||
allRow.Children.Add(LocalizedText("Str_Crop_All"));
|
||||
allRow.MouseLeftButtonDown += (_, _) =>
|
||||
{
|
||||
cropAll = !cropAll;
|
||||
allTick.Visibility = cropAll ? Visibility.Visible : Visibility.Collapsed;
|
||||
if (cropAll) allBox.SetResourceReference(Border.BackgroundProperty, "SelectionAccent");
|
||||
else { allBox.Background = Brushes.Transparent; allBox.BorderBrush = _swatchDimBorder; }
|
||||
};
|
||||
outer.Children.Add(allRow);
|
||||
|
||||
// Single Crop button on the right.
|
||||
var cropBtn = CropBtn(UiKit.Make(Loc("Str_Crop_Apply"), true));
|
||||
cropBtn.SetResourceReference(ContentControl.ContentProperty, "Str_Crop_Apply");
|
||||
cropBtn.SetResourceReference(FrameworkElement.ToolTipProperty, "Str_TT_CropThisPage");
|
||||
cropBtn.Click += (_, _) =>
|
||||
{
|
||||
int pc = _doc?.PageCount ?? 0;
|
||||
int[]? pages;
|
||||
if (cropAll) pages = [.. Enumerable.Range(0, pc)];
|
||||
else if (!string.IsNullOrWhiteSpace(_cropRangeBox?.Text))
|
||||
{
|
||||
pages = ParsePageRange(_cropRangeBox!.Text, pc);
|
||||
if (pages is null) { SetStatus(Loc("Str_InvalidRange")); return; }
|
||||
}
|
||||
else pages = [currentPage];
|
||||
ApplyCrop(pages);
|
||||
};
|
||||
outer.Children.Add(cropBtn);
|
||||
|
||||
// Wrap the controls in the shared bar host + frame and place it like the other annotate bars
|
||||
// (top, right-anchored, slidable via the grip; the X position persists across tools).
|
||||
var bar = new Border
|
||||
{
|
||||
BorderThickness = new Thickness(1, 0, 1, 1),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
CornerRadius = new CornerRadius(0, 0, 4, 4),
|
||||
Padding = new Thickness(4),
|
||||
Effect = AnnotBarShadow(),
|
||||
Child = BuildBarHost(outer)
|
||||
};
|
||||
bar.SetResourceReference(Border.BackgroundProperty, "BgFlyout");
|
||||
bar.SetResourceReference(Border.BorderBrushProperty, "PaneBorderBrush");
|
||||
_cropConfirmBar = bar;
|
||||
|
||||
var previewArea = PagePreviewPanel.Parent as Grid;
|
||||
if (previewArea is not null)
|
||||
{
|
||||
Panel.SetZIndex(bar, 100);
|
||||
previewArea.Children.Add(bar);
|
||||
PlaceAnnotationBar(bar, grip, fadeIn: false);
|
||||
}
|
||||
_annotBarTool = EditTool.Crop; // so re-clicking the Crop tool minimizes this bar like the others
|
||||
_annotBarMinimized = false;
|
||||
AddCropHandles();
|
||||
SyncCropBoxInputs();
|
||||
}
|
||||
|
||||
private void HideCropConfirmBar()
|
||||
{
|
||||
if (_cropConfirmBar is not null)
|
||||
{
|
||||
// Remove from whichever panel it was added to (outer grid or canvas fallback)
|
||||
(_annotationCanvas.Parent as Panel)?.Children.Remove(_cropConfirmBar);
|
||||
_annotationCanvas.Children.Remove(_cropConfirmBar); // no-op if not there
|
||||
(PagePreviewPanel.Parent as Panel)?.Children.Remove(_cropConfirmBar);
|
||||
_cropConfirmBar = null;
|
||||
}
|
||||
if (_cropPreviewRectBorder is not null)
|
||||
{
|
||||
(_cropPreviewRectBorder.Parent as Panel)?.Children.Remove(_cropPreviewRectBorder);
|
||||
_annotationCanvas.Children.Remove(_cropPreviewRectBorder);
|
||||
_cropPreviewRectBorder = null;
|
||||
}
|
||||
if (_cropPreviewRect is not null)
|
||||
{
|
||||
(_cropPreviewRect.Parent as Panel)?.Children.Remove(_cropPreviewRect);
|
||||
_annotationCanvas.Children.Remove(_cropPreviewRect);
|
||||
_cropPreviewRect = null;
|
||||
}
|
||||
RemoveCropHandles();
|
||||
_cropXBox = _cropYBox = _cropWBox = _cropHBox = null;
|
||||
_cropRangeBox = null;
|
||||
if (_annotBarTool == EditTool.Crop) _annotBarTool = null; // release the shared annotate-bar slot
|
||||
}
|
||||
|
||||
private void AddCropHandles()
|
||||
{
|
||||
RemoveCropHandles();
|
||||
const double hSize = 24;
|
||||
var tags = new[] { "NW", "NE", "SE", "SW" };
|
||||
var cursors = new[] { Cursors.SizeNWSE, Cursors.SizeNESW, Cursors.SizeNWSE, Cursors.SizeNESW };
|
||||
// Handles live in the OUTER unscaled panel (same as the confirm bar) so they render
|
||||
// at a fixed screen size regardless of canvas zoom level.
|
||||
var outerGrid = PagePreviewPanel.Parent as Panel ?? (Panel)_annotationCanvas;
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var tag = tags[i];
|
||||
var h = new Rectangle
|
||||
{
|
||||
Width = hSize, Height = hSize,
|
||||
Fill = Brushes.Transparent,
|
||||
Stroke = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88)),
|
||||
StrokeThickness = 1.5,
|
||||
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||||
{ Color = Colors.Black, ShadowDepth = 0, BlurRadius = 3, Opacity = 0.6 },
|
||||
Tag = tag,
|
||||
Cursor = cursors[i],
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
};
|
||||
Panel.SetZIndex(h, 101);
|
||||
// Attach drag directly on the handle so clicks don't need to reach _annotationCanvas.
|
||||
h.MouseLeftButtonDown += (_, e) =>
|
||||
{
|
||||
_activeCropHandleTag = tag;
|
||||
// Measure and capture against the active surface (per-page overlay in
|
||||
// Continuous view) so the drag delta matches the crop rect's coordinate space.
|
||||
_cropHandleDragStart = e.GetPosition(_activeCanvas);
|
||||
_cropRectAtHandleDrag = _cropCanvasRect;
|
||||
_activeCanvas.CaptureMouse();
|
||||
e.Handled = true;
|
||||
};
|
||||
_cropHandles.Add(h);
|
||||
outerGrid.Children.Add(h);
|
||||
}
|
||||
PositionCropHandles();
|
||||
}
|
||||
|
||||
private void RemoveCropHandles()
|
||||
{
|
||||
var outerGrid = PagePreviewPanel.Parent as Panel ?? (Panel)_annotationCanvas;
|
||||
foreach (var h in _cropHandles)
|
||||
{
|
||||
outerGrid.Children.Remove(h);
|
||||
_annotationCanvas.Children.Remove(h); // belt-and-suspenders in case it ended up in canvas
|
||||
}
|
||||
_cropHandles.Clear();
|
||||
_activeCropHandleTag = null;
|
||||
RemoveCropBrackets(); // no-op - list is always empty now, kept for safety
|
||||
}
|
||||
|
||||
private void RemoveCropBrackets()
|
||||
{
|
||||
foreach (var b in _cropBrackets) _annotationCanvas.Children.Remove(b);
|
||||
_cropBrackets.Clear();
|
||||
}
|
||||
|
||||
private void PositionCropHandles()
|
||||
{
|
||||
if (_cropHandles.Count < 4) return;
|
||||
const double hSize = 24;
|
||||
var outerGrid = PagePreviewPanel.Parent as UIElement ?? _annotationCanvas;
|
||||
// Translate canvas-space corners to outer-panel screen space (same as RepositionCropConfirmBar).
|
||||
var canvasCorners = new Point[]
|
||||
{
|
||||
new(_cropCanvasRect.X, _cropCanvasRect.Y),
|
||||
new(_cropCanvasRect.Right, _cropCanvasRect.Y),
|
||||
new(_cropCanvasRect.Right, _cropCanvasRect.Bottom),
|
||||
new(_cropCanvasRect.X, _cropCanvasRect.Bottom),
|
||||
};
|
||||
var offsets = new (double dx, double dy)[]
|
||||
{
|
||||
(0, 0 ), // NW: top-left at top-left corner
|
||||
(-hSize, 0 ), // NE: top-right at top-right corner
|
||||
(-hSize, -hSize ), // SE: bottom-right at bottom-right corner
|
||||
(0, -hSize ), // SW: bottom-left at bottom-left corner
|
||||
};
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
Point screen = _activeCanvas.TranslatePoint(canvasCorners[i], outerGrid);
|
||||
_cropHandles[i].Margin = new Thickness(
|
||||
screen.X + offsets[i].dx,
|
||||
screen.Y + offsets[i].dy,
|
||||
0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCropRectVisuals()
|
||||
{
|
||||
if (_cropPreviewRect is null) return;
|
||||
var r = _cropCanvasRect;
|
||||
Canvas.SetLeft(_cropPreviewRect, r.X); Canvas.SetTop(_cropPreviewRect, r.Y);
|
||||
_cropPreviewRect.Width = r.Width; _cropPreviewRect.Height = r.Height;
|
||||
PositionCropHandles();
|
||||
SyncCropBoxInputs();
|
||||
}
|
||||
|
||||
private void ApplyCrop(int[] pageIndices)
|
||||
{
|
||||
if (_doc is null || _currentFile is null) { SetStatus(Loc("Str_CropNoDoc")); return; }
|
||||
int currentPage = _cropPageIndex >= 0 ? _cropPageIndex : _currentPage;
|
||||
if (currentPage < 0) { SetStatus(Loc("Str_CropNoPage")); return; }
|
||||
if (!_renderDims.TryGetValue(currentPage, out var refDims))
|
||||
{ SetStatus(Loc("Str_CropNoDims")); return; }
|
||||
|
||||
try
|
||||
{
|
||||
PushDocUndo();
|
||||
|
||||
// Convert canvas rect to PDF CropBox coords using the rotation-aware helper.
|
||||
// This is the correct inversion of how Docnet renders the rotated bitmap.
|
||||
_pageRotations.TryGetValue(currentPage, out int rot);
|
||||
var refPage = _doc.Pages[currentPage];
|
||||
double refPdfW = refPage.Width.Point;
|
||||
double refPdfH = refPage.Height.Point;
|
||||
|
||||
var (rx1, ry1, rx2, ry2) = CanvasToPdfRect(
|
||||
_cropCanvasRect, refPdfW, refPdfH, refDims.w, refDims.h, rot);
|
||||
|
||||
foreach (int pi in pageIndices)
|
||||
{
|
||||
if (pi < 0 || pi >= _doc.PageCount) continue;
|
||||
var page = _doc.Pages[pi];
|
||||
double pW = page.Width.Point;
|
||||
double pH = page.Height.Point;
|
||||
|
||||
// Scale proportionally when "All Pages" spans pages of different sizes
|
||||
double x1 = rx1 * pW / refPdfW;
|
||||
double y1 = ry1 * pH / refPdfH;
|
||||
double x2 = rx2 * pW / refPdfW;
|
||||
double y2 = ry2 * pH / refPdfH;
|
||||
|
||||
// Clamp to media box and ensure minimum 1-pt size
|
||||
x1 = Math.Max(0, x1); y1 = Math.Max(0, y1);
|
||||
x2 = Math.Min(pW, x2); y2 = Math.Min(pH, y2);
|
||||
if (x2 - x1 < 1) x2 = x1 + 1;
|
||||
if (y2 - y1 < 1) y2 = y1 + 1;
|
||||
|
||||
// Write CropBox directly into the page dictionary (more reliable across
|
||||
// PdfSharpCore versions than the CropBox property setter).
|
||||
var cropArr = new PdfSharpCore.Pdf.PdfArray();
|
||||
cropArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(x1));
|
||||
cropArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(y1));
|
||||
cropArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(x2));
|
||||
cropArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(y2));
|
||||
page.Elements["/CropBox"] = cropArr;
|
||||
|
||||
// Mirror to TrimBox (PDF spec: TrimBox within CropBox within MediaBox)
|
||||
var trimArr = new PdfSharpCore.Pdf.PdfArray();
|
||||
trimArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(x1));
|
||||
trimArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(y1));
|
||||
trimArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(x2));
|
||||
trimArr.Elements.Add(new PdfSharpCore.Pdf.PdfReal(y2));
|
||||
page.Elements["/TrimBox"] = trimArr;
|
||||
}
|
||||
|
||||
HideCropConfirmBar();
|
||||
SetTool(EditTool.Select);
|
||||
SaveTempAndReload(keepAnnotations: true, preserveZoom: true);
|
||||
SetStatus(string.Format(Loc("Str_Cropped"), pageIndices.Length));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(string.Format(Loc("Str_CropFailed"), ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveCropBox(int[] pageIndices)
|
||||
{
|
||||
if (_doc is null || _currentFile is null) return;
|
||||
try
|
||||
{
|
||||
PushDocUndo();
|
||||
foreach (int pi in pageIndices)
|
||||
{
|
||||
if (pi < 0 || pi >= _doc.PageCount) continue;
|
||||
_doc.Pages[pi].Elements.Remove("/CropBox");
|
||||
_doc.Pages[pi].Elements.Remove("/TrimBox");
|
||||
}
|
||||
HideCropConfirmBar();
|
||||
SetTool(EditTool.Select);
|
||||
SaveTempAndReload(keepAnnotations: true);
|
||||
SetStatus(string.Format(Loc("Str_RemovedCrop"), pageIndices.Length));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(string.Format(Loc("Str_RemoveCropFailed"), ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,675 @@
|
||||
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 KillerPDF.Services;
|
||||
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// Moved from Shell/Links.cs; the namespace and class line are the only changes. Window members
|
||||
// spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ============================================================
|
||||
// PDF Link Annotation Overlays
|
||||
// ============================================================
|
||||
|
||||
// LinkInfo lives in Models/LinkTypes.cs, not here - ContextMenu.cs is on the window and also
|
||||
// reads the link rects, so the type cannot be nested in whichever class owns them.
|
||||
|
||||
// Per-page link rects for the tiled views (continuous / grid / two-page), keyed by page index.
|
||||
// Clicks and the hover cursor are resolved by bounds-testing these in Canvas_MouseLeftButtonDown
|
||||
// and Canvas_MouseMove: a per-link overlay swallows the click in the tiled layout but its own
|
||||
// handler never fires, so no visual overlay is created - these rects are the source of truth.
|
||||
private readonly Dictionary<int, List<LinkInfo>> _continuousLinks = [];
|
||||
|
||||
/// <summary>The link-rect map, for the window side. ContextMenu.cs bounds-tests it to build
|
||||
/// the right-click menu and FileOperations.cs clears it on document change; both live on the
|
||||
/// window while Links.cs lives here.</summary>
|
||||
internal Dictionary<int, List<LinkInfo>> ContinuousLinks => _continuousLinks;
|
||||
|
||||
/// <summary>Hit-slop around a link rect, shared with ContextMenu.cs so the menu targets the
|
||||
/// same links the click and hover paths do.</summary>
|
||||
internal const double LinkHitPadShared = LinkHitPad;
|
||||
|
||||
// Small hit-slop (render-dim units) added around a link rect for click / hover / right-click
|
||||
// hit-testing so thin one-line link strips are easy to hit without over-reaching neighbors.
|
||||
// Applied identically in single-page (grows the overlay in RenderPageLinks) and tiled views
|
||||
// (bounds-checks) so both feel the same.
|
||||
private const double LinkHitPad = 5;
|
||||
|
||||
// Persisted opt-IN for the click-safety confirmation prompt, surfaced as the
|
||||
// "Confirm before opening links" toggle on the About card footer.
|
||||
//
|
||||
// Positive sense and default OFF: links keep opening immediately unless you ask for the
|
||||
// prompt. ONE key, deliberately - a hardcoded master switch plus an inverted
|
||||
// "SkipLinkConfirm" opt-out can disagree with each other. The dialog's "Don't ask again" is
|
||||
// the same switch as the checkbox.
|
||||
internal const string ConfirmLinksSetting = "ConfirmLinks";
|
||||
|
||||
// Confirms before opening an external link in the browser, unless the user opted out. Returns true
|
||||
// to proceed. Internal go-to-page links never call this.
|
||||
private bool ConfirmOpenLink(string url)
|
||||
{
|
||||
if (App.GetSetting(ConfirmLinksSetting) != "1") return true;
|
||||
var (result, dontAsk) = KillerDialog.ShowWithCheckbox(
|
||||
Host!.Window,
|
||||
$"{Loc("Str_LinkConfirmBody")}\n\n{url}",
|
||||
Loc("Str_LinkDontAsk"),
|
||||
Loc("Str_LinkConfirmTitle"),
|
||||
MessageBoxButton.OKCancel);
|
||||
if (result != MessageBoxResult.OK) return false;
|
||||
// "Don't ask again" IS the toggle, so turn it off rather than setting a second key the
|
||||
// About checkbox knows nothing about - that is how the two could drift apart before.
|
||||
if (dontAsk)
|
||||
{
|
||||
App.SetSetting(ConfirmLinksSetting, "0");
|
||||
if (LinkConfirmCheck != null) LinkConfirmCheck.IsChecked = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Schemes we will hand to the OS shell when a PDF link is clicked. A PDF can embed ANY URI, and
|
||||
// Process.Start(UseShellExecute=true) would happily launch file:// paths, UNC shares, javascript:,
|
||||
// or registered protocol handlers (ms-msdt:/search-ms: - real malware vectors). Anything outside
|
||||
// this allow-list is refused. http/https = web links; mailto = email links.
|
||||
private static readonly HashSet<string> AllowedLinkSchemes =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "http", "https", "mailto" };
|
||||
|
||||
// True only for an absolute URI in an allowed scheme. Rejects scheme-less / relative URIs (a bare
|
||||
// "www.example.com" is a Tier 2 follow-up), plus file:, javascript:, and custom protocol handlers.
|
||||
private static bool IsAllowedLinkUri(string url) =>
|
||||
Uri.TryCreate(url, UriKind.Absolute, out var uri) && AllowedLinkSchemes.Contains(uri.Scheme);
|
||||
|
||||
// A PDF can store a scheme-less link like "www.example.com" or "example.com/page". Treat a domain-
|
||||
// shaped target as https so it still opens; anything with an explicit scheme, a backslash (UNC/path),
|
||||
// or whitespace is left untouched (and thus refused by IsAllowedLinkUri unless it's http/https/mailto).
|
||||
private static string NormalizeLinkUri(string raw)
|
||||
{
|
||||
raw = raw.Trim();
|
||||
if (raw.Length == 0) return raw;
|
||||
if (raw.Contains('\\') || raw.Contains(' ')) return raw; // Windows path / UNC / junk - don't touch
|
||||
if (raw.Contains("://")) return raw; // already scheme://...
|
||||
int colon = raw.IndexOf(':');
|
||||
int slash = raw.IndexOf('/');
|
||||
if (colon >= 0 && (slash < 0 || colon < slash)) return raw; // "scheme:" (mailto:, file:, C:) - don't touch
|
||||
string host = slash >= 0 ? raw[..slash] : raw; // host part before any path
|
||||
return host.Contains('.') ? "https://" + raw : raw; // dotted host => assume https
|
||||
}
|
||||
|
||||
// Maps a PDF rectangle (points, origin bottom-left, already min/max-normalized) to a canvas-space
|
||||
// rectangle (pixels, origin top-left) for a page rendered at bitmapW x bitmapH. Shared by the
|
||||
// PdfSharpCore and PDFium link readers so the two stay pixel-identical.
|
||||
private static (double x, double y, double w, double h) PdfRectToCanvas(
|
||||
double rx1, double ry1, double rx2, double ry2,
|
||||
double pageWidthPt, double pageHeightPt, int bitmapW, int bitmapH)
|
||||
{
|
||||
double x = rx1 / pageWidthPt * bitmapW;
|
||||
double y = (pageHeightPt - ry2) / pageHeightPt * bitmapH;
|
||||
double w = (rx2 - rx1) / pageWidthPt * bitmapW;
|
||||
double h = (ry2 - ry1) / pageHeightPt * bitmapH;
|
||||
return (x, y, w, h);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Follows a resolved link target: an int page index navigates within the document; a string URI
|
||||
/// is scheme-checked, confirmed, then opened via the shell. Single choke point for both the
|
||||
/// single-page (_linkOverlays) and tiled (_continuousLinks) click paths, so the safety checks
|
||||
/// can't be bypassed by one route and a failed open is always reported instead of silent.
|
||||
/// </summary>
|
||||
private void FollowLinkTarget(object? target)
|
||||
{
|
||||
if (target is int pageIndex)
|
||||
{
|
||||
if (_doc != null && pageIndex >= 0 && pageIndex < _doc.PageCount)
|
||||
{
|
||||
RecordNavJump(); // Alt+Left retraces the link hop
|
||||
_currentPage = pageIndex;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (target is not string raw || string.IsNullOrWhiteSpace(raw)) return;
|
||||
|
||||
// Scheme-less but domain-shaped targets (e.g. "www.example.com") become https:// here.
|
||||
string url = NormalizeLinkUri(raw);
|
||||
if (!IsAllowedLinkUri(url))
|
||||
{
|
||||
SetStatus($"{Loc("Str_LinkBlocked")} {raw}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ConfirmOpenLink(url)) return;
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Open link failed: {ex}");
|
||||
SetStatus(Loc("Str_LinkOpenFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the right-click actions for a link onto `menu`: Open Link (via the safe FollowLinkTarget
|
||||
// path), Copy Link Address / Copy Email Address, and - only for PdfSharpCore-sourced links
|
||||
// (annotIndex >= 0) - Remove Link from PDF. Shared by the single-page overlay menu and the tiled-
|
||||
// view canvas menu so both views offer the same actions.
|
||||
private void AddLinkMenuItems(ContextMenu menu, object target, int annotIndex, int pageIndex)
|
||||
{
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_OpenLink"), (_, _) => FollowLinkTarget(target), glyph: ""));
|
||||
if (target is string uri)
|
||||
{
|
||||
if (uri.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_CopyEmail"), (_, _) => TrySetClipboard(uri["mailto:".Length..]), "Ctrl+C", ""));
|
||||
else
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_CopyLink"), (_, _) => TrySetClipboard(uri), "Ctrl+C", ""));
|
||||
}
|
||||
if (annotIndex >= 0)
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_RemoveLink"), (_, _) => RemoveLinkAnnotation(pageIndex, annotIndex), "Delete", ""));
|
||||
}
|
||||
|
||||
// Clipboard COM calls throw when another app is holding the clipboard open; swallow so a copy
|
||||
// never crashes the app (the worst case is the copy silently not happening).
|
||||
private static void TrySetClipboard(string text)
|
||||
{
|
||||
try { Clipboard.SetText(text); } catch { }
|
||||
}
|
||||
|
||||
// Status-bar hover feedback: shows the hovered link's target, restoring the prior status on exit.
|
||||
private string? _preHoverStatus;
|
||||
private void ShowLinkHoverStatus(string? target)
|
||||
{
|
||||
if (target != null)
|
||||
{
|
||||
_preHoverStatus ??= StatusText.Text;
|
||||
StatusText.Text = target;
|
||||
}
|
||||
else if (_preHoverStatus != null)
|
||||
{
|
||||
StatusText.Text = _preHoverStatus;
|
||||
_preHoverStatus = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carries the link target (page index or URI string) plus the annotation's location in
|
||||
/// the PDF so the overlay can be used to remove the native annotation on demand.
|
||||
/// </summary>
|
||||
private sealed class LinkAnnotInfo(object target, int pageIndex, int annotIndex)
|
||||
{
|
||||
public object Target { get; } = target; // int pageIndex or string URI
|
||||
public int PageIndex { get; } = pageIndex; // 0-based page in _doc
|
||||
public int AnnotIndex { get; } = annotIndex; // index inside page /Annots array
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses all link annotations from a PDF page and converts them to canvas-space
|
||||
/// rectangles. Works for both primary and secondary page renders.
|
||||
/// </summary>
|
||||
private List<LinkInfo> GetPageLinks(int pageIndex, int bitmapW, int bitmapH)
|
||||
{
|
||||
var links = new List<LinkInfo>();
|
||||
if (_doc is null) return links;
|
||||
try
|
||||
{
|
||||
var pdfPage = _doc.Pages[pageIndex];
|
||||
var annotsArr = pdfPage.Elements.GetArray("/Annots");
|
||||
if (annotsArr is null || annotsArr.Elements.Count == 0) return links;
|
||||
|
||||
double pageWidthPt = pdfPage.Width.Point;
|
||||
double pageHeightPt = pdfPage.Height.Point;
|
||||
if (pageWidthPt <= 0) pageWidthPt = 595.28;
|
||||
if (pageHeightPt <= 0) pageHeightPt = 841.89;
|
||||
|
||||
for (int i = 0; i < annotsArr.Elements.Count; i++)
|
||||
{
|
||||
PdfItem? elem = annotsArr.Elements[i];
|
||||
PdfDictionary? ann = elem as PdfDictionary ?? DerefItem(elem) as PdfDictionary;
|
||||
if (ann is null) continue;
|
||||
|
||||
var subtype = ann.Elements["/Subtype"]?.ToString() ?? "";
|
||||
if (!subtype.Contains("Link")) continue;
|
||||
|
||||
var rectArr = ann.Elements.GetArray("/Rect");
|
||||
if (rectArr is null || rectArr.Elements.Count < 4) continue;
|
||||
double rx1 = rectArr.Elements.GetReal(0);
|
||||
double ry1 = rectArr.Elements.GetReal(1);
|
||||
double rx2 = rectArr.Elements.GetReal(2);
|
||||
double ry2 = rectArr.Elements.GetReal(3);
|
||||
if (rx1 > rx2) (rx1, rx2) = (rx2, rx1);
|
||||
if (ry1 > ry2) (ry1, ry2) = (ry2, ry1);
|
||||
|
||||
var (cx, cy, cw, ch) = PdfRectToCanvas(rx1, ry1, rx2, ry2, pageWidthPt, pageHeightPt, bitmapW, bitmapH);
|
||||
if (cw < 1 || ch < 1) continue;
|
||||
|
||||
int? targetPage = null;
|
||||
string? uri = null;
|
||||
|
||||
var actionDict = ann.Elements.GetDictionary("/A");
|
||||
if (actionDict != null)
|
||||
{
|
||||
var s = actionDict.Elements["/S"]?.ToString() ?? "";
|
||||
if (s.Contains("GoTo"))
|
||||
targetPage = ResolveDest(actionDict.Elements["/D"]);
|
||||
else if (s.Contains("URI"))
|
||||
uri = actionDict.Elements.GetString("/URI");
|
||||
}
|
||||
else
|
||||
{
|
||||
targetPage = ResolveDest(ann.Elements["/Dest"]);
|
||||
}
|
||||
|
||||
if (targetPage is null && uri is null) continue;
|
||||
|
||||
object tag = targetPage.HasValue ? (object)targetPage.Value : uri!;
|
||||
string tip = targetPage.HasValue ? $"Go to page {targetPage.Value + 1}" : uri!;
|
||||
links.Add(new LinkInfo(cx, cy, cw, ch, tag, tip, i));
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"GetPageLinks (PdfSharpCore): {ex}"); }
|
||||
|
||||
// PdfSharpCore cannot dereference link annotations stored in object streams (common in
|
||||
// linearized / PDF 1.5+ files): it sees the /Annots references but resolves them to null,
|
||||
// yielding zero links. PDFium reads object streams natively, so when PdfSharpCore found no
|
||||
// links here, fall back to it. The early "no /Annots" return above means this only runs on
|
||||
// pages that actually declare annotations, so link-free pages never pay the PDFium cost.
|
||||
if (links.Count == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var viaPdfium = GetPageLinksViaPdfium(pageIndex, bitmapW, bitmapH);
|
||||
if (viaPdfium.Count > 0) return viaPdfium;
|
||||
}
|
||||
catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"GetPageLinks (PDFium fallback): {ex}"); }
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PDFium link extraction (fallback for object-stream PDFs)
|
||||
//
|
||||
// PdfSharpCore silently drops link annotations stored in object streams (linearized /
|
||||
// PDF 1.5+). PDFium - already shipped with Docnet and used elsewhere for security
|
||||
// stripping - resolves them natively via Services/PdfiumInterop.cs.
|
||||
// ============================================================
|
||||
|
||||
private const int PDFACTION_GOTO = 1;
|
||||
private const int PDFACTION_URI = 3;
|
||||
|
||||
// ALL direct PDFium P/Invoke (the link + page-size entry points included) lives in
|
||||
// Services/PdfiumInterop.cs - one class, one lock (Docnet's), auditable discipline.
|
||||
|
||||
// Cached PDFium document handle for link extraction. Object-stream PDFs take the PDFium fallback
|
||||
// on every annotated page; without this we'd FPDF_LoadDocument (re-parse the whole file) once per
|
||||
// page during a render sweep. Keyed by path so it self-heals when the working file changes
|
||||
// (SaveTempAndReload swaps in a new temp). NOTE: on a plain open _currentFile IS the user's real
|
||||
// file (it is only a temp copy after a page edit or repair), so holding this open blocks saving
|
||||
// over that file - every save-over path calls CloseLinkPdfiumDoc() first (#129). Only touched from
|
||||
// UI-thread render paths (RenderPageLinks / AddSecondaryPageLinks), so no locking is needed.
|
||||
private IntPtr _linkPdfiumDoc = IntPtr.Zero;
|
||||
private string? _linkPdfiumDocPath;
|
||||
|
||||
/// <summary>Returns the cached PDFium handle for the current file, (re)opening it if the file
|
||||
/// changed or it isn't open yet. Returns IntPtr.Zero if there is no file or the load fails.</summary>
|
||||
private IntPtr EnsureLinkPdfiumDoc()
|
||||
{
|
||||
if (_currentFile is null) { CloseLinkPdfiumDoc(); return IntPtr.Zero; }
|
||||
if (_linkPdfiumDoc != IntPtr.Zero && _linkPdfiumDocPath == _currentFile)
|
||||
return _linkPdfiumDoc;
|
||||
|
||||
CloseLinkPdfiumDoc();
|
||||
try { _ = DocLib.Instance; } catch { } // force Docnet to init PDFium before direct pdfium.dll calls
|
||||
IntPtr doc = PdfiumInterop.FPDF_LoadDocument(_currentFile, null);
|
||||
if (doc != IntPtr.Zero)
|
||||
{
|
||||
_linkPdfiumDoc = doc;
|
||||
_linkPdfiumDocPath = _currentFile;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
/// <summary>Closes the cached PDFium link handle if open. Called when the document changes or
|
||||
/// closes; the path check in EnsureLinkPdfiumDoc is the backstop for anything not closed here.</summary>
|
||||
private void CloseLinkPdfiumDoc()
|
||||
{
|
||||
if (_linkPdfiumDoc != IntPtr.Zero)
|
||||
{
|
||||
try { PdfiumInterop.FPDF_CloseDocument(_linkPdfiumDoc); } catch { }
|
||||
_linkPdfiumDoc = IntPtr.Zero;
|
||||
}
|
||||
_linkPdfiumDocPath = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a page's link annotations via PDFium (handles object-stream PDFs that PdfSharpCore
|
||||
/// cannot). Returns the same canvas-space LinkInfo list as GetPageLinks, with AnnotIndex = -1
|
||||
/// because the native annotation isn't addressable through PdfSharpCore's /Annots array - so
|
||||
/// "Remove Link from PDF" is not offered for these.
|
||||
/// </summary>
|
||||
private List<LinkInfo> GetPageLinksViaPdfium(int pageIndex, int bitmapW, int bitmapH)
|
||||
{
|
||||
var links = new List<LinkInfo>();
|
||||
|
||||
// Reuse the PDFium handle cached per document (EnsureLinkPdfiumDoc) instead of reloading the
|
||||
// whole file on every annotated page - object-stream PDFs take this path on every page, so a
|
||||
// per-call FPDF_LoadDocument would re-parse the file once per page during a render sweep. The
|
||||
// page itself is still loaded/closed per call; only the document handle is shared.
|
||||
IntPtr doc = EnsureLinkPdfiumDoc();
|
||||
if (doc == IntPtr.Zero) return links;
|
||||
|
||||
IntPtr page = PdfiumInterop.FPDF_LoadPage(doc, pageIndex);
|
||||
if (page == IntPtr.Zero) return links;
|
||||
try
|
||||
{
|
||||
double pageWidthPt = PdfiumInterop.FPDF_GetPageWidth(page);
|
||||
double pageHeightPt = PdfiumInterop.FPDF_GetPageHeight(page);
|
||||
if (pageWidthPt <= 0) pageWidthPt = 595.28;
|
||||
if (pageHeightPt <= 0) pageHeightPt = 841.89;
|
||||
|
||||
int startPos = 0;
|
||||
while (PdfiumInterop.FPDFLink_Enumerate(page, ref startPos, out IntPtr link))
|
||||
{
|
||||
if (!PdfiumInterop.FPDFLink_GetAnnotRect(link, out PdfiumInterop.FS_RECTF r)) continue;
|
||||
|
||||
// PDFium may report top/bottom in either order; normalize to min/max so the
|
||||
// mapping matches GetPageLinks (PDF origin is bottom-left, y up).
|
||||
double rx1 = Math.Min(r.left, r.right);
|
||||
double rx2 = Math.Max(r.left, r.right);
|
||||
double ry1 = Math.Min(r.top, r.bottom);
|
||||
double ry2 = Math.Max(r.top, r.bottom);
|
||||
|
||||
var (cx, cy, cw, ch) = PdfRectToCanvas(rx1, ry1, rx2, ry2, pageWidthPt, pageHeightPt, bitmapW, bitmapH);
|
||||
if (cw < 1 || ch < 1) continue;
|
||||
|
||||
int? targetPage = null;
|
||||
string? uri = null;
|
||||
|
||||
IntPtr dest = PdfiumInterop.FPDFLink_GetDest(doc, link);
|
||||
if (dest != IntPtr.Zero)
|
||||
{
|
||||
int t = PdfiumInterop.FPDFDest_GetDestPageIndex(doc, dest);
|
||||
if (t >= 0) targetPage = t;
|
||||
}
|
||||
else
|
||||
{
|
||||
IntPtr action = PdfiumInterop.FPDFLink_GetAction(link);
|
||||
if (action != IntPtr.Zero)
|
||||
{
|
||||
uint at = PdfiumInterop.FPDFAction_GetType(action);
|
||||
if (at == PDFACTION_URI)
|
||||
{
|
||||
uint len = PdfiumInterop.FPDFAction_GetURIPath(doc, action, null, 0);
|
||||
if (len > 1)
|
||||
{
|
||||
var buf = new byte[len];
|
||||
PdfiumInterop.FPDFAction_GetURIPath(doc, action, buf, len);
|
||||
uri = System.Text.Encoding.UTF8.GetString(buf, 0, (int)len - 1);
|
||||
}
|
||||
}
|
||||
else if (at == PDFACTION_GOTO)
|
||||
{
|
||||
IntPtr d2 = PdfiumInterop.FPDFAction_GetDest(doc, action);
|
||||
if (d2 != IntPtr.Zero)
|
||||
{
|
||||
int t = PdfiumInterop.FPDFDest_GetDestPageIndex(doc, d2);
|
||||
if (t >= 0) targetPage = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetPage is null && string.IsNullOrEmpty(uri)) continue;
|
||||
|
||||
object tag = targetPage.HasValue ? (object)targetPage.Value : uri!;
|
||||
string tip = targetPage.HasValue ? $"Go to page {targetPage.Value + 1}" : uri!;
|
||||
links.Add(new LinkInfo(cx, cy, cw, ch, tag, tip, -1));
|
||||
}
|
||||
}
|
||||
finally { PdfiumInterop.FPDF_ClosePage(page); }
|
||||
return links;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders link overlays for the primary page onto the annotation canvas.
|
||||
/// Uses a manual bounds-check in Canvas_MouseLeftButtonDown for hit detection
|
||||
/// (transparent Canvas children are unreliable for WPF hit-testing alone).
|
||||
/// </summary>
|
||||
internal void RenderPageLinks(int pageIndex, int bitmapW, int bitmapH)
|
||||
{
|
||||
if (_doc is null || _currentFile is null) return;
|
||||
|
||||
var links = GetPageLinks(pageIndex, bitmapW, bitmapH);
|
||||
foreach (var lnk in links)
|
||||
{
|
||||
var info = new LinkAnnotInfo(lnk.Tag, pageIndex, lnk.AnnotIndex);
|
||||
// Grow the overlay by LinkHitPad on every side so the hand cursor, right-click menu, and the
|
||||
// click bounds-check all share the padded hit area the tiled views use - thin one-line link
|
||||
// strips are easy to hit in single-page view too.
|
||||
var overlay = new Canvas
|
||||
{
|
||||
Width = lnk.Cw + LinkHitPad * 2,
|
||||
Height = lnk.Ch + LinkHitPad * 2,
|
||||
Background = Brushes.Transparent,
|
||||
Cursor = Cursors.Hand,
|
||||
ToolTip = lnk.Tip,
|
||||
Tag = info,
|
||||
IsHitTestVisible = true,
|
||||
};
|
||||
Canvas.SetLeft(overlay, lnk.Cx - LinkHitPad);
|
||||
Canvas.SetTop(overlay, lnk.Cy - LinkHitPad);
|
||||
|
||||
// Right-click menu: same actions as the tiled-view canvas menu, from the shared builder.
|
||||
var cm = new ContextMenu();
|
||||
if (TryFindResource(typeof(ContextMenu)) is Style menuStyle) cm.Style = menuStyle;
|
||||
TextOptions.SetTextFormattingMode(cm, TextFormattingMode.Display);
|
||||
TextOptions.SetTextRenderingMode(cm, TextRenderingMode.Grayscale);
|
||||
AddLinkMenuItems(cm, lnk.Tag, lnk.AnnotIndex, pageIndex);
|
||||
if (cm.Items.Count > 0) overlay.ContextMenu = cm;
|
||||
|
||||
_annotationCanvas.Children.Add(overlay);
|
||||
_linkOverlays.Add(overlay);
|
||||
}
|
||||
|
||||
if (links.Count > 0)
|
||||
SetStatus(string.Format(Loc("Str_PageOfLinks"), pageIndex + 1, _doc.PageCount, links.Count));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a native PDF link annotation from the page /Annots array and persists the change.
|
||||
/// Called from the "Remove Link from PDF" context-menu item on link overlays.
|
||||
/// </summary>
|
||||
private void RemoveLinkAnnotation(int pageIndex, int annotIndex)
|
||||
{
|
||||
if (_doc is null || pageIndex >= _doc.PageCount || annotIndex < 0) return;
|
||||
try
|
||||
{
|
||||
var pdfPage = _doc.Pages[pageIndex];
|
||||
var annotsArr = pdfPage.Elements.GetArray("/Annots");
|
||||
if (annotsArr is null || annotIndex >= annotsArr.Elements.Count) return;
|
||||
|
||||
// Neutralize the annotation object before removing the /Annots reference.
|
||||
// If PdfSharpCore writes the orphaned indirect object to the output file,
|
||||
// aggressive PDF viewers that scan cross-reference tables directly (rather
|
||||
// than following /Annots) would still trigger the link without this step.
|
||||
PdfItem? elem = annotsArr.Elements[annotIndex];
|
||||
PdfDictionary? ann = elem as PdfDictionary ?? DerefItem(elem) as PdfDictionary;
|
||||
if (ann != null)
|
||||
{
|
||||
ann.Elements.Remove("/A");
|
||||
ann.Elements.Remove("/Dest");
|
||||
ann.Elements.Remove("/Subtype");
|
||||
}
|
||||
|
||||
annotsArr.Elements.RemoveAt(annotIndex);
|
||||
MarkDirty();
|
||||
SaveTempAndReload();
|
||||
// Refresh the current page view so the overlay disappears.
|
||||
int sel = _currentPage;
|
||||
_currentPage = -1;
|
||||
_currentPage = sel;
|
||||
SetStatus(Loc("Str_LinkRemoved"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
KillerDialog.Show(Host!.Window, $"{Loc("Str_LinkRemoveFailed")}\n{ex.Message}", "KillerPDF",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
// StripLinkAnnotationBorders lives in Services/PdfScrub.cs (KillerUI refactor), beside
|
||||
// the other pre-save scrubs it always runs with.
|
||||
|
||||
/// <summary>
|
||||
/// Records a page's link rectangles for the tiled views (continuous, grid, two-page). No
|
||||
/// clickable overlay is created: in the tiled layout a per-link overlay swallows the click
|
||||
/// but its own handler never fires, so clicks and the hover cursor are resolved by bounds-
|
||||
/// testing these rects in Canvas_MouseLeftButtonDown and Canvas_MouseMove instead.
|
||||
/// </summary>
|
||||
internal void AddSecondaryPageLinks(int pageIndex, int bitmapW, int bitmapH)
|
||||
{
|
||||
_continuousLinks[pageIndex] = GetPageLinks(pageIndex, bitmapW, bitmapH);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a /Dest value (PdfArray, PdfString, or PdfName) to a 0-based page index.
|
||||
/// Returns null if the destination cannot be resolved.
|
||||
/// Note: PdfReference is internal to PdfSharpCore so we use reflection for ObjectNumber
|
||||
/// and var-inferred types instead of the type name.
|
||||
/// </summary>
|
||||
private int? ResolveDest(PdfItem? destItem)
|
||||
{
|
||||
if (destItem is null || _doc is null) return null;
|
||||
|
||||
// Dereference indirect object if needed (PdfReference is internal, use duck-typing).
|
||||
destItem = DerefItem(destItem);
|
||||
|
||||
PdfArray? arr = null;
|
||||
|
||||
if (destItem is PdfArray a)
|
||||
{
|
||||
arr = a;
|
||||
}
|
||||
else if (destItem is PdfString || destItem is PdfName)
|
||||
{
|
||||
// Named destination - look up in the document catalog
|
||||
arr = ResolveNamedDest(destItem);
|
||||
}
|
||||
|
||||
if (arr is null || arr.Elements.Count == 0) return null;
|
||||
|
||||
// First element of the destination array is an indirect page reference.
|
||||
// PdfReference.ObjectNumber is public but its type is internal; use reflection.
|
||||
var pageRefItem = arr.Elements[0];
|
||||
int elemObjNum = PdfScrub.GetObjectNumber(pageRefItem);
|
||||
if (elemObjNum > 0)
|
||||
{
|
||||
for (int i = 0; i < _doc.PageCount; i++)
|
||||
{
|
||||
// PdfPage.Reference (public) gives us access to ObjectNumber
|
||||
var pgRef = _doc.Pages[i].Reference;
|
||||
if (pgRef != null && pgRef.ObjectNumber == elemObjNum)
|
||||
return i;
|
||||
}
|
||||
}
|
||||
else if (pageRefItem is PdfInteger pageInt)
|
||||
{
|
||||
int pn = pageInt.Value;
|
||||
if (pn >= 0 && pn < _doc.PageCount) return pn;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a named destination (string or name) to a destination array using the
|
||||
/// catalog's /Dests dictionary or /Names /Dests name tree.
|
||||
/// </summary>
|
||||
private PdfArray? ResolveNamedDest(PdfItem nameItem)
|
||||
{
|
||||
if (_doc is null) return null;
|
||||
string name = nameItem switch
|
||||
{
|
||||
PdfString s => s.Value,
|
||||
PdfName n => n.Value.TrimStart('/'),
|
||||
_ => ""
|
||||
};
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
|
||||
var catalog = _doc.Internals.Catalog;
|
||||
|
||||
// Legacy /Dests dictionary (direct mapping)
|
||||
var dests = catalog.Elements.GetDictionary("/Dests");
|
||||
if (dests != null)
|
||||
{
|
||||
PdfItem? val = DerefItem(dests.Elements[name] ?? dests.Elements["/" + name] ?? new PdfInteger(-1));
|
||||
if (val is PdfArray da) return da;
|
||||
if (val is PdfDictionary dd) return dd.Elements.GetArray("/D");
|
||||
}
|
||||
|
||||
// Modern /Names /Dests name tree
|
||||
var names = catalog.Elements.GetDictionary("/Names");
|
||||
var destTree = names?.Elements.GetDictionary("/Dests");
|
||||
if (destTree != null)
|
||||
return ResolveNameTree(destTree, name);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks a PDF name tree to find the destination array for the given name.
|
||||
/// </summary>
|
||||
private static PdfArray? ResolveNameTree(PdfDictionary node, string name)
|
||||
{
|
||||
// Leaf node: flat /Names array [key val key val ...]
|
||||
var namesArr = node.Elements.GetArray("/Names");
|
||||
if (namesArr != null)
|
||||
{
|
||||
for (int i = 0; i + 1 < namesArr.Elements.Count; i += 2)
|
||||
{
|
||||
var key = namesArr.Elements[i];
|
||||
string keyStr = key is PdfString ks ? ks.Value : key?.ToString() ?? "";
|
||||
if (keyStr == name)
|
||||
{
|
||||
PdfItem? val = Services.PdfScrub.DerefItemStatic(namesArr.Elements[i + 1]);
|
||||
if (val is PdfArray va) return va;
|
||||
if (val is PdfDictionary vd) return vd.Elements.GetArray("/D");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Intermediate node: recurse into /Kids
|
||||
var kids = node.Elements.GetArray("/Kids");
|
||||
if (kids != null)
|
||||
{
|
||||
for (int i = 0; i < kids.Elements.Count; i++)
|
||||
{
|
||||
PdfItem? kid = Services.PdfScrub.DerefItemStatic(kids.Elements[i]);
|
||||
if (kid is PdfDictionary kd)
|
||||
{
|
||||
var result = ResolveNameTree(kd, name);
|
||||
if (result != null) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
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 KillerPDF.Services;
|
||||
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// Moved from Shell/PageSelection.cs; the namespace and class line are the only changes. Window
|
||||
// members spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ============================================================
|
||||
// Page selection handler
|
||||
// ============================================================
|
||||
|
||||
private void PageList_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
|
||||
{
|
||||
// Same speed knob as the document viewport (WheelScrollFactor in Zoom.cs).
|
||||
Host?.ScrollSidebar(this, -e.Delta * (48.0 / 120.0) * Controls.PdfViewer.WheelScrollFactor);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void PageJumpBox_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Enter || _doc is null) return;
|
||||
e.Handled = true;
|
||||
if (int.TryParse(Host?.PageJumpText, out int pg))
|
||||
{
|
||||
int idx = Math.Max(0, Math.Min(_doc.PageCount - 1, pg - 1));
|
||||
RecordNavJump(); // Alt+Left retraces the typed jump
|
||||
_currentPage = idx;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Restore current page number if input was invalid
|
||||
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||
}
|
||||
Keyboard.ClearFocus();
|
||||
}
|
||||
|
||||
private void PageJumpBox_GotFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Host?.SelectAllPageJumpText();
|
||||
}
|
||||
|
||||
/// <summary>Set by SyncCurrentPageTo (and only it) around its programmatic SelectedIndex
|
||||
/// write, so the scroll-driven sync does not re-enter the render path through the window's
|
||||
/// XAML-bound handler. Replaces the old detach/attach of a cached delegate, which never
|
||||
/// worked: the list's real subscription is the WINDOW stub, so the -= removed nothing and
|
||||
/// the += accumulated direct subscriptions (2026-08-01).</summary>
|
||||
private bool _syncingPageList;
|
||||
|
||||
private void PageList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_syncingPageList) return; // programmatic sync, not a user selection
|
||||
// The sidebar page list is WINDOW chrome - there is one of it, and BOTH panes attach
|
||||
// their own handler to it. It describes the focused pane only, so the other pane has
|
||||
// to sit this out. Scrolling a pane calls SyncCurrentPageTo, which detaches its OWN
|
||||
// handler before setting SelectedIndex to avoid re-entering the render path; the other
|
||||
// pane's handler stayed attached and navigated that pane to the page you had just
|
||||
// scrolled to in this one, which is why scrolling pane A scrolled pane B.
|
||||
if (Host != null && !Host.IsViewerFocused(this)) return;
|
||||
|
||||
// Mirror the sidebar into the view's own current page. Set
|
||||
// BEFORE the >= 0 guard on purpose - clearing the list (tab close, document close)
|
||||
// drops SelectedIndex to -1 and that has to be mirrored too, or a closed document
|
||||
// leaves a stale page number behind. Assigns _view.CurrentPage directly rather than
|
||||
// going through _currentPage, whose setter would write back into PageList and re-enter
|
||||
// this handler.
|
||||
State.CurrentPage = (sender as ListBox)?.SelectedIndex ?? -1;
|
||||
|
||||
if (_currentPage >= 0)
|
||||
{
|
||||
CommitActiveTextBox();
|
||||
ClearSelection();
|
||||
ClearTextSelection();
|
||||
Host?.EnsureSidebarPageVisible(this, _currentPage);
|
||||
if (_viewMode == ViewMode.Continuous)
|
||||
{
|
||||
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||
ScrollContinuousToPage(_currentPage);
|
||||
return;
|
||||
}
|
||||
if (_viewMode == ViewMode.Grid)
|
||||
{
|
||||
// Grid is a stable overview: selecting a page highlights it but must NOT
|
||||
// re-anchor the grid. It still needs an initial render (open / first display)
|
||||
// when no tiles exist yet; later selections only update the highlight.
|
||||
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||
// Keep the statusbar counter honest even when the clicked tile is already in
|
||||
// view (BringIntoView then scrolls nothing, so the scroll-sync never fires).
|
||||
SetStatus(string.Format(Loc("Str_PageOf"), _currentPage + 1, _doc!.PageCount) + $" - {DisplayZoomPct():F0}%");
|
||||
if (_pageContentPanel.Children.Count <= 1)
|
||||
{
|
||||
PagePreviewPanel.ScrollToTop();
|
||||
PagePreviewPanel.ScrollToHorizontalOffset(0);
|
||||
RenderPage(0); // grid primary is always page 0
|
||||
// Default the grid to a clean 3-columns-across fit. Deferred to Loaded so the
|
||||
// viewport width is valid (it can still be 0 mid-open, which would fall back
|
||||
// to a carried-over zoom and show a single large page).
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded,
|
||||
(Action)(() => SetZoom(GridZoomForN(Math.Min(_doc?.PageCount ?? 1, 3)))));
|
||||
}
|
||||
else if (_currentPage < _pageContentPanel.Children.Count
|
||||
&& _pageContentPanel.Children[_currentPage] is FrameworkElement gridTile)
|
||||
{
|
||||
// Scroll the chosen page's tile into view (BringIntoView accounts for the zoom transform).
|
||||
gridTile.BringIntoView();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Clicking either page of the spread that's already shown (or re-selecting the
|
||||
// current single page) renders the exact same pixels, so skip the re-render and its
|
||||
// flash - just move the page number. SpreadStart, NOT a local % 2: this was the
|
||||
// fourth pairing site and the one #193's book layout missed - its stale (0,1)
|
||||
// pairing matched the rendered cover and swallowed the render of spread (1,2).
|
||||
int targetPrimary = _currentPage;
|
||||
if (_viewMode == ViewMode.TwoPage) targetPrimary = SpreadStart(targetPrimary);
|
||||
if (targetPrimary == _renderedPrimaryPage && Math.Abs(_zoomLevel - _lastRenderZoom) < 0.0001)
|
||||
{
|
||||
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||
return;
|
||||
}
|
||||
PagePreviewPanel.ScrollToTop();
|
||||
PagePreviewPanel.ScrollToHorizontalOffset(0);
|
||||
RenderPage(_currentPage);
|
||||
ApplyZoom();
|
||||
// Update page jump box
|
||||
if (Host != null) Host.PageJumpText = (_currentPage + 1).ToString();
|
||||
// Re-highlight search results on this page if a search is active
|
||||
if (_searchBar is not null && _searchBar.Visibility == Visibility.Visible
|
||||
&& Search.HasResults)
|
||||
HighlightSearchResultsOnCurrentPage();
|
||||
}
|
||||
}
|
||||
|
||||
private void ShortcutHelp_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ShortcutOverlay.Visibility == Visibility.Visible) FadeOverlayOut(ShortcutOverlay);
|
||||
else ShowShortcutsOverlayExclusive();
|
||||
}
|
||||
|
||||
private void ShortcutOverlay_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
// Click on the dim backdrop closes the overlay.
|
||||
FadeOverlayOut(ShortcutOverlay);
|
||||
}
|
||||
|
||||
private void ShortcutOverlayCard_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
// Stop the click from bubbling up to the backdrop handler.
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void ShortcutOverlayClose_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
FadeOverlayOut(ShortcutOverlay);
|
||||
}
|
||||
|
||||
private void Hyperlink_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e)
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
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 KillerPDF.Services;
|
||||
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// Moved from Shell/Selection.cs; the namespace and class line are the only changes. Window
|
||||
// members spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ============================================================
|
||||
// Selection
|
||||
// ============================================================
|
||||
|
||||
// Resolve the active theme's "SelectionAccent" color: a per-theme color picked to stay
|
||||
// readable on the white PDF page (Accent is white in several themes, and AccentBorder is a
|
||||
// pale cream that washes out on white). Falls back to brand green.
|
||||
private Color AccentColor()
|
||||
=> TryFindResource("SelectionAccent") is SolidColorBrush b ? b.Color : Color.FromRgb(30, 165, 76);
|
||||
internal SolidColorBrush AccentBrush(byte alpha = 255)
|
||||
{
|
||||
var c = AccentColor();
|
||||
return new SolidColorBrush(Color.FromArgb(alpha, c.R, c.G, c.B));
|
||||
}
|
||||
// A darker shade of the accent, used for a cover's selection chrome and its in-edit outline so a
|
||||
// cover reads as distinct from the lighter accent on the text box stacked over it.
|
||||
private SolidColorBrush DarkerAccentBrush(byte alpha = 255)
|
||||
{
|
||||
var c = AccentColor();
|
||||
return new SolidColorBrush(Color.FromArgb(alpha, (byte)(c.R * 0.6), (byte)(c.G * 0.6), (byte)(c.B * 0.6)));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Flowing text selection (#127)
|
||||
// ============================================================
|
||||
// A drag that STARTS on a text character tracks the actual run of characters in reading
|
||||
// order, browser-style, instead of the rectangle marquee. Geometry comes from
|
||||
// TextRunService (PdfPig words, the same source as search), endpoints are caret positions
|
||||
// (page, 0..N over the page's flattened chars), and the painted quads use the exact
|
||||
// PDF-to-render math AddSearchHighlight uses, so everything lands where search lands.
|
||||
// Drags that start on empty page keep the classic marquee (annotation box-select,
|
||||
// region copy, OCR region) - see Canvas_MouseLeftButtonDown.
|
||||
|
||||
private readonly TextRunService _textRuns = new();
|
||||
private bool _txtSelActive; // drag in progress
|
||||
private bool _txtSelHasRange; // a committed selection is on screen
|
||||
private (int Page, int Caret) _txtSelAnchor;
|
||||
private (int Page, int Caret) _txtSelFocus;
|
||||
private Point _txtSelDownPos; // press point (gesture canvas coords)
|
||||
private bool _txtSelDragStarted; // true once movement exceeds the click threshold
|
||||
private PageAnnotation? _txtSelClickAnnot; // annotation under the press; selected on plain click
|
||||
private Rect _txtSelClickAnnotBounds;
|
||||
private EditTool? _txtSelCommitTool; // #127 Phase 2: non-null while a Highlight/Strike/
|
||||
// Underline tool owns the flowing drag - the release
|
||||
// commits annotations instead of copying text
|
||||
|
||||
/// <summary>Canvas point to PDF space (points, bottom-left origin) - the inverse of the
|
||||
/// mapping AddSearchHighlight paints with, same as ExtractTextFromRegion.</summary>
|
||||
private static (double X, double Y) CanvasToPdf(Point pos, double renderW, double renderH, PageTextRuns runs)
|
||||
=> (pos.X * runs.PdfWidth / renderW, runs.PdfHeight - pos.Y * runs.PdfHeight / renderH);
|
||||
|
||||
/// <summary>Called from the Select tool's mouse-down. Returns true (and arms the drag) only
|
||||
/// when the press lands ON text; empty page falls through to the marquee.</summary>
|
||||
private bool TryBeginTextSelection(int pageIdx, Point pos)
|
||||
{
|
||||
if (_currentFile is null) return false;
|
||||
if (!_renderDims.TryGetValue(pageIdx, out var rd)) return false;
|
||||
var runs = _textRuns.GetPage(_currentFile, pageIdx);
|
||||
if (runs is null || runs.Chars.Count == 0) return false;
|
||||
|
||||
var (px, py) = CanvasToPdf(pos, rd.w, rd.h, runs);
|
||||
if (!TextRunService.IsOverText(runs, px, py)) return false;
|
||||
|
||||
ClearTextSelection();
|
||||
int caret = TextRunService.CaretFromPoint(runs, px, py);
|
||||
_txtSelAnchor = _txtSelFocus = (pageIdx, caret);
|
||||
_txtSelDownPos = pos;
|
||||
_txtSelDragStarted = false;
|
||||
_txtSelActive = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Mouse-move while a flowing selection drag is live. Resolves which page the
|
||||
/// pointer is over (cross-page tracking in Continuous, where every overlay is a live tile),
|
||||
/// moves the focus caret, and repaints.</summary>
|
||||
private void UpdateTextSelectionDrag(MouseEventArgs e)
|
||||
{
|
||||
if (_currentFile is null) return;
|
||||
// Click-vs-drag threshold: below ~4px of movement this is still a click (which selects
|
||||
// the annotation under the press, if any, on mouse-up) - not a text drag.
|
||||
if (!_txtSelDragStarted)
|
||||
{
|
||||
var tcv = _gestureCanvas ?? _activeCanvas;
|
||||
if (tcv is null) return;
|
||||
var tp = e.GetPosition(tcv);
|
||||
if (Math.Abs(tp.X - _txtSelDownPos.X) < 4 && Math.Abs(tp.Y - _txtSelDownPos.Y) < 4) return;
|
||||
_txtSelDragStarted = true;
|
||||
}
|
||||
int page = _gesturePage;
|
||||
Canvas? cv = _gestureCanvas ?? _activeCanvas;
|
||||
|
||||
if (_viewMode == ViewMode.Continuous)
|
||||
{
|
||||
foreach (var kv in _pages)
|
||||
{
|
||||
var c = kv.Value;
|
||||
double cw = double.IsNaN(c.Width) ? c.ActualWidth : c.Width;
|
||||
double ch = double.IsNaN(c.Height) ? c.ActualHeight : c.Height;
|
||||
var p = e.GetPosition(c);
|
||||
if (p.X >= 0 && p.X <= cw && p.Y >= 0 && p.Y <= ch) { page = kv.Key; cv = c; break; }
|
||||
}
|
||||
}
|
||||
if (cv is null) return;
|
||||
|
||||
// Clamp into the canvas so dragging past an edge clamps to the start/end of lines
|
||||
// instead of losing the selection.
|
||||
double cvW = double.IsNaN(cv.Width) ? cv.ActualWidth : cv.Width;
|
||||
double cvH = double.IsNaN(cv.Height) ? cv.ActualHeight : cv.Height;
|
||||
var pos = e.GetPosition(cv);
|
||||
pos = new Point(Math.Max(0, Math.Min(cvW, pos.X)), Math.Max(0, Math.Min(cvH, pos.Y)));
|
||||
|
||||
if (!_renderDims.TryGetValue(page, out var rd)) return;
|
||||
var runs = _textRuns.GetPage(_currentFile, page);
|
||||
if (runs is null) return;
|
||||
|
||||
var (px, py) = CanvasToPdf(pos, rd.w, rd.h, runs);
|
||||
var focus = (page, TextRunService.CaretFromPoint(runs, px, py));
|
||||
if (focus == _txtSelFocus) return;
|
||||
_txtSelFocus = focus;
|
||||
RepaintTextSelection();
|
||||
}
|
||||
|
||||
/// <summary>Mouse-up: commit the range, copy it (matching the app's existing
|
||||
/// select-copies-immediately behavior), and leave the quads on screen.</summary>
|
||||
private void FinishTextSelection()
|
||||
{
|
||||
_txtSelActive = false;
|
||||
var clickAnnot = _txtSelClickAnnot;
|
||||
var clickBounds = _txtSelClickAnnotBounds;
|
||||
var commitTool = _txtSelCommitTool;
|
||||
_txtSelClickAnnot = null;
|
||||
_txtSelCommitTool = null;
|
||||
if (!_txtSelDragStarted || _txtSelAnchor == _txtSelFocus)
|
||||
{
|
||||
// Plain click: the annotation under the press (e.g. a highlight box covering this
|
||||
// paragraph) gets selected, exactly as it did before flowing selection existed.
|
||||
// (Select tool only - a highlight-tool click just drops the empty gesture.)
|
||||
ClearTextSelection();
|
||||
if (commitTool is null && clickAnnot is not null) SelectAnnotation(clickAnnot, clickBounds);
|
||||
return;
|
||||
}
|
||||
if (commitTool is EditTool hlTool)
|
||||
{
|
||||
CommitFlowingHighlight(hlTool);
|
||||
return;
|
||||
}
|
||||
_txtSelHasRange = true;
|
||||
|
||||
int words;
|
||||
_selectedText = BuildSelectedText(out words);
|
||||
if (string.IsNullOrWhiteSpace(_selectedText))
|
||||
{
|
||||
SetStatus(Loc("Str_St_NoTextInSelection"));
|
||||
ClearTextSelection();
|
||||
return;
|
||||
}
|
||||
try { Clipboard.SetText(_selectedText); } catch { /* clipboard momentarily locked by another app */ }
|
||||
SetStatus(string.Format(Loc("Str_St_CopiedWords"), words));
|
||||
}
|
||||
|
||||
private ((int Page, int Caret) Start, (int Page, int Caret) End) OrderedSelection()
|
||||
{
|
||||
var a = _txtSelAnchor;
|
||||
var f = _txtSelFocus;
|
||||
bool aFirst = a.Page < f.Page || (a.Page == f.Page && a.Caret <= f.Caret);
|
||||
return aFirst ? (a, f) : (f, a);
|
||||
}
|
||||
|
||||
/// <summary>The caret slice of the selection that falls on one page, or (0,0) when none.</summary>
|
||||
private (int Start, int End) SelectionSliceForPage(int page, int charCount)
|
||||
{
|
||||
var (s, e) = OrderedSelection();
|
||||
if (page < s.Page || page > e.Page) return (0, 0);
|
||||
int start = page == s.Page ? s.Caret : 0;
|
||||
int end = page == e.Page ? e.Caret : charCount;
|
||||
return (start, end);
|
||||
}
|
||||
|
||||
private string BuildSelectedText(out int wordCount)
|
||||
{
|
||||
wordCount = 0;
|
||||
if (_currentFile is null) return string.Empty;
|
||||
var (s, e) = OrderedSelection();
|
||||
var sb = new System.Text.StringBuilder();
|
||||
for (int p = s.Page; p <= e.Page; p++)
|
||||
{
|
||||
var runs = _textRuns.GetPage(_currentFile, p);
|
||||
if (runs is null || runs.Chars.Count == 0) continue;
|
||||
var (start, end) = SelectionSliceForPage(p, runs.Chars.Count);
|
||||
if (start >= end) continue;
|
||||
string t = TextRunService.TextForRange(runs, start, end, out int w);
|
||||
if (t.Length == 0) continue;
|
||||
if (sb.Length > 0) sb.Append('\n');
|
||||
sb.Append(t);
|
||||
wordCount += w;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Per-line rects (canvas render-dim space) of the current selection on one page -
|
||||
/// one rect per line, first selected char to last, browser-style. Shared by the selection
|
||||
/// quad painter and the flowing-highlight commit (#127 Phase 2) so a committed highlight
|
||||
/// lands exactly where the drag preview showed it.</summary>
|
||||
private List<Rect> SelectionLineRectsForPage(int page)
|
||||
{
|
||||
var result = new List<Rect>();
|
||||
if (_currentFile is null) return result;
|
||||
var (s, e) = OrderedSelection();
|
||||
if (page < s.Page || page > e.Page) return result;
|
||||
if (!_renderDims.TryGetValue(page, out var rd)) return result;
|
||||
var runs = _textRuns.GetPage(_currentFile, page);
|
||||
if (runs is null || runs.Chars.Count == 0) return result;
|
||||
var (start, end) = SelectionSliceForPage(page, runs.Chars.Count);
|
||||
if (start >= end) return result;
|
||||
|
||||
double sx = rd.w / runs.PdfWidth;
|
||||
double sy = rd.h / runs.PdfHeight;
|
||||
|
||||
int i = start;
|
||||
while (i < end)
|
||||
{
|
||||
var line = runs.Lines[runs.Chars[i].Line];
|
||||
int segEnd = Math.Min(end, line.End);
|
||||
|
||||
// A selected caret slice runs left-to-right for LTR and right-to-left for RTL.
|
||||
// Use its physical extremes rather than assuming the first glyph is on the left.
|
||||
double left = runs.Chars.Skip(i).Take(segEnd - i).Min(c => c.Left);
|
||||
double right = runs.Chars.Skip(i).Take(segEnd - i).Max(c => c.Right);
|
||||
double h = (line.Top - line.Bottom) * sy;
|
||||
double pad = h * 0.12; // a touch of breathing room; tighter than search's 0.30
|
||||
|
||||
result.Add(new Rect(left * sx, rd.h - line.Top * sy - pad,
|
||||
Math.Max((right - left) * sx, 2), Math.Max(h + pad * 2, 2)));
|
||||
i = segEnd;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Paints one page's selection quads onto its overlay. Called while dragging and
|
||||
/// from the tail of RenderAllAnnotations so the quads survive re-renders, exactly like
|
||||
/// search highlights do.</summary>
|
||||
private void ApplyTextSelectionQuads(int page, Canvas canvas)
|
||||
{
|
||||
if (!_txtSelActive && !_txtSelHasRange) return;
|
||||
foreach (var r in SelectionLineRectsForPage(page))
|
||||
{
|
||||
var rect = new Rectangle
|
||||
{
|
||||
Opacity = 60.0 / 255.0,
|
||||
Width = r.Width,
|
||||
Height = r.Height,
|
||||
IsHitTestVisible = false,
|
||||
Tag = "TextSelQuad"
|
||||
};
|
||||
// Live theme binding (net48 rule: a plain brush snapshot won't follow a theme
|
||||
// switch) - the quads recolor the moment the theme or accent changes.
|
||||
rect.SetResourceReference(Shape.FillProperty, "SelectionAccent");
|
||||
Canvas.SetLeft(rect, r.X);
|
||||
Canvas.SetTop(rect, r.Y);
|
||||
canvas.Children.Add(rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>#127 Phase 2: turns the flowing selection into Highlight / Strikethrough /
|
||||
/// Underline annotations - one per selected line, grouped per page so one gesture behaves
|
||||
/// as one annotation (select, move, delete together), and one page-snapshot undo entry so
|
||||
/// Ctrl+Z reverts the whole gesture in a single step.</summary>
|
||||
private void CommitFlowingHighlight(EditTool tool)
|
||||
{
|
||||
var (s, e) = OrderedSelection();
|
||||
var perPage = new List<(int Page, List<Rect> Rects)>();
|
||||
for (int p = s.Page; p <= e.Page; p++)
|
||||
{
|
||||
var rects = SelectionLineRectsForPage(p);
|
||||
if (rects.Count > 0) perPage.Add((p, rects));
|
||||
}
|
||||
if (perPage.Count == 0) { ClearTextSelection(); return; }
|
||||
|
||||
PushPagesSnapshotUndo(perPage.Select(pp => pp.Page));
|
||||
var style = tool == EditTool.Strikethrough ? HighlightStyle.Strikethrough
|
||||
: tool == EditTool.Underline ? HighlightStyle.Underline
|
||||
: HighlightStyle.Fill;
|
||||
int total = 0;
|
||||
foreach (var (page, rects) in perPage)
|
||||
{
|
||||
// One group per page; a single-line highlight stays ungrouped.
|
||||
string gid = rects.Count > 1 ? Guid.NewGuid().ToString("N") : "";
|
||||
if (!_annotations.ContainsKey(page)) _annotations[page] = [];
|
||||
foreach (var r in rects)
|
||||
{
|
||||
var ha = new HighlightAnnotation { PageIndex = page, Bounds = r, Style = style, GroupId = gid };
|
||||
ha.SetColor(tool == EditTool.Highlight ? _highlightColor : _lineAnnotColor);
|
||||
_annotations[page].Add(ha);
|
||||
total++;
|
||||
}
|
||||
}
|
||||
MarkDirty();
|
||||
ClearTextSelection();
|
||||
foreach (var (page, _) in perPage) RenderAllAnnotations(page);
|
||||
SetStatus(string.Format(Loc(style == HighlightStyle.Fill ? "Str_St_HighlightedLines" : style == HighlightStyle.Strikethrough ? "Str_St_StruckLines" : "Str_St_UnderlinedLines"), total));
|
||||
}
|
||||
|
||||
/// <summary>Drops and repaints the quads on every page the selection touches.</summary>
|
||||
private void RepaintTextSelection()
|
||||
{
|
||||
RemoveTextSelQuads();
|
||||
var (s, e) = OrderedSelection();
|
||||
for (int p = s.Page; p <= e.Page; p++)
|
||||
{
|
||||
var canvas = VisibleCanvasForPage(p);
|
||||
if (canvas is not null) ApplyTextSelectionQuads(p, canvas);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveTextSelQuads()
|
||||
{
|
||||
foreach (var canvas in AllPageCanvases())
|
||||
{
|
||||
var toRemove = canvas.Children.OfType<Rectangle>()
|
||||
.Where(r => r.Tag is string s && s == "TextSelQuad").ToList();
|
||||
foreach (var r in toRemove)
|
||||
canvas.Children.Remove(r);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ctrl+A: flowing select-all on the current page (quads over every line) and copy.</summary>
|
||||
private void SelectAllText()
|
||||
{
|
||||
if (_currentFile is null) return;
|
||||
int pageIdx = _currentPage;
|
||||
if (pageIdx < 0) return;
|
||||
|
||||
try
|
||||
{
|
||||
var runs = _textRuns.GetPage(_currentFile, pageIdx);
|
||||
if (runs is null || runs.Chars.Count == 0)
|
||||
{
|
||||
SetStatus(Loc("Str_St_NoTextOnPage"));
|
||||
return;
|
||||
}
|
||||
ClearTextSelection();
|
||||
_txtSelAnchor = (pageIdx, 0);
|
||||
_txtSelFocus = (pageIdx, runs.Chars.Count);
|
||||
_txtSelHasRange = true;
|
||||
RepaintTextSelection();
|
||||
|
||||
_selectedText = TextRunService.TextForRange(runs, 0, runs.Chars.Count, out _);
|
||||
if (string.IsNullOrWhiteSpace(_selectedText))
|
||||
{
|
||||
SetStatus(Loc("Str_St_NoTextOnPage"));
|
||||
ClearTextSelection();
|
||||
return;
|
||||
}
|
||||
Clipboard.SetText(_selectedText);
|
||||
SetStatus(Loc("Str_St_SelectAllCopied"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(string.Format(Loc("Str_St_SelectAllError"), ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private void CopySelectedText()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_selectedText))
|
||||
{
|
||||
Clipboard.SetText(_selectedText);
|
||||
SetStatus(Loc("Str_St_Copied"));
|
||||
}
|
||||
else
|
||||
{
|
||||
SetStatus(Loc("Str_St_NoTextSelected"));
|
||||
}
|
||||
}
|
||||
|
||||
internal void ClearTextSelection()
|
||||
{
|
||||
if (_selectRect is not null)
|
||||
{
|
||||
// Remove from the rect's ACTUAL parent. Since the cross-page marquee rework the
|
||||
// selection box lives on the window-level MarqueeLayer, not the page canvas, so
|
||||
// removing from _activeCanvas was a silent no-op that orphaned the box on the
|
||||
// layer until app restart (#121).
|
||||
(_selectRect.Parent as Canvas)?.Children.Remove(_selectRect);
|
||||
_selectRect = null;
|
||||
}
|
||||
_selectedText = null;
|
||||
_txtSelActive = false;
|
||||
_txtSelHasRange = false;
|
||||
_txtSelDragStarted = false;
|
||||
_txtSelCommitTool = null;
|
||||
RemoveTextSelQuads();
|
||||
}
|
||||
|
||||
/// <summary>Marquee fallback: rectangle region copy, used when a drag starts on EMPTY page
|
||||
/// (scans, margins). Kept word-box based on purpose - on pages with no text layer there is
|
||||
/// nothing to flow along, and this is also what the annotation box-select falls back to.</summary>
|
||||
private void ExtractTextFromRegion(int pageIdx, Rect canvasBounds)
|
||||
{
|
||||
if (_currentFile is null || pageIdx < 0) return;
|
||||
if (!_renderDims.ContainsKey(pageIdx)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var (renderW, renderH) = _renderDims[pageIdx];
|
||||
|
||||
using var pigDoc = PdfPigDoc.Open(_currentFile);
|
||||
if (pageIdx >= pigDoc.NumberOfPages) return;
|
||||
var page = pigDoc.GetPage(pageIdx + 1); // PdfPig is 1-based
|
||||
|
||||
double pdfW = page.Width;
|
||||
double pdfH = page.Height;
|
||||
double sx = pdfW / renderW;
|
||||
double sy = pdfH / renderH;
|
||||
|
||||
// Convert canvas rect to PDF coordinates (flip Y - PDF origin is bottom-left)
|
||||
double pdfLeft = canvasBounds.Left * sx;
|
||||
double pdfRight = canvasBounds.Right * sx;
|
||||
double pdfTop = pdfH - (canvasBounds.Top * sy);
|
||||
double pdfBottom = pdfH - (canvasBounds.Bottom * sy);
|
||||
// pdfTop > pdfBottom because of Y flip
|
||||
double pdfMinY = Math.Min(pdfTop, pdfBottom);
|
||||
double pdfMaxY = Math.Max(pdfTop, pdfBottom);
|
||||
|
||||
var words = page.GetWords()
|
||||
.Where(w =>
|
||||
{
|
||||
var bb = w.BoundingBox;
|
||||
double cx = (bb.Left + bb.Right) / 2;
|
||||
double cy = (bb.Bottom + bb.Top) / 2;
|
||||
return cx >= pdfLeft && cx <= pdfRight && cy >= pdfMinY && cy <= pdfMaxY;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (words.Count == 0)
|
||||
{
|
||||
SetStatus(Loc("Str_St_NoTextInSelection"));
|
||||
ClearTextSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedText = WordsToText(words);
|
||||
|
||||
Clipboard.SetText(_selectedText);
|
||||
int wordCount = words.Count;
|
||||
SetStatus(string.Format(Loc("Str_St_CopiedWords"), wordCount));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(string.Format(Loc("Str_St_ExtractError"), ex.Message));
|
||||
ClearTextSelection();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// This pane's tab band: which tabs are in the strip, which of them owns an edge, what the card's
|
||||
// top corners do, where the focus ring runs, and the drag that reorders them or hands one to the
|
||||
// other pane.
|
||||
//
|
||||
// Ported from KillerShell (FilePane.xaml + Tabs.cs + DualPane.cs + PaneDrag.cs). The strip is an
|
||||
// ItemsControl bound to _sessions over a UniformGrid, and every visual decision below is a
|
||||
// NOTIFYING FLAG ON THE SESSION that a template trigger reads - not a property written onto a
|
||||
// code-built Border. That is the whole point: there is one place each rule is expressed, so a
|
||||
// fix to one edge case cannot break the next one.
|
||||
//
|
||||
// Two consequences worth knowing before changing anything here:
|
||||
// * UniformGrid divides the band equally, so the last visible tab ALWAYS reaches the strip's
|
||||
// right edge. Edge ownership is decided, never measured. The old strip measured it after
|
||||
// every reflow, which is why the halo came and went with the pane width.
|
||||
// * A collapsed child is not counted when UniformGrid divides the band, so windowing tabs out
|
||||
// into the chevron needs no width arithmetic at all - the survivors fill the band on their own.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
/// <summary>Bind the strip to this pane's sessions. Called once, from InitSplitPanes.</summary>
|
||||
private void InitTabStrip()
|
||||
{
|
||||
if (TabStrip != null) TabStrip.ItemsSource = _sessions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one funnel. Every add, close, switch, drag-reorder and resize ends here, and it is the
|
||||
/// only thing that writes the strip's state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Still called RebuildTabStrip because ~20 call sites and RebuildTabStripExt already say so,
|
||||
/// but it rebuilds nothing: the ItemsControl repaints itself off the collection and the
|
||||
/// notifying flags.
|
||||
/// </remarks>
|
||||
private void RebuildTabStrip()
|
||||
{
|
||||
if (TabStrip == null || TabStripBorder == null) return;
|
||||
|
||||
foreach (var t in _sessions) t.RefreshTabLabel();
|
||||
|
||||
int docTabs = _sessions.Count(t => t.Doc != null || t.DeferredPath != null);
|
||||
// Only show the strip once THIS pane has more than one document - a single open PDF
|
||||
// doesn't need tabs, split or not. (KillerShell forces the band on in both panes whenever
|
||||
// either one has 2+ tabs, so the two card tops always line up; the simpler per-pane
|
||||
// rule is used here instead, so a lone tab never shows a bar even while split.)
|
||||
bool show = docTabs > 1;
|
||||
TabStripBorder.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
// The -1 tucks the card's top border a pixel into the band, so the active tab and the card
|
||||
// read as one surface. ONLY while there IS a band: a collapsed element contributes no
|
||||
// height, so with no strip the -1 lifts this pane a pixel above the other one instead of
|
||||
// tucking under anything. The bandless card gets 3px of top air instead of 0 - at 0 it
|
||||
// opened 3px too high against the chrome (2026-08-01).
|
||||
CardRow.Margin = new Thickness(0, show ? -1 : 3, 0, 0);
|
||||
|
||||
// Win98 tabs sit on a raised client frame. Keep the frame's vertical sides and bottom;
|
||||
// the one-pixel top ledge is drawn by TabBarRing so the active tab can cover its own
|
||||
// segment while the ledge remains visible beneath the inactive tabs.
|
||||
bool retroTheme = Services.ThemeManager.Current == Services.Theme.SE98;
|
||||
bool retroTabs = show && retroTheme;
|
||||
if (retroTheme)
|
||||
{
|
||||
PaneBevelOuterDark.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelTopLeftBrush");
|
||||
PaneBevelOuterLight.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelBottomRightBrush");
|
||||
PaneBevelInnerDark.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelInnerTopLeftBrush");
|
||||
PaneBevelInnerLight.SetResourceReference(Border.BorderBrushProperty, "DocumentPaneBevelInnerBottomRightBrush");
|
||||
}
|
||||
else
|
||||
{
|
||||
PaneBevelOuterDark.SetResourceReference(Border.BorderBrushProperty, "PaneBevelDarkBrush");
|
||||
PaneBevelOuterLight.SetResourceReference(Border.BorderBrushProperty, "PaneBevelLightBrush");
|
||||
PaneBevelInnerDark.SetResourceReference(Border.BorderBrushProperty, "PaneBevelDark2Brush");
|
||||
PaneBevelInnerLight.SetResourceReference(Border.BorderBrushProperty, "PaneBevelLight2Brush");
|
||||
}
|
||||
if (retroTabs)
|
||||
{
|
||||
PaneBorder.BorderThickness = new Thickness(1, 0, 1, 1);
|
||||
PaneBevelOuterDark.BorderThickness = new Thickness(1, 0, 0, 0);
|
||||
PaneBevelOuterLight.BorderThickness = new Thickness(0, 0, 1, 1);
|
||||
PaneBevelInnerDark.BorderThickness = new Thickness(1, 0, 0, 0);
|
||||
// PaneBorder + the outer dark bevel are the complete Win98 right edge.
|
||||
// A third same-color inner rule made the edge fat and produced a one-pixel
|
||||
// step where the selected last tab joined it.
|
||||
PaneBevelInnerLight.BorderThickness = new Thickness(0, 0, 0, 1);
|
||||
}
|
||||
else if (retroTheme)
|
||||
{
|
||||
// With no tab band there is nothing for a raised client frame to join. The old
|
||||
// generic fallback below reapplied all four 98SE bevel resources and drew a heavy
|
||||
// rectangle around the entire document pane. Keep the single-document client
|
||||
// flush; the classic frame is only part of the multi-tab treatment above.
|
||||
PaneBorder.BorderThickness = new Thickness(0);
|
||||
PaneBevelOuterDark.BorderThickness = new Thickness(0);
|
||||
PaneBevelOuterLight.BorderThickness = new Thickness(0);
|
||||
PaneBevelInnerDark.BorderThickness = new Thickness(0);
|
||||
PaneBevelInnerLight.BorderThickness = new Thickness(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
PaneBorder.BorderThickness = new Thickness(1);
|
||||
PaneBevelOuterDark.SetResourceReference(Border.BorderThicknessProperty, "PaneBevelLightThickness");
|
||||
PaneBevelOuterLight.SetResourceReference(Border.BorderThicknessProperty, "PaneBevelDarkThickness");
|
||||
PaneBevelInnerDark.SetResourceReference(Border.BorderThicknessProperty, "PaneBevel2LightThickness");
|
||||
PaneBevelInnerLight.SetResourceReference(Border.BorderThicknessProperty, "PaneBevel2DarkThickness");
|
||||
}
|
||||
|
||||
// Which tabs fit at this width, before anything below asks which is on an edge.
|
||||
ApplyTabWindow();
|
||||
|
||||
// First and last VISIBLE, not first and last in the list. Both are about the strip's own
|
||||
// edges: IsLast drops the divider that would otherwise land on the right edge as a stray
|
||||
// rule, and IsFirst/IsLast keep the tab from drawing the outer ring side that the band
|
||||
// already draws. With tabs windowed out, the tab sitting on an edge is not the one at the
|
||||
// end of the collection.
|
||||
//
|
||||
// And NOT the last visible tab while the chevron is showing: the chevron is what sits on
|
||||
// the band's right edge then, so the tab is a middle tab in every way that matters. Told
|
||||
// otherwise it dropped the divider that separates it from the chevron AND handed its right
|
||||
// side to the band, which drew that side at the band's edge - past the chevron, as an
|
||||
// accent stripe up the far right with nothing under it.
|
||||
bool chevron = TabOverflowBtn.Visibility == Visibility.Visible;
|
||||
|
||||
var strip = _sessions.Where(t => t.IsStripVisible).ToList();
|
||||
foreach (var t in _sessions)
|
||||
{
|
||||
t.IsFirst = false;
|
||||
t.IsLast = false;
|
||||
t.RetroBeforeActive = false;
|
||||
t.RetroAfterActive = false;
|
||||
t.RetroLastInactive = false;
|
||||
t.UseRetroTabChrome = retroTabs;
|
||||
}
|
||||
if (strip.Count > 0)
|
||||
{
|
||||
strip[0].IsFirst = true;
|
||||
strip[strip.Count - 1].IsLast = !chevron;
|
||||
|
||||
int activeIndex = strip.IndexOf(_active!);
|
||||
if (retroTabs && activeIndex >= 0)
|
||||
{
|
||||
if (activeIndex > 0) strip[activeIndex - 1].RetroBeforeActive = true;
|
||||
if (activeIndex + 1 < strip.Count) strip[activeIndex + 1].RetroAfterActive = true;
|
||||
if (!chevron && !strip[^1].IsActive) strip[^1].RetroLastInactive = true;
|
||||
}
|
||||
}
|
||||
|
||||
SyncPaneLeadingCorner();
|
||||
UpdatePaneFocusRing();
|
||||
UpdateTabStripFade();
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)UpdateFooterFade);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// OVERFLOW
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// The strip is a UniformGrid, so every tab takes an equal share of the band whatever the
|
||||
// count - right up to a point, and then off a cliff. Eight tabs in a half-width pane came out
|
||||
// around forty pixels each, which is not a label, it is a shape. A tab you cannot read is a
|
||||
// tab you have to click to identify, and at that point the strip has stopped being navigation.
|
||||
//
|
||||
// So the COUNT is capped rather than the width. As many tabs as fit at TabFloorWidth stay in
|
||||
// the strip and the rest are collapsed. The chevron at the right end lists every tab, so
|
||||
// nothing is unreachable.
|
||||
//
|
||||
// Scrolling was the other option and is what a browser does. It lost because the band is a
|
||||
// bordered surface the pane's focus ring runs along, and a scrolled band cannot be edge to
|
||||
// edge - the ring would have to stop somewhere that is not a corner.
|
||||
|
||||
/// <summary>Narrowest a tab may get before the strip stops taking more.</summary>
|
||||
/// <remarks>
|
||||
/// Picked from what it has to hold rather than off a grid: 120px is about sixteen characters
|
||||
/// at this size once the close x and the padding are paid for - "Quarterly-Repo...", enough to
|
||||
/// tell two documents apart. Much below a hundred and the ellipsis starts eating the part that
|
||||
/// distinguishes them, which is the whole job.
|
||||
/// </remarks>
|
||||
private const double TabFloorWidth = 120;
|
||||
|
||||
/// <summary>What the chevron takes out of the band while it is showing.</summary>
|
||||
private const double TabChevronWidth = 26;
|
||||
|
||||
/// <summary>Index of the leftmost tab currently in the strip. 0 whenever they all fit.</summary>
|
||||
/// <remarks>
|
||||
/// Per pane, like the sessions themselves: the two strips are different widths and hold
|
||||
/// different numbers of tabs, so one shared index would have each pane scrolling the other.
|
||||
/// </remarks>
|
||||
private int _tabWindow;
|
||||
|
||||
/// <summary>
|
||||
/// Decide which of this pane's tabs are in the strip at its current width, and show or hide
|
||||
/// the chevron. Called from RebuildTabStrip, before anything reads which tab is on an edge.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The window is a contiguous RUN, not a set: tabs keep their order and their neighbors, so a
|
||||
/// strip that has moved still reads like the tab bar it was. It shifts the least it can to
|
||||
/// keep the active tab on screen, which is the one invariant that matters - a tab you just
|
||||
/// switched to and cannot see is worse than no strip at all.
|
||||
/// </remarks>
|
||||
private void ApplyTabWindow()
|
||||
{
|
||||
int n = _sessions.Count;
|
||||
if (n == 0) { TabOverflowBtn.Visibility = Visibility.Collapsed; return; }
|
||||
|
||||
// ActualWidth is 0 until the band has been measured once - on the first pass, and on any
|
||||
// pass that runs while the pane is hidden. Falling back to the pane's own width keeps the
|
||||
// answer sane instead of capping the strip at one tab and having to be undone by the
|
||||
// SizeChanged that follows.
|
||||
double avail = TabStripBorder.ActualWidth > 0 ? TabStripBorder.ActualWidth : ActualWidth;
|
||||
|
||||
// Two passes, because the chevron's width changes the answer that decides whether there is
|
||||
// a chevron. Asked without it first: if everything fits there is none, and the whole band
|
||||
// belongs to the strip.
|
||||
int cap = (int)(avail / TabFloorWidth);
|
||||
bool overflow = cap < n;
|
||||
if (overflow)
|
||||
{
|
||||
cap = Math.Max(1, (int)((avail - TabChevronWidth) / TabFloorWidth));
|
||||
if (cap >= n) overflow = false;
|
||||
}
|
||||
|
||||
TabOverflowBtn.Visibility = overflow ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
int start = 0;
|
||||
if (overflow)
|
||||
{
|
||||
// Clamped before the active tab is considered, so a window left pointing past the end
|
||||
// by a close does not survive as a scroll nobody asked for.
|
||||
start = Math.Max(0, Math.Min(_tabWindow, n - cap));
|
||||
|
||||
int active = _active == null ? -1 : _sessions.IndexOf(_active);
|
||||
if (active >= 0 && active < start) start = active;
|
||||
else if (active >= 0 && active > start + cap - 1) start = active - cap + 1;
|
||||
|
||||
_tabWindow = start;
|
||||
}
|
||||
else
|
||||
{
|
||||
_tabWindow = 0;
|
||||
cap = n;
|
||||
}
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
_sessions[i].IsStripVisible = i >= start && i < start + cap;
|
||||
}
|
||||
|
||||
/// <summary>The band was resized, so the strip may hold a different number of tabs.</summary>
|
||||
/// <remarks>
|
||||
/// Goes through RebuildTabStrip rather than calling ApplyTabWindow alone: a different set of
|
||||
/// visible tabs is a different first and last tab, and those are the card's corner rounding
|
||||
/// and the focus ring's outer verticals as much as they are the strip.
|
||||
/// </remarks>
|
||||
private void TabBarResized()
|
||||
{
|
||||
if (_sessions.Count == 0 || _inTabResize) return;
|
||||
|
||||
// Reentrancy guard, not an optimization. RebuildTabStrip writes CardRow.Margin and flips
|
||||
// the band's own Visibility, either of which can raise SizeChanged again from inside this
|
||||
// call - and a layout loop in WPF is not a slow app, it is a hung one. The pass that
|
||||
// follows would compute the same answer anyway.
|
||||
_inTabResize = true;
|
||||
try { RebuildTabStrip(); }
|
||||
finally { _inTabResize = false; }
|
||||
}
|
||||
|
||||
private bool _inTabResize;
|
||||
|
||||
private void TabStripBorder_SizeChanged(object sender, SizeChangedEventArgs e) => TabBarResized();
|
||||
|
||||
/// <summary>The chevron: every tab in this pane, hidden ones included, in strip order.</summary>
|
||||
/// <remarks>
|
||||
/// EVERY tab, not only the overflowed ones. A list that shows just what is off screen makes
|
||||
/// you work out which those are before you can use it, and the visible ones cost nothing to
|
||||
/// include. Built on each open rather than kept: titles change on every save and load.
|
||||
/// </remarks>
|
||||
private void TabOverflow_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var menu = MakeThemedMenu();
|
||||
foreach (var t in _sessions)
|
||||
{
|
||||
var sess = t;
|
||||
// Doubled, because a lone underscore in a MenuItem header is an access-key marker:
|
||||
// "Q3_Report" would draw as "Q3Report" with an R underlined, and file names carry
|
||||
// underscores all the time.
|
||||
var item = MakeMenuItem(sess.TabLabel.Replace("_", "__"), (_, _) => SwitchToTab(sess), glyph: "");
|
||||
// Bold rather than a check mark: the menu has no icon column to put one in.
|
||||
if (sess.IsActive) item.FontWeight = FontWeights.Bold;
|
||||
menu.Items.Add(item);
|
||||
}
|
||||
menu.PlacementTarget = TabOverflowBtn;
|
||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// CARD CORNERS + FOCUS RING
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Square off the card's top corners under a flush active tab.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each top corner squares only when the tab sitting on it is the ACTIVE one: the first tab
|
||||
/// owns the top-left, the last owns the top-right. That tab's outer edge is flat and flush
|
||||
/// with the card's, so a curve underneath cuts a notch out from under a square tab. An
|
||||
/// inactive tab is window-colored and so a different surface anyway, and the card keeps its
|
||||
/// rounding under it; no strip at all and the card takes its full radius back.
|
||||
///
|
||||
/// Read off the MODEL, never re-measured from the visual tree. Skipped in full screen, where
|
||||
/// ApplyFullScreen owns the radius and squares all four.
|
||||
/// </remarks>
|
||||
private void SyncPaneLeadingCorner()
|
||||
{
|
||||
// PaneBorder / PaneShadow, not DocPaneBorder / DocPaneShadow: those names are the
|
||||
// window's forwards to the FOCUSED pane, and this runs for whichever pane's strip
|
||||
// changed. Using them here squares pane A's corners when pane B's tabs move.
|
||||
if (PaneBorder == null || _fullScreen) return;
|
||||
double r = TryFindResource("RadCard") is CornerRadius rc ? rc.TopLeft : 6;
|
||||
|
||||
bool strip = TabStripBorder != null && TabStripBorder.Visibility == Visibility.Visible;
|
||||
bool firstActive = strip && _active?.IsFirst == true;
|
||||
bool lastActive = strip && _active?.IsLast == true;
|
||||
|
||||
var cr = new CornerRadius(firstActive ? 0 : r, lastActive ? 0 : r, r, r);
|
||||
PaneBorder.CornerRadius = cr;
|
||||
if (PaneShadow != null) PaneShadow.CornerRadius = cr;
|
||||
// Keep the ring's top radii in step with the card's, so its curved sides land exactly on
|
||||
// the card's own left/right border rather than beside them.
|
||||
if (TabBarRing != null)
|
||||
{
|
||||
TabBarRing.CornerRadius = new CornerRadius(cr.TopLeft, cr.TopRight, 0, 0);
|
||||
if (Services.ThemeManager.Current == Services.Theme.SE98)
|
||||
{
|
||||
// The raised pane's light top rule is the horizontal part of the selected-tab
|
||||
// route. The selected tab covers its own segment; the remaining rule turns up
|
||||
// at the tab sides and therefore reads as one continuous classic outline.
|
||||
TabBarRing.BorderThickness = new Thickness(0, 1, 0, 0);
|
||||
TabBarRing.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Modern themes do not use the Win98 edge overlays, so the ring carries the
|
||||
// side only where the active tab makes that card corner square.
|
||||
TabBarRing.BorderThickness = new Thickness(firstActive ? 1 : 0, 1, lastActive ? 1 : 0, 0);
|
||||
TabBarRing.SetResourceReference(Border.BorderBrushProperty, "PaneEdgeBrush");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mark this pane's focus state on its tabs and draw the ring's outer verticals.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The ring has to continue UP and AROUND the active tab, or it stops dead at the band and the
|
||||
/// tab and card read as two surfaces. The tab's own share of that is a template trigger on
|
||||
/// PaneFocused; all this does is set the flag.
|
||||
///
|
||||
/// PaneDimmed is the other half - the active tab of the pane that does NOT have focus drops
|
||||
/// its lip to the card's border color, because two lips at full accent both claim to be the
|
||||
/// live pane. Deliberately NOT !PaneFocused: with one pane open both are false and that pane's
|
||||
/// lip stays bright.
|
||||
///
|
||||
/// The outermost verticals come from the BAND, not from the tab: a first or last tab's own
|
||||
/// outer border sits on the ScrollViewer's clip edge and gets cut, so whether it survived
|
||||
/// depended on how the UniformGrid divided a fractional band width. TabEdgeLeft/Right are
|
||||
/// anchored to the band's own edges, which are the card's edges, so there is no arithmetic to
|
||||
/// land wrong.
|
||||
/// </remarks>
|
||||
private void UpdatePaneFocusRing()
|
||||
{
|
||||
bool split = Host?.IsSplitView == true;
|
||||
bool retro = Services.ThemeManager.Current == Services.Theme.SE98;
|
||||
// Modern themes only need a focus ring when panes are split. 98SE uses the
|
||||
// selected pane's surface color as its focus indicator, including single-pane mode.
|
||||
bool paneActive = PaneHasFocus && (split || retro);
|
||||
bool lit = PaneHasFocus && split && !retro;
|
||||
|
||||
// TabBarRing is the pane border's top segment inside the tab band. Corner syncing
|
||||
// assigns its geometry and an idle brush, so focus must restore the live accent here
|
||||
// every time the tab state is rebuilt. Without this assignment, the active tab and
|
||||
// the vertical card edges lit up while the horizontal segments beside the tab stayed
|
||||
// dark, leaving the focused-pane perimeter visibly broken.
|
||||
if (TabBarRing != null && !retro)
|
||||
TabBarRing.SetResourceReference(Border.BorderBrushProperty,
|
||||
lit ? "TabActiveRingBrush" : "PaneEdgeBrush");
|
||||
|
||||
foreach (var t in _sessions)
|
||||
{
|
||||
// 98SE uses PaneFocused only for the shared darker pane/tab surface. Its focus
|
||||
// thickness resources are zero, so this never revives the modern accent outline.
|
||||
t.PaneFocused = paneActive && t.IsActive;
|
||||
t.PaneDimmed = split && !paneActive && t.IsActive;
|
||||
}
|
||||
|
||||
if (PaneBorder != null)
|
||||
PaneBorder.SetResourceReference(Border.BackgroundProperty,
|
||||
retro ? (paneActive ? "FocusedPaneBrush" : "TabInactiveBrush") : "BgCanvas");
|
||||
if (PaneShadow != null)
|
||||
PaneShadow.SetResourceReference(Border.BackgroundProperty,
|
||||
retro ? (paneActive ? "FocusedPaneBrush" : "TabInactiveBrush") : "BgCanvas");
|
||||
|
||||
// Same ownership rule the card's corner rounding uses, read off the tab rather than
|
||||
// recomputed: with the strip windowed the tab on an edge is not the one at the end of the
|
||||
// list, and two places working that out separately is two places to get it wrong.
|
||||
bool firstActive = _active?.IsFirst == true;
|
||||
bool lastActive = _active?.IsLast == true;
|
||||
bool firstInactiveRetro = retro && _sessions.Any(t => t.IsStripVisible && t.IsFirst && !t.IsActive);
|
||||
bool lastInactiveRetro = retro && _sessions.Any(t => t.RetroLastInactive);
|
||||
// The XAML declares these with NO Background - unlike KillerShell's copy, which paints
|
||||
// them PrimaryBrush directly in markup, KillerPDF's accent key is only known at runtime
|
||||
// (SelectionAccent, resolved the same way SetFocusHalo resolves the card border). Without
|
||||
// this they toggle Visible and still draw nothing: a transparent Border is invisible
|
||||
// whatever its Visibility says.
|
||||
if (TabEdgeLeft != null)
|
||||
{
|
||||
if (retro)
|
||||
{
|
||||
// This is the OUTER gray frame, not a duplicate highlight. The active first
|
||||
// tab is inset one pixel: its own white bevel lands at x+1 and its inset light
|
||||
// gray bevel at x+2, exactly where the pane draws those same two raised layers.
|
||||
// Keeping the three responsibilities separate makes the complete side read
|
||||
// gray / white / light-gray instead of a flat or doubled white line.
|
||||
// Run through the band's final row so it meets the card edge below. Leaving the
|
||||
// inactive case one pixel short exposed a literal gap in the left frame.
|
||||
TabEdgeLeft.Margin = new Thickness(0, firstActive ? 3 : 5, 0, 0);
|
||||
TabEdgeLeft.Visibility = firstActive || firstInactiveRetro
|
||||
? Visibility.Visible : Visibility.Collapsed;
|
||||
TabEdgeLeft.SetResourceReference(Border.BackgroundProperty, "PaneBorderBrush");
|
||||
}
|
||||
else
|
||||
{
|
||||
TabEdgeLeft.Margin = new Thickness(0, 9, 0, 0);
|
||||
TabEdgeLeft.Visibility = lit && firstActive ? Visibility.Visible : Visibility.Collapsed;
|
||||
TabEdgeLeft.SetResourceReference(Border.BackgroundProperty, "SelectionAccent");
|
||||
}
|
||||
}
|
||||
if (TabEdgeRight != null)
|
||||
{
|
||||
// The tab now reserves its final pixel, so this is only the outer frame. Because
|
||||
// the border lives inside TabScroll it shares the tab's vertical origin and no
|
||||
// longer starts above the tab or cuts through the scrollbar-arrow corner.
|
||||
if (retro && lastInactiveRetro)
|
||||
{
|
||||
TabEdgeRight.Margin = new Thickness(0, 5, 0, 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
TabEdgeRight.Margin = retro ? new Thickness(0, 3, 0, 0) : new Thickness(0, 9, 0, 0);
|
||||
}
|
||||
TabEdgeRight.Visibility = retro
|
||||
? (lastActive || lastInactiveRetro ? Visibility.Visible : Visibility.Collapsed)
|
||||
: (lit && lastActive ? Visibility.Visible : Visibility.Collapsed);
|
||||
TabEdgeRight.SetResourceReference(Border.BackgroundProperty, retro ? "PaneBorderBrush" : "SelectionAccent");
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// TAB GESTURES
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// Left-click switches on mouse-UP, so a press can begin a drag without switching first.
|
||||
|
||||
private void Tab_MouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not DocumentSession s) return;
|
||||
if (e.ChangedButton == MouseButton.Middle) { e.Handled = true; CloseTab(s); }
|
||||
}
|
||||
|
||||
private void Tab_RightClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.DataContext is not DocumentSession s) return;
|
||||
var menu = MakeThemedMenu();
|
||||
menu.Items.Add(MakeMenuItem(Loc("Str_Ctx_CloseTab"), (_, _) => CloseTab(s), "Ctrl+W", ""));
|
||||
var others = MakeMenuItem(Loc("Str_Ctx_CloseOthers"), (_, _) => CloseOtherTabs(s), "Ctrl+Shift+W", "");
|
||||
others.IsEnabled = _sessions.Count(z => z.Doc != null || z.DeferredPath != null) > 1;
|
||||
menu.Items.Add(others);
|
||||
menu.PlacementTarget = fe;
|
||||
menu.IsOpen = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void CloseTab_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button b && b.Tag is DocumentSession s) CloseTab(s);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// DRAG: reorder within this pane, or hand the tab to the other one
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// Arm on press; past the threshold the grabbed tab glues to the cursor and its neighbors
|
||||
// glide aside as it crosses their layout-slot midpoints. A plain click still switches on
|
||||
// release.
|
||||
//
|
||||
// Over the OTHER pane the real tab cannot follow the hand - it is still parked in the strip it
|
||||
// came from - so a ghost takes over and the reorder stands down (Shell/PaneDrag.cs). Coming
|
||||
// back into this pane hands control straight back.
|
||||
|
||||
private DocumentSession? _tabDragSession;
|
||||
private Point _tabDragStart;
|
||||
private double _tabGrabDX;
|
||||
private bool _tabDragging;
|
||||
|
||||
/// <summary>Cursor offset inside the grabbed tab, so the window's ghost can sit exactly where
|
||||
/// the tab did when it was picked up.</summary>
|
||||
internal double TabGrabOffsetX => _tabGrabDX;
|
||||
|
||||
private FrameworkElement? TabContainer(DocumentSession s)
|
||||
=> TabStrip?.ItemContainerGenerator.ContainerFromItem(s) as FrameworkElement;
|
||||
|
||||
/// <summary>Did the press land on a button (the close x) rather than on the tab itself?</summary>
|
||||
private static bool InsideButton(object src)
|
||||
{
|
||||
var d = src as System.Windows.DependencyObject;
|
||||
while (d != null && d is not Button && d is not Window)
|
||||
d = VisualTreeHelper.GetParent(d);
|
||||
return d is Button;
|
||||
}
|
||||
|
||||
/// <summary>Midpoint X of a tab's LAYOUT slot (ignores any in-flight slide transform).</summary>
|
||||
private static double LayoutMidX(FrameworkElement fe)
|
||||
{
|
||||
var slot = System.Windows.Controls.Primitives.LayoutInformation.GetLayoutSlot(fe);
|
||||
return slot.X + slot.Width / 2;
|
||||
}
|
||||
|
||||
/// <summary>Set a tab's horizontal offset immediately - glues the grabbed tab to the cursor.</summary>
|
||||
private static void SetTabOffsetX(FrameworkElement tab, double x)
|
||||
{
|
||||
if (tab.RenderTransform is not TranslateTransform tt)
|
||||
{
|
||||
tt = new TranslateTransform();
|
||||
tab.RenderTransform = tt;
|
||||
}
|
||||
tt.BeginAnimation(TranslateTransform.XProperty, null); // drop any prior animation so the set sticks
|
||||
tt.X = x;
|
||||
}
|
||||
|
||||
/// <summary>Glide a just-reordered neighbor from where it was into its new slot, so a swap
|
||||
/// reads as a movement instead of an instant jump.</summary>
|
||||
private static void AnimateTabSlide(FrameworkElement? tab, double fromX)
|
||||
{
|
||||
if (tab == null) return;
|
||||
if (tab.RenderTransform is not TranslateTransform tt)
|
||||
{
|
||||
tt = new TranslateTransform();
|
||||
tab.RenderTransform = tt;
|
||||
}
|
||||
tt.BeginAnimation(TranslateTransform.XProperty, null);
|
||||
var anim = new System.Windows.Media.Animation.DoubleAnimation(fromX, 0,
|
||||
new Duration(TimeSpan.FromMilliseconds(140)))
|
||||
{
|
||||
EasingFunction = new System.Windows.Media.Animation.CubicEase
|
||||
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut },
|
||||
};
|
||||
tt.BeginAnimation(TranslateTransform.XProperty, anim);
|
||||
}
|
||||
|
||||
internal void CleanupTabTransforms()
|
||||
{
|
||||
foreach (var s in _sessions)
|
||||
if (TabContainer(s) is { } c)
|
||||
{
|
||||
c.RenderTransform = null;
|
||||
Panel.SetZIndex(c, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void Tab_DragDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement bd || bd.DataContext is not DocumentSession s) return;
|
||||
if (InsideButton(e.OriginalSource)) return; // the close x handles its own click
|
||||
_tabDragSession = s;
|
||||
_tabDragStart = e.GetPosition(TabStrip);
|
||||
_tabGrabDX = e.GetPosition(bd).X;
|
||||
_tabDragging = false;
|
||||
bd.CaptureMouse();
|
||||
// Own the press entirely so it cannot bubble to the title bar's window-drag handler, and
|
||||
// so the mouse capture rather than the caption hit-test drives the drag - which is what
|
||||
// makes it Y-independent.
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void Tab_DragMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement bd || !bd.IsMouseCaptured || _tabDragSession is null) return;
|
||||
var cont = TabContainer(_tabDragSession);
|
||||
if (cont == null) return;
|
||||
|
||||
double x = e.GetPosition(TabStrip).X;
|
||||
if (!_tabDragging && Math.Abs(x - _tabDragStart.X) < SystemParameters.MinimumHorizontalDragDistance) return;
|
||||
_tabDragging = true;
|
||||
Panel.SetZIndex(cont, 3); // the grabbed tab rides above its neighbors
|
||||
|
||||
var over = Host?.TabDropTarget(this, e);
|
||||
Host?.UpdateTabDragFeedback(this, _tabDragSession, e, over);
|
||||
if (over != null) return; // the ghost has it; no reorder while the pointer is away
|
||||
|
||||
int cur = _sessions.IndexOf(_tabDragSession);
|
||||
double slide = cont.ActualWidth;
|
||||
double rawLeft = x - _tabGrabDX;
|
||||
double leftEdge = rawLeft;
|
||||
double rightEdge = rawLeft + cont.ActualWidth;
|
||||
double maxLeft = Math.Max(0, TabStrip.ActualWidth - slide);
|
||||
double renderLeft = Math.Min(Math.Max(0, rawLeft), maxLeft);
|
||||
|
||||
// Swap when the ADVANCING edge crosses a neighbor's layout-slot midpoint. Edge against
|
||||
// midpoint gives natural hysteresis, so a tab parked on a boundary does not bounce.
|
||||
bool swapped = false;
|
||||
if (cur + 1 < _sessions.Count && TabContainer(_sessions[cur + 1]) is { } right && rightEdge > LayoutMidX(right))
|
||||
{
|
||||
_sessions.Move(cur + 1, cur);
|
||||
AnimateTabSlide(TabContainer(_sessions[cur]), slide); // it jumped left; glide it in from the right
|
||||
swapped = true;
|
||||
}
|
||||
else if (cur - 1 >= 0 && TabContainer(_sessions[cur - 1]) is { } left && leftEdge < LayoutMidX(left))
|
||||
{
|
||||
_sessions.Move(cur - 1, cur);
|
||||
AnimateTabSlide(TabContainer(_sessions[cur]), -slide); // it jumped right; glide it in from the left
|
||||
swapped = true;
|
||||
}
|
||||
|
||||
// After a swap the grabbed tab's slot has moved by a neighbor's width; refresh layout so
|
||||
// the new slot is current, then offset it back under the cursor.
|
||||
if (swapped) TabStrip.UpdateLayout();
|
||||
var dragged = TabContainer(_tabDragSession);
|
||||
if (dragged == null) return;
|
||||
var slot = System.Windows.Controls.Primitives.LayoutInformation.GetLayoutSlot(dragged);
|
||||
SetTabOffsetX(dragged, renderLeft - slot.X);
|
||||
}
|
||||
|
||||
private void Tab_DragUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement bd || !bd.IsMouseCaptured) return;
|
||||
bd.ReleaseMouseCapture();
|
||||
bool wasDragging = _tabDragging;
|
||||
var s = _tabDragSession;
|
||||
_tabDragSession = null;
|
||||
_tabDragging = false;
|
||||
Host?.HideTabDragFeedback(); // the ghost goes whatever the drop turns out to be
|
||||
|
||||
if (!wasDragging)
|
||||
{
|
||||
if (s != null) SwitchToTab(s);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dropped over the OTHER pane? Then this was a move, not a reorder. Checked on RELEASE
|
||||
// rather than mid-drag on purpose: moving a tab between panes re-creates its container,
|
||||
// which would pull the mouse capture out from under the drag that is still running.
|
||||
if (s != null && Host?.TabDropTarget(this, e) is { } target)
|
||||
{
|
||||
Host.MoveTabToPane(this, target, s, e);
|
||||
return;
|
||||
}
|
||||
|
||||
RebuildTabStrip(); // a reorder may have moved the active tab on or off an edge
|
||||
|
||||
// Settle the grabbed tab from its dragged offset into its final slot.
|
||||
var cont = s != null ? TabContainer(s) : null;
|
||||
if (cont?.RenderTransform is TranslateTransform tt && Math.Abs(tt.X) > 0.5)
|
||||
{
|
||||
var settle = new System.Windows.Media.Animation.DoubleAnimation(0,
|
||||
new Duration(TimeSpan.FromMilliseconds(120)))
|
||||
{
|
||||
EasingFunction = new System.Windows.Media.Animation.CubicEase
|
||||
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut },
|
||||
};
|
||||
settle.Completed += (_, _) => CleanupTabTransforms();
|
||||
tt.BeginAnimation(TranslateTransform.XProperty, settle);
|
||||
}
|
||||
else CleanupTabTransforms();
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// CROSS-PANE MOVE (the window drives this - Shell/PaneDrag.cs)
|
||||
// ════════════════════════════════════════════════════════════════════════════════════════
|
||||
// A session is already self-contained - it owns its document, annotations, undo stack, render
|
||||
// cache and view state - so a move between panes is a move: out of one collection, into the
|
||||
// other. Nothing is reloaded, which is what lets a large document cross without a re-render.
|
||||
|
||||
/// <summary>This pane's band, for the window's drop hit-test and caret math.</summary>
|
||||
internal Border TabBandCtl => TabStripBorder;
|
||||
|
||||
/// <summary>This pane's strip, for the window's caret math.</summary>
|
||||
internal ItemsControl TabStripCtl => TabStrip;
|
||||
|
||||
/// <summary>How many tabs this pane holds. The caret divides the band by this.</summary>
|
||||
internal int TabCount => _sessions.Count;
|
||||
|
||||
/// <summary>Take <paramref name="s"/> out of this pane and pick whatever should be active in
|
||||
/// its place. Pure bookkeeping - the caller re-renders, because which pane's fields are live
|
||||
/// at that moment is its decision, not this one's.</summary>
|
||||
internal void DetachSessionExt(DocumentSession s)
|
||||
{
|
||||
int idx = _sessions.IndexOf(s);
|
||||
if (idx < 0) return;
|
||||
|
||||
_sessions.Remove(s);
|
||||
// Its bitmaps travel with it: leaving the session in this pane's LRU would have this pane
|
||||
// clearing a cache the other pane is now serving from.
|
||||
_renderLru.Remove(s);
|
||||
|
||||
if (ReferenceEquals(_active, s))
|
||||
SetActiveSession(_sessions.Count > 0 ? _sessions[Math.Min(idx, _sessions.Count - 1)] : null);
|
||||
|
||||
RebuildTabStrip();
|
||||
}
|
||||
|
||||
/// <summary>Put <paramref name="s"/> into this pane at <paramref name="index"/> and make it
|
||||
/// the front tab - the tab you just dragged is the one you are looking at.</summary>
|
||||
internal void AdoptSessionExt(DocumentSession s, int index)
|
||||
{
|
||||
_sessions.Insert(Math.Min(Math.Max(0, index), _sessions.Count), s);
|
||||
SetActiveSession(s);
|
||||
RebuildTabStrip();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using PdfSharpCore.Pdf;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// Tabbed document support. KillerPDF keeps one window and one live "working set" of
|
||||
// per-document fields (in MainWindow.xaml.cs). Each open PDF is a DocumentSession that
|
||||
// owns its own copy of those fields. Switching tabs captures the live fields into the
|
||||
// outgoing session and applies the incoming session's fields, then re-renders.
|
||||
// Moved from Shell/Tabs.cs. THIS PANE's open documents and its own tab strip - `_sessions` and
|
||||
// `_active` are per-pane, NOT one window-level list serving one window-level strip. That single
|
||||
// ownership is what puts a document opened in the second pane's tab above the first one, and
|
||||
// what makes the two panes fight over which shows a document; both symptoms are the same bug.
|
||||
//
|
||||
// DocumentSession deliberately carries VIEW state (zoom, page, scroll, view mode) alongside
|
||||
// document state. That is correct because a session belongs to exactly one pane, so there is no
|
||||
// second viewer to disagree with it. Opening the same file in both panes gives two independent
|
||||
// copies, which is what makes that true - see the duplicate-file save guard, because two copies
|
||||
// can otherwise save over each other.
|
||||
//
|
||||
// The tab STRIP - the band, the drag physics and the focus ring - lives in
|
||||
// PdfViewer.TabStrip.cs. This file is the session model and its lifecycle.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// One open document. Holds the per-document state that the rest of MainWindow reads
|
||||
// and writes through its instance fields. The collection references here ARE the live
|
||||
// collections while this session is active.
|
||||
// internal, not private: PdfViewer.Bridge.cs types the active session as
|
||||
// MainWindow.DocumentSession so the moved render pipeline can pass it to the render cache
|
||||
// unchanged. Still nested, so it is only reachable as MainWindow.DocumentSession.
|
||||
internal sealed class DocumentSession : System.ComponentModel.INotifyPropertyChanged
|
||||
{
|
||||
public PdfDocument? Doc;
|
||||
public string? CurrentFile;
|
||||
public string? OriginalFile;
|
||||
// Set on a restored tab that hasn't been loaded yet (lazy tabs): Doc stays null until the
|
||||
// user first switches to it, so startup doesn't render every reopened PDF.
|
||||
public string? DeferredPath;
|
||||
|
||||
public double ZoomLevel = 1.0;
|
||||
public double LastRenderZoom = 1.0;
|
||||
public FitMode Fit = FitMode.None;
|
||||
public ViewMode View = ViewMode.Continuous;
|
||||
public int GridColumns = 3; // grid column count; grid zoom is derived from this, so it must be per-tab too
|
||||
public EditTool Tool = EditTool.Select; // active editing tool, remembered per document
|
||||
public int PageIndex;
|
||||
public bool IsDirty;
|
||||
public bool ProtectedSource; // #149: source file had a password/encryption when opened
|
||||
public double ScrollH;
|
||||
public double ScrollV;
|
||||
public int SearchPageCursor = -1;
|
||||
|
||||
public Dictionary<int, List<PageAnnotation>> Annotations = [];
|
||||
public Dictionary<int, (int w, int h)> RenderDims = [];
|
||||
// LRU render cache: rasterized page bitmaps keyed by (page, size-bucket, rotation). Lets a
|
||||
// switch back to a recent tab reuse the bitmaps instead of re-running pdfium. Concurrent because
|
||||
// the continuous/secondary streamers read it from a background thread. Cleared on edits that change
|
||||
// a page's pixels or page order, and dropped entirely when the tab falls out of the LRU window.
|
||||
public readonly System.Collections.Concurrent.ConcurrentDictionary<(int page, int bucket, int rot), System.Windows.Media.Imaging.BitmapSource> RenderCache = new();
|
||||
// #189: each entry's byte size, recorded by the INSERTING thread. The budget must
|
||||
// never be computed by reading PixelWidth/Height off the cached bitmaps - an entry
|
||||
// that could not freeze throws cross-thread from whatever renderer is evicting,
|
||||
// which killed the background render tasks (vanishing thumbnails after invert).
|
||||
public readonly System.Collections.Concurrent.ConcurrentDictionary<(int page, int bucket, int rot), long> RenderCacheSize = new();
|
||||
public Dictionary<int, int> PageRotations = [];
|
||||
public Dictionary<int, string> FormTextValues = [];
|
||||
public Dictionary<int, bool> FormCheckValues = [];
|
||||
public Dictionary<string, string> FormRadioValues = [];
|
||||
public Dictionary<int, double> FormFontSizes = [];
|
||||
public Stack<UndoEntry> UndoStack = new();
|
||||
public Stack<UndoEntry> RedoStack = new();
|
||||
public Dictionary<int, List<(double left, double bottom, double right, double top)>> AllSearchRects = [];
|
||||
public List<int> SearchResultPages = [];
|
||||
|
||||
public string Title =>
|
||||
string.IsNullOrEmpty(OriginalFile)
|
||||
? "Untitled"
|
||||
: System.IO.Path.GetFileNameWithoutExtension(OriginalFile);
|
||||
|
||||
// ── Tab-strip presentation state ─────────────────────────────────────────────────
|
||||
// Everything below is bound by the tab template (PdfViewer.xaml) and nothing else
|
||||
// reads it. It has to NOTIFY: a strip row is only rebuilt when the collection itself
|
||||
// changes, so a property edited in place on a live row would otherwise never repaint.
|
||||
// (The same trap as KillerNotes issue #13.)
|
||||
|
||||
private string _tabLabel = "Untitled";
|
||||
/// <summary>Title with the dirty dot, as the tab shows it.</summary>
|
||||
public string TabLabel { get => _tabLabel; private set { if (_tabLabel != value) { _tabLabel = value; Notify(); } } }
|
||||
|
||||
private string _tabTip = "Untitled";
|
||||
/// <summary>The tab's tooltip: the full path this document came from.</summary>
|
||||
public string TabTip { get => _tabTip; private set { if (_tabTip != value) { _tabTip = value; Notify(); } } }
|
||||
|
||||
/// <summary>Re-read the label and tooltip off the document. Called from RebuildTabStrip,
|
||||
/// which is the one funnel every add, close, save and load already goes through, so
|
||||
/// there is no second place that has to remember to keep the strip current.</summary>
|
||||
internal void RefreshTabLabel()
|
||||
{
|
||||
TabLabel = (IsDirty ? "• " : "") + Title;
|
||||
TabTip = OriginalFile ?? "Untitled";
|
||||
}
|
||||
|
||||
private bool _isActive;
|
||||
/// <summary>The front tab of its pane.</summary>
|
||||
public bool IsActive { get => _isActive; set { if (_isActive != value) { _isActive = value; Notify(); } } }
|
||||
|
||||
// Leftmost tab in the strip. Only the focus ring reads this: the band draws the ring's
|
||||
// outermost verticals itself (TabEdgeLeft / TabEdgeRight), because a tab's own outer
|
||||
// border sits on the ScrollViewer's clip edge and survives or vanishes depending on how
|
||||
// the UniformGrid divided a fractional band width. Without it the first and last tab
|
||||
// drew that side TOO, so the outer edge of the ring came out 2px wherever the clip
|
||||
// spared it and 1px everywhere else.
|
||||
private bool _isFirst;
|
||||
public bool IsFirst { get => _isFirst; set { if (_isFirst != value) { _isFirst = value; Notify(); } } }
|
||||
|
||||
// Sitting on the strip's right EDGE - the last visible tab, but only while the overflow
|
||||
// chevron is hidden. The tab's 1px right border is a divider BETWEEN tabs, so a tab on
|
||||
// the edge drops it, where it would read as a stray rule; a tab with the chevron beside
|
||||
// it still wants it. It also decides who owns the ring's right vertical.
|
||||
private bool _isLast;
|
||||
public bool IsLast { get => _isLast; set { if (_isLast != value) { _isLast = value; Notify(); } } }
|
||||
|
||||
// Win98 tabs overlap their immediate neighbor by one pixel, like the native tab
|
||||
// control. These flags are only enabled by RebuildTabStrip while the retro theme is
|
||||
// active, so the modern themes retain their existing edge-to-edge geometry.
|
||||
private bool _retroBeforeActive;
|
||||
public bool RetroBeforeActive { get => _retroBeforeActive; set { if (_retroBeforeActive != value) { _retroBeforeActive = value; Notify(); } } }
|
||||
|
||||
private bool _retroAfterActive;
|
||||
public bool RetroAfterActive { get => _retroAfterActive; set { if (_retroAfterActive != value) { _retroAfterActive = value; Notify(); } } }
|
||||
|
||||
private bool _retroLastInactive;
|
||||
public bool RetroLastInactive { get => _retroLastInactive; set { if (_retroLastInactive != value) { _retroLastInactive = value; Notify(); } } }
|
||||
|
||||
// Theme gate for chrome that must never leak into the shared tab template. Modern
|
||||
// tabs keep their ShadowBar and normal canvas fills; 98SE replaces those with crisp
|
||||
// pixel bevels and pane-focus shading.
|
||||
private bool _useRetroTabChrome;
|
||||
public bool UseRetroTabChrome { get => _useRetroTabChrome; set { if (_useRetroTabChrome != value) { _useRetroTabChrome = value; Notify(); } } }
|
||||
|
||||
// True only for the ACTIVE tab of the FOCUSED pane, and only while split. The focus ring
|
||||
// has to continue around the active tab - the tab and the card are one surface, so a
|
||||
// ring that stops at the strip reads as broken.
|
||||
private bool _paneFocused;
|
||||
public bool PaneFocused { get => _paneFocused; set { if (_paneFocused != value) { _paneFocused = value; Notify(); } } }
|
||||
|
||||
// Active tab of the pane that does NOT have focus. Not simply !PaneFocused: with one
|
||||
// pane open there is no focused/unfocused distinction to draw, and the single pane's lip
|
||||
// stays bright.
|
||||
private bool _paneDimmed;
|
||||
public bool PaneDimmed { get => _paneDimmed; set { if (_paneDimmed != value) { _paneDimmed = value; Notify(); } } }
|
||||
|
||||
// In the strip right now, as opposed to behind the chevron. The strip caps the NUMBER of
|
||||
// tabs rather than letting them shrink without limit (ApplyTabWindow), and a tab outside
|
||||
// the window collapses - UniformGrid ignores a collapsed child when it divides the band,
|
||||
// so the ones left still fill it edge to edge. True by default: a tab is in the strip
|
||||
// until something works out that it does not fit.
|
||||
private bool _isStripVisible = true;
|
||||
public bool IsStripVisible { get => _isStripVisible; set { if (_isStripVisible != value) { _isStripVisible = value; Notify(); } } }
|
||||
|
||||
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
|
||||
private void Notify([System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||
=> PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(name));
|
||||
}
|
||||
|
||||
// ObservableCollection, not List: the strip is an ItemsControl bound straight to this, so an
|
||||
// add, a close or a drag-reorder repaints on its own. That binding is the whole point of the
|
||||
// port - the strip used to be code-built Borders kept in step by hand.
|
||||
private readonly System.Collections.ObjectModel.ObservableCollection<DocumentSession> _sessions = [];
|
||||
private DocumentSession? _active;
|
||||
|
||||
// ============================================================
|
||||
// Session state capture / apply
|
||||
// ============================================================
|
||||
|
||||
// Copy the live working set INTO the session (call before switching away from it).
|
||||
private void CaptureSessionState(DocumentSession s)
|
||||
{
|
||||
s.Doc = _doc;
|
||||
s.CurrentFile = _currentFile;
|
||||
s.OriginalFile = _originalFile;
|
||||
s.ZoomLevel = _zoomLevel;
|
||||
s.LastRenderZoom = _lastRenderZoom;
|
||||
s.Fit = _fitMode;
|
||||
s.View = _viewMode;
|
||||
s.GridColumns = _gridColumns;
|
||||
s.Tool = _currentTool;
|
||||
s.IsDirty = _isDirty;
|
||||
s.ProtectedSource = _openedFromProtected;
|
||||
s.SearchPageCursor = Search.PageCursor;
|
||||
// State.CurrentPage, not PageList.SelectedIndex: the sidebar is a window singleton
|
||||
// that follows the FOCUSED pane, so an unfocused pane capturing (the close path, the
|
||||
// save path's dirty check) was parking the OTHER pane's page number into its session.
|
||||
// Identical for the focused pane by the stage-3a sync.
|
||||
s.PageIndex = State.CurrentPage >= 0 ? State.CurrentPage : s.PageIndex;
|
||||
s.ScrollH = PagePreviewPanel?.HorizontalOffset ?? 0;
|
||||
s.ScrollV = PagePreviewPanel?.VerticalOffset ?? 0;
|
||||
|
||||
s.Annotations = _annotations;
|
||||
s.RenderDims = _renderDims;
|
||||
s.PageRotations = _pageRotations;
|
||||
s.FormTextValues = _formTextValues;
|
||||
s.FormCheckValues = _formCheckValues;
|
||||
s.FormRadioValues = _formRadioValues;
|
||||
s.FormFontSizes = _formFontSizes;
|
||||
s.UndoStack = _undoStack;
|
||||
s.RedoStack = _redoStack;
|
||||
s.AllSearchRects = Search.AllSearchRects;
|
||||
s.SearchResultPages = Search.ResultPages;
|
||||
// Persist this document's fit/zoom/view/page so reopening it (even after a restart) restores it.
|
||||
// Two-pane guard: DocStates is keyed by file path, so the SAME file open in BOTH panes
|
||||
// (two independent copies) is two writers on one entry - whichever pane captured last
|
||||
// silently overwrote the state the user actually left the file in, and on quit that was
|
||||
// just the close path's fixed A-then-B capture order. Only the focused pane writes when
|
||||
// the other pane also holds the file. FocusPane captures the outgoing pane BEFORE the
|
||||
// swap, so the pane being LEFT still counts as focused here - the rule this yields is
|
||||
// "the most recently used pane wins". A pane holding the only copy always writes.
|
||||
if (Host == null || Host.IsViewerFocused(this) || !Host.OtherViewerHasFile(this, s.OriginalFile))
|
||||
SaveDocState(s.OriginalFile, s.Fit, s.ZoomLevel, s.View, s.PageIndex);
|
||||
}
|
||||
|
||||
// ── Per-document view state (persisted across restarts, keyed by file path) ──────────────────
|
||||
// So reopening a file restores how you left it (fit mode, zoom, view mode, page) instead of the
|
||||
// per-view-mode default. Stored as one registry value: lines of "path|fit|zoom|view|page", most
|
||||
// recent first, capped. '|' and newline are both illegal in Windows paths, so they're safe delimiters.
|
||||
private const int DocStatesMax = 40;
|
||||
|
||||
private void SaveDocState(string? path, FitMode fit, double zoom, ViewMode view, int page)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return; // skip Untitled/imported
|
||||
string entry = string.Join("|", path,
|
||||
fit.ToString(),
|
||||
zoom.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
view.ToString(),
|
||||
page.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
var lines = new List<string> { entry };
|
||||
var raw = App.GetSetting("DocStates");
|
||||
if (!string.IsNullOrEmpty(raw))
|
||||
foreach (var line in raw!.Split('\n'))
|
||||
{
|
||||
if (line.Length == 0) continue;
|
||||
int bar = line.IndexOf('|');
|
||||
string lpath = bar > 0 ? line[..bar] : line;
|
||||
if (!string.Equals(lpath, path, StringComparison.OrdinalIgnoreCase))
|
||||
lines.Add(line);
|
||||
}
|
||||
if (lines.Count > DocStatesMax) lines = lines.GetRange(0, DocStatesMax);
|
||||
App.SetSetting("DocStates", string.Join("\n", lines));
|
||||
}
|
||||
|
||||
private bool TryGetDocState(string? path, out FitMode fit, out double zoom, out ViewMode view, out int page)
|
||||
{
|
||||
fit = FitMode.None; zoom = 1.0; view = ViewMode.Continuous; page = 0;
|
||||
if (string.IsNullOrEmpty(path)) return false;
|
||||
var raw = App.GetSetting("DocStates");
|
||||
if (string.IsNullOrEmpty(raw)) return false;
|
||||
foreach (var line in raw!.Split('\n'))
|
||||
{
|
||||
var p = line.Split('|');
|
||||
if (p.Length < 5 || !string.Equals(p[0], path, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
Enum.TryParse(p[1], out fit);
|
||||
double.TryParse(p[2], System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out zoom);
|
||||
Enum.TryParse(p[3], out view);
|
||||
int.TryParse(p[4], out page);
|
||||
if (zoom <= 0) zoom = 1.0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Point the live working set AT the session's state. Pure field assignment - no UI.
|
||||
private void ApplySessionState(DocumentSession s)
|
||||
{
|
||||
_doc = s.Doc;
|
||||
_currentFile = s.CurrentFile;
|
||||
_originalFile = s.OriginalFile;
|
||||
// The cached PDFium link handle belongs to the file we're switching AWAY from. This is the one
|
||||
// chokepoint every active-doc swap funnels through (tab switch, close-tab, close-all), so drop
|
||||
// it here and it can never outlive its document; the next link extraction reopens it lazily for
|
||||
// the new file (see EnsureLinkPdfiumDoc). CloseLinkPdfiumDoc is idempotent and cheap.
|
||||
CloseLinkPdfiumDoc();
|
||||
_zoomLevel = s.ZoomLevel;
|
||||
_lastRenderZoom = s.LastRenderZoom;
|
||||
_fitMode = s.Fit;
|
||||
_viewMode = s.View;
|
||||
_gridColumns = s.GridColumns;
|
||||
_currentTool = s.Tool;
|
||||
_isDirty = s.IsDirty;
|
||||
_openedFromProtected = s.ProtectedSource;
|
||||
Search.PageCursor = s.SearchPageCursor;
|
||||
|
||||
_annotations = s.Annotations;
|
||||
_renderDims = s.RenderDims;
|
||||
_pageRotations = s.PageRotations;
|
||||
_formTextValues = s.FormTextValues;
|
||||
_formCheckValues = s.FormCheckValues;
|
||||
_formRadioValues = s.FormRadioValues;
|
||||
_formFontSizes = s.FormFontSizes;
|
||||
_undoStack = s.UndoStack;
|
||||
_redoStack = s.RedoStack;
|
||||
_navBack.Clear(); // jump history is per-view-session: a tab switch starts fresh
|
||||
_navForward.Clear();
|
||||
Search.AllSearchRects = s.AllSearchRects;
|
||||
Search.ResultPages = s.SearchResultPages;
|
||||
TouchRenderLru(s); // this tab is now active: keep its render cache, evict tabs beyond the window
|
||||
}
|
||||
|
||||
// ── LRU render-bitmap cache ───────────────────────────────────────────────────────────────────
|
||||
// Keeps the rasterized page bitmaps of the most-recent few tabs so switching back skips pdfium and
|
||||
// fills instantly. The render paths (single / secondary tiles / continuous) check the active tab's
|
||||
// cache before rasterizing and store the frozen bitmap after building it.
|
||||
private readonly List<DocumentSession> _renderLru = [];
|
||||
private const int RenderCacheTabCap = 3;
|
||||
|
||||
// Background-thread safe: a cached frozen bitmap for this render, or null (the caller must rasterize).
|
||||
internal static System.Windows.Media.Imaging.BitmapSource? TryGetCachedRender(DocumentSession? s, int page, int bucket, int rot)
|
||||
=> (s != null && s.RenderCache.TryGetValue((page, bucket, rot), out var b)) ? b : null;
|
||||
|
||||
// #122: cap the number of cached page bitmaps per tab. The cache used to grow without
|
||||
// bound (one bitmap per page ever rendered, several MB each), so scrolling a large
|
||||
// image-heavy document in Continuous view pinned gigabytes in one tab.
|
||||
private const int RenderCachePageCap = 48;
|
||||
|
||||
// #189: the count cap alone was not enough - an entry's size scales with the page and the
|
||||
// base render budget, so 48 cached Letter pages held ~630 MB in one tab. Budget the cache
|
||||
// in BYTES too, with a floor of nearby pages so the moving window around the viewport
|
||||
// still serves instantly. Frozen BitmapSources are safe to measure from any thread.
|
||||
private const long RenderCacheByteBudget = 160L << 20; // ~160 MB per tab
|
||||
private const int RenderCacheMinPages = 6;
|
||||
|
||||
private static long RenderCacheBytes(DocumentSession s)
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var size in s.RenderCacheSize.Values) total += size;
|
||||
return total;
|
||||
}
|
||||
|
||||
internal static void CacheRender(DocumentSession? s, int page, int bucket, int rot, System.Windows.Media.Imaging.BitmapSource bmp)
|
||||
{
|
||||
if (s == null) return;
|
||||
if (bmp.CanFreeze && !bmp.IsFrozen) bmp.Freeze();
|
||||
// Measured HERE, on the thread that made the bitmap - see RenderCacheSize.
|
||||
s.RenderCacheSize[(page, bucket, rot)] = 4L * bmp.PixelWidth * bmp.PixelHeight;
|
||||
s.RenderCache[(page, bucket, rot)] = bmp;
|
||||
// Evict the entries farthest from the page just cached: renders arrive around the
|
||||
// viewport, so this keeps a moving window of nearby pages hot and stays safe to run
|
||||
// from any thread (no UI state needed).
|
||||
while (true)
|
||||
{
|
||||
int count = s.RenderCache.Count;
|
||||
bool overCount = count > RenderCachePageCap;
|
||||
bool overBytes = count > RenderCacheMinPages && RenderCacheBytes(s) > RenderCacheByteBudget;
|
||||
if (!overCount && !overBytes) break;
|
||||
var farthest = default((int page, int bucket, int rot));
|
||||
int bestDist = -1;
|
||||
foreach (var key in s.RenderCache.Keys)
|
||||
{
|
||||
int d = Math.Abs(key.page - page);
|
||||
if (d > bestDist) { bestDist = d; farthest = key; }
|
||||
}
|
||||
if (bestDist <= 0) break; // only current-page entries left; nothing sane to evict
|
||||
s.RenderCache.TryRemove(farthest, out _);
|
||||
s.RenderCacheSize.TryRemove(farthest, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// #135: the invert state is baked into cached pixels - drop every cached tab's page
|
||||
// bitmaps when it flips so no stale-colored bitmap survives the toggle. The image-rect
|
||||
// cache (the carve-out that keeps pictures uninverted) goes with it; it re-fills lazily.
|
||||
private void FlushAllRenderCaches()
|
||||
{
|
||||
foreach (var s in _renderLru) { s.RenderCache.Clear(); s.RenderCacheSize.Clear(); }
|
||||
// THIS pane's rect cache - the bare call, NOT `Viewer.FlushImageRectCache()`, which
|
||||
// hardcodes pane A and leaves pane B's night-mode carve-out cache serving rects from
|
||||
// the previous state after an invert toggle.
|
||||
FlushImageRectCache();
|
||||
}
|
||||
|
||||
// Mark a tab most-recently-used; drop the bitmap caches of tabs that fall outside the LRU window.
|
||||
private void TouchRenderLru(DocumentSession? s)
|
||||
{
|
||||
if (s == null) return;
|
||||
_renderLru.Remove(s);
|
||||
_renderLru.Add(s);
|
||||
bool dropped = false;
|
||||
while (_renderLru.Count > RenderCacheTabCap)
|
||||
{
|
||||
var old = _renderLru[0];
|
||||
_renderLru.RemoveAt(0);
|
||||
old.RenderCache.Clear();
|
||||
old.RenderCacheSize.Clear();
|
||||
dropped = true;
|
||||
}
|
||||
if (dropped) CompactLohSoon();
|
||||
}
|
||||
|
||||
// #122: .NET Framework never compacts the Large Object Heap on its own, so even after the
|
||||
// page-bitmap caches are dropped the process keeps its peak RAM (the classic "closed the
|
||||
// tab, Task Manager still shows gigabytes"). Request a one-shot LOH compaction at idle,
|
||||
// deferred so it never janks the close/switch animation itself.
|
||||
private void CompactLohSoon()
|
||||
{
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.ApplicationIdle, (Action)(() =>
|
||||
{
|
||||
System.Runtime.GCSettings.LargeObjectHeapCompactionMode =
|
||||
System.Runtime.GCLargeObjectHeapCompactionMode.CompactOnce;
|
||||
GC.Collect();
|
||||
}));
|
||||
}
|
||||
|
||||
// Drop a tab's cached bitmaps after an edit that changes page pixels or page order.
|
||||
private void InvalidateRenderCache(DocumentSession? s)
|
||||
{
|
||||
s?.RenderCache.Clear();
|
||||
s?.RenderCacheSize.Clear();
|
||||
}
|
||||
|
||||
// Make sure there is always at least one session, adopting whatever is currently live.
|
||||
private void EnsureInitialSession()
|
||||
{
|
||||
if (_sessions.Count > 0) return;
|
||||
var s = new DocumentSession();
|
||||
_sessions.Add(s);
|
||||
_active = s;
|
||||
// Only adopt the live working set when this pane actually owns it. CaptureSessionState
|
||||
// copies _doc, _annotations, _undoStack and the rest BY REFERENCE, so capturing while
|
||||
// the shared fields still describe the other pane makes this session an alias of that
|
||||
// pane's live document - the same trap ApplyActiveSessionIfAny guards against. An
|
||||
// unfocused pane's first session stays genuinely blank instead.
|
||||
if (Host == null || Host.IsViewerFocused(this)) CaptureSessionState(s);
|
||||
}
|
||||
|
||||
// Commit / cancel any in-progress interaction so it doesn't bleed onto another document.
|
||||
private void CancelTransientForSwitch()
|
||||
{
|
||||
CommitActiveTextBox();
|
||||
RemoveTextEditHandles();
|
||||
ClearSelection();
|
||||
ClearTextSelection();
|
||||
CloseSearchBar();
|
||||
HideDrawSettings();
|
||||
HideTextSettings();
|
||||
HideSignaturePopup();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Rendering the active session
|
||||
// ============================================================
|
||||
|
||||
// Re-render whatever document the active session holds (or show the empty drop zone).
|
||||
// The shared page context menu, attached per pane by MainWindow.BuildContextMenu. The
|
||||
// opening hook rebuilds items at the cursor; per-tile overlays populate programmatically
|
||||
// (which does not raise ContextMenuOpening), same contract as before.
|
||||
internal void AttachContextMenuExt(System.Windows.Controls.ContextMenu menu)
|
||||
{
|
||||
_annotationCanvas.ContextMenu = menu;
|
||||
_annotationCanvas.ContextMenuOpening += (s, e) =>
|
||||
PopulateContextMenu(System.Windows.Input.Mouse.GetPosition(_annotationCanvas),
|
||||
Math.Max(0, _currentPage));
|
||||
}
|
||||
|
||||
// Per-pane cache flush for the per-pane invert toggle: only THIS pane's sessions hold
|
||||
// stale pixels, and flushing every pane's cache made the untouched pane visibly
|
||||
// re-render on the other pane's toggle (2026-08-15).
|
||||
internal void FlushOwnRenderCaches()
|
||||
{
|
||||
foreach (var s in _sessions) { s.RenderCache.Clear(); s.RenderCacheSize.Clear(); }
|
||||
FlushImageRectCache();
|
||||
}
|
||||
|
||||
// Invert repaint for an UNFOCUSED pane: PIXELS ONLY. RenderActiveSession here ran
|
||||
// BootstrapDocumentView / ShowEmptyState, whose Host chrome mutations (sidebar rebuild
|
||||
// with this pane's thumbnails - or ClearSidebarPages and control-disabling when this
|
||||
// pane is EMPTY) trashed the focused pane's shared sidebar (2026-08-15). An empty pane
|
||||
// has no pixels to repaint and must cause no side effects at all.
|
||||
internal void RepaintPixelsExt()
|
||||
{
|
||||
if (_doc is null) return;
|
||||
if (_viewMode == ViewMode.Continuous)
|
||||
{
|
||||
_continuousSharpenCts?.Cancel();
|
||||
_continuousSharpPages.Clear();
|
||||
foreach (var child in _continuousPanel.Children)
|
||||
if (child is Border b && b.Child is Grid g
|
||||
&& g.Children.Count > 0 && g.Children[0] is System.Windows.Controls.Image img)
|
||||
img.Source = null;
|
||||
_ = RenderContinuousPages(Math.Max(0, _currentPage));
|
||||
StartRerenderTimer();
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderPage(_viewMode == ViewMode.Grid ? 0 : Math.Max(0, _currentPage), keepTiles: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderActiveSession()
|
||||
{
|
||||
if (_active == null || _active.Doc == null) { ShowEmptyState(); return; }
|
||||
|
||||
FileNameLabel.Text = System.IO.Path.GetFileName(_active.OriginalFile ?? "");
|
||||
_annotationCanvas.Children.Clear();
|
||||
MarkDirty(_isDirty); // sync the Save button color to this tab's dirty state
|
||||
BootstrapDocumentView(_active.PageIndex, autoFit: false);
|
||||
SetTool(_active.Tool); // restore this document's active editing tool (and its tool bar)
|
||||
|
||||
// Restore the saved scroll position after the Background zoom pass queued inside
|
||||
// BootstrapDocumentView has run (ContextIdle is lower priority than Background).
|
||||
double sh = _active.ScrollH, sv = _active.ScrollV;
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.ContextIdle, (Action)(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
PagePreviewPanel.ScrollToHorizontalOffset(sh);
|
||||
PagePreviewPanel.ScrollToVerticalOffset(sv);
|
||||
}
|
||||
catch { }
|
||||
}));
|
||||
}
|
||||
|
||||
// Visual reset to the no-document drop-zone state. Mirrors CloseFile's teardown but
|
||||
// does not close the document or touch session bookkeeping (callers handle that).
|
||||
private void ShowEmptyState()
|
||||
{
|
||||
_activeTextBox = null;
|
||||
RemoveTextEditHandles();
|
||||
_thumbCts?.Cancel();
|
||||
Host?.ClearSidebarPages(this);
|
||||
PageImage.Source = null;
|
||||
_annotationCanvas.Children.Clear();
|
||||
FileNameLabel.Text = "";
|
||||
DropZone.Visibility = Visibility.Visible;
|
||||
PopulateRecentFilesList();
|
||||
PagePreviewPanel.Visibility = Visibility.Collapsed;
|
||||
CloseSearchBar();
|
||||
HideDrawSettings();
|
||||
HideTextSettings();
|
||||
HideSignaturePopup();
|
||||
SetTool(EditTool.Select);
|
||||
if (Host != null)
|
||||
{
|
||||
Host.CloseFileEnabled = false;
|
||||
Host.PageJumpEnabled = false;
|
||||
}
|
||||
_continuousRenderCts?.Cancel();
|
||||
_continuousPanel.Children.Clear();
|
||||
_continuousTops.Clear();
|
||||
if (Host != null)
|
||||
{
|
||||
Host.PageJumpText = "";
|
||||
Host.PageTotalText = "/ -";
|
||||
}
|
||||
OutlineTree.Items.Clear();
|
||||
SidebarOutlinesTab.IsEnabled = false;
|
||||
if (_sidebarShowingOutlines) SwitchSidebarToPagesTab();
|
||||
SyncSidebarToDocState(hasDoc: false, startup: false); // nothing open: collapse the rail, hide page controls
|
||||
MarkDirty(false);
|
||||
SetStatus(Loc("Str_Ready"));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Opening / switching / closing tabs
|
||||
// ============================================================
|
||||
|
||||
// Prepare a tab to receive a document load: capture the current tab, then either reuse
|
||||
// the active tab if it's empty or create a new one, and blank the live working set.
|
||||
private DocumentSession BeginTabLoad(out DocumentSession? prev, out bool createdNew)
|
||||
{
|
||||
EnsureInitialSession();
|
||||
CommitActiveTextBox();
|
||||
CancelTransientForSwitch();
|
||||
prev = _active;
|
||||
if (_active != null) CaptureSessionState(_active);
|
||||
|
||||
DocumentSession target;
|
||||
if (_active != null && _active.Doc == null && _active.DeferredPath == null)
|
||||
{
|
||||
target = _active; // reuse the current empty tab (never a deferred one)
|
||||
createdNew = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
target = new DocumentSession();
|
||||
// Inherit the current view mode so a newly opened PDF doesn't snap back to the
|
||||
// default (Continuous) when the user prefers Single / Two-Page / Grid.
|
||||
if (prev != null) { target.View = prev.View; target.Fit = prev.Fit; }
|
||||
_sessions.Add(target);
|
||||
createdNew = true;
|
||||
}
|
||||
SetActiveSession(target);
|
||||
ApplySessionState(target); // blank live fields (target has no document yet)
|
||||
return target;
|
||||
}
|
||||
|
||||
// Roll back a failed / canceled load started by BeginTabLoad.
|
||||
private void AbortTabLoad(DocumentSession target, DocumentSession? prev, bool createdNew)
|
||||
{
|
||||
if (createdNew) _sessions.Remove(target);
|
||||
SetActiveSession(prev);
|
||||
if (prev != null) { ApplySessionState(prev); RenderActiveSession(); }
|
||||
else { EnsureInitialSession(); RenderActiveSession(); }
|
||||
RebuildTabStrip();
|
||||
}
|
||||
|
||||
// Returns an open session for the given file path (case-insensitive full-path match), or null.
|
||||
private DocumentSession? FindOpenSession(string path)
|
||||
{
|
||||
string full;
|
||||
try { full = System.IO.Path.GetFullPath(path); } catch { full = path; }
|
||||
return _sessions.FirstOrDefault(s =>
|
||||
(s.Doc != null || s.DeferredPath != null) &&
|
||||
!string.IsNullOrEmpty(s.OriginalFile) &&
|
||||
string.Equals(SafeFullPath(s.OriginalFile!), full, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string SafeFullPath(string p)
|
||||
{
|
||||
try { return System.IO.Path.GetFullPath(p); } catch { return p; }
|
||||
}
|
||||
|
||||
// Open a PDF in its own tab (reusing the current tab if it is empty). If the same file is
|
||||
// already open in an unedited tab, switch to that tab instead of opening a duplicate.
|
||||
private void OpenInNewTab(string path)
|
||||
{
|
||||
EnsureInitialSession();
|
||||
CommitActiveTextBox();
|
||||
if (_active != null) CaptureSessionState(_active); // keep dirty / path current for the check
|
||||
|
||||
var existing = FindOpenSession(path);
|
||||
if (existing != null && !existing.IsDirty)
|
||||
{
|
||||
SwitchToTab(existing);
|
||||
SetStatus(string.Format(Loc("Str_St_AlreadyOpen"), System.IO.Path.GetFileName(path)));
|
||||
return;
|
||||
}
|
||||
|
||||
var target = BeginTabLoad(out var prev, out bool createdNew);
|
||||
OpenFile(path);
|
||||
if (_doc == null)
|
||||
{
|
||||
// A background open (encryption strip / repair) finalizes this tab itself, so the
|
||||
// not-yet-loaded _doc isn't a failure - leave the tab in place.
|
||||
if (_asyncOpenPending) return;
|
||||
// Open failed, was canceled, or a password prompt was dismissed.
|
||||
AbortTabLoad(target, prev, createdNew);
|
||||
return;
|
||||
}
|
||||
CaptureSessionState(_active!);
|
||||
SetTool(_currentTool); // sync the tool UI to this (new) tab's tool
|
||||
RebuildTabStrip();
|
||||
}
|
||||
|
||||
// Cycle to the next (dir = +1) or previous (dir = -1) open document tab.
|
||||
private void CycleTab(int dir)
|
||||
{
|
||||
var docTabs = _sessions.Where(t => t.Doc != null || t.DeferredPath != null).ToList();
|
||||
if (docTabs.Count < 2 || _active == null) return;
|
||||
int i = docTabs.IndexOf(_active);
|
||||
if (i < 0) return;
|
||||
int next = (i + dir + docTabs.Count) % docTabs.Count;
|
||||
SwitchToTab(docTabs[next]);
|
||||
}
|
||||
|
||||
/// <summary>Make <paramref name="s"/> this pane's active session and mark its tab.
|
||||
///
|
||||
/// The IsActive flags are what the strip template triggers on, so every write to _active
|
||||
/// goes through here - a raw assignment leaves the old tab drawn as the front one.</summary>
|
||||
private void SetActiveSession(DocumentSession? s)
|
||||
{
|
||||
_active = s;
|
||||
foreach (var t in _sessions) t.IsActive = ReferenceEquals(t, s);
|
||||
}
|
||||
|
||||
// Switch the active tab to an already-loaded session.
|
||||
private void SwitchToTab(DocumentSession target)
|
||||
{
|
||||
if (target == _active) return;
|
||||
// _doc, _annotations and the rest of the "live working set" are window fields shared by
|
||||
// BOTH panes (see PdfViewer.Bridge.cs) - they describe whichever pane is ActiveViewer,
|
||||
// not this pane specifically. Clicking a tab in a pane that is not (yet) focused must
|
||||
// claim that ownership FIRST, or CaptureSessionState/ApplySessionState below read and
|
||||
// write the OTHER pane's fields: this pane's tab strip ends up showing the right tab
|
||||
// while its canvas gets repainted with whatever the actually-focused pane rendered next.
|
||||
// FocusPane no-ops when this pane already owns focus. (#161 - "clicking a tab on one
|
||||
// pane is making it show up in the other one again", 2026-08-01.)
|
||||
Host?.FocusViewer(this);
|
||||
CommitActiveTextBox();
|
||||
CancelTransientForSwitch();
|
||||
if (_active != null) CaptureSessionState(_active);
|
||||
SetActiveSession(target);
|
||||
ApplySessionState(target);
|
||||
// Hide the document content while the new tab renders and restores its scroll position, then fade
|
||||
// it in. This masks the rebuild and the "loads at the top then snaps to my place" jump - the user
|
||||
// only sees the final, correctly-scrolled view fade in. PageContentGrid is the parent of BOTH the
|
||||
// single/grid panel and the continuous panel, so one fade covers every view mode.
|
||||
PageContentGrid.BeginAnimation(UIElement.OpacityProperty, null);
|
||||
PageContentGrid.Opacity = 0;
|
||||
if (target.Doc == null && target.DeferredPath != null)
|
||||
MaterializeDeferred(target);
|
||||
else
|
||||
RenderActiveSession();
|
||||
// The switch can move the overflow window (the incoming tab may have been behind the
|
||||
// chevron), which changes which tab sits on each edge - and that is the card's corner
|
||||
// rounding and the ring's outer verticals, not just the strip.
|
||||
RebuildTabStrip();
|
||||
FadeInDocContent();
|
||||
}
|
||||
|
||||
// Fade the document pane content back in after a switch. Queued at ContextIdle so it runs AFTER the
|
||||
// scroll-position restore (also ContextIdle, queued earlier by RenderActiveSession) - the snap to
|
||||
// position happens while hidden, so it's never seen. Always lands at full opacity.
|
||||
private void FadeInDocContent()
|
||||
{
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.ContextIdle, (Action)(() =>
|
||||
{
|
||||
var fade = new System.Windows.Media.Animation.DoubleAnimation(
|
||||
0, 1, new Duration(TimeSpan.FromMilliseconds(140)))
|
||||
{ EasingFunction = new System.Windows.Media.Animation.QuadraticEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut } };
|
||||
fade.Completed += (_, _) =>
|
||||
{
|
||||
PageContentGrid.BeginAnimation(UIElement.OpacityProperty, null);
|
||||
PageContentGrid.Opacity = 1;
|
||||
};
|
||||
PageContentGrid.BeginAnimation(UIElement.OpacityProperty, fade);
|
||||
}));
|
||||
}
|
||||
|
||||
// Load a restored-but-deferred tab's PDF the first time it is viewed (lazy tabs). The session
|
||||
// must already be the live working set (ApplySessionState called) before this runs.
|
||||
private void MaterializeDeferred(DocumentSession target)
|
||||
{
|
||||
var path = target.DeferredPath;
|
||||
target.DeferredPath = null;
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path))
|
||||
{
|
||||
RenderActiveSession(); // file vanished since last session - show the empty state
|
||||
return;
|
||||
}
|
||||
OpenFile(path!); // loads into the live fields and renders the view
|
||||
if (_doc == null)
|
||||
{
|
||||
if (_asyncOpenPending) return; // background strip/repair finalizes the tab itself
|
||||
RenderActiveSession(); return;
|
||||
}
|
||||
CaptureSessionState(target); // persist the now-loaded document back into the session
|
||||
}
|
||||
|
||||
// Close a tab. Prompts to save if that tab has unsaved changes, then switches to a
|
||||
// neighboring tab (or the empty state when the last tab closes).
|
||||
// Closes every open document tab except `keep` (each may prompt to save if dirty, like a manual close).
|
||||
private void CloseOtherTabs(DocumentSession keep)
|
||||
{
|
||||
foreach (var s in _sessions.Where(z => !ReferenceEquals(z, keep) && (z.Doc != null || z.DeferredPath != null)).ToList())
|
||||
CloseTab(s);
|
||||
}
|
||||
|
||||
private void CloseTab(DocumentSession? s)
|
||||
{
|
||||
EnsureInitialSession();
|
||||
if (s == null) return;
|
||||
|
||||
// Same reason as the top of SwitchToTab: claim the shared fields for this pane before
|
||||
// touching them below, in case this pane is not (yet) ActiveViewer - e.g. the tab
|
||||
// context menu's Close Tab / Close Other Tabs, invoked directly on this pane's own
|
||||
// instance. No-ops when already focused.
|
||||
Host?.FocusViewer(this);
|
||||
|
||||
// Make the target the live working set so its dirty flag / document are current.
|
||||
if (s != _active)
|
||||
{
|
||||
CommitActiveTextBox();
|
||||
CancelTransientForSwitch();
|
||||
if (_active != null) CaptureSessionState(_active);
|
||||
SetActiveSession(s);
|
||||
ApplySessionState(s);
|
||||
RenderActiveSession();
|
||||
}
|
||||
else
|
||||
{
|
||||
CommitActiveTextBox();
|
||||
CaptureSessionState(s);
|
||||
}
|
||||
|
||||
if (_isDirty)
|
||||
{
|
||||
var res = KillerDialog.Show(Host!.Window,
|
||||
Loc("Str_Dlg_UnsavedClose"),
|
||||
"KillerPDF", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
||||
if (res != MessageBoxResult.Yes) { RebuildTabStrip(); return; }
|
||||
}
|
||||
|
||||
try { _doc?.Close(); } catch { }
|
||||
_doc = null;
|
||||
|
||||
int idx = _sessions.IndexOf(s);
|
||||
_sessions.Remove(s);
|
||||
_renderLru.Remove(s); // don't pin a closed tab's render cache in the LRU list
|
||||
s.RenderCache.Clear();
|
||||
s.RenderCacheSize.Clear();
|
||||
CompactLohSoon(); // #122: give the freed bitmap memory back to the OS
|
||||
|
||||
if (_sessions.Count == 0)
|
||||
{
|
||||
App.RemoveSetting("LastFile"); // a manually emptied window won't reopen on launch
|
||||
var blank = new DocumentSession();
|
||||
_sessions.Add(blank);
|
||||
SetActiveSession(blank);
|
||||
ApplySessionState(blank);
|
||||
ShowEmptyState();
|
||||
}
|
||||
else
|
||||
{
|
||||
var next = _sessions[Math.Min(idx, _sessions.Count - 1)];
|
||||
SetActiveSession(next);
|
||||
ApplySessionState(next);
|
||||
if (next.Doc == null && next.DeferredPath != null) MaterializeDeferred(next);
|
||||
else RenderActiveSession();
|
||||
}
|
||||
RebuildTabStrip();
|
||||
}
|
||||
|
||||
// Ctrl+Q: close every open document and reset to a single blank tab, with one combined warning
|
||||
// if anything is unsaved (rather than a prompt per tab).
|
||||
private void CloseAllTabs()
|
||||
{
|
||||
EnsureInitialSession();
|
||||
CommitActiveTextBox();
|
||||
if (_active != null) CaptureSessionState(_active);
|
||||
|
||||
var docTabs = _sessions.Where(t => t.Doc != null || t.DeferredPath != null).ToList();
|
||||
if (docTabs.Count == 0) return;
|
||||
|
||||
if (docTabs.Any(t => t.IsDirty))
|
||||
{
|
||||
var res = KillerDialog.Show(Host!.Window, Loc("Str_Dlg_UnsavedCloseAll"),
|
||||
"KillerPDF", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
||||
if (res != MessageBoxResult.Yes) { RebuildTabStrip(); return; }
|
||||
}
|
||||
|
||||
foreach (var s in docTabs) { try { s.Doc?.Close(); } catch { } }
|
||||
try { _doc?.Close(); } catch { }
|
||||
_doc = null;
|
||||
|
||||
_sessions.Clear();
|
||||
App.RemoveSetting("LastFile"); // a manually emptied window won't reopen on launch
|
||||
var blank2 = new DocumentSession();
|
||||
_sessions.Add(blank2);
|
||||
SetActiveSession(blank2);
|
||||
ApplySessionState(blank2);
|
||||
ShowEmptyState();
|
||||
RebuildTabStrip();
|
||||
}
|
||||
|
||||
// OpenFromExternal / RestoreAndActivate moved BACK to Shell/ExternalOpen.cs on MainWindow.
|
||||
// They are window chrome, not pane behavior: RestoreAndActivate drives WindowState,
|
||||
// Activate() and Topmost, none of which exist on a UserControl, and App calls both on the
|
||||
// window. Only the OpenInNewTab call inside them belongs to a pane, and that now routes
|
||||
// through ActiveViewer like every other window -> viewer call.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// This pane's tab surface, exposed to the window. Wrappers inside the same partial class, so
|
||||
/// they can reach members PdfViewer.Tabs.cs keeps private; the Ext suffix exists only because a
|
||||
/// wrapper cannot share a name with what it wraps.
|
||||
///
|
||||
/// The window calls these against ActiveViewer, so a shortcut or toolbar button acts on the
|
||||
/// focused pane.
|
||||
/// </summary>
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ── Opening and closing ──────────────────────────────────────────────────────────────
|
||||
internal void OpenInNewTabExt(string path) => OpenInNewTab(path);
|
||||
internal void CloseTabExt(DocumentSession? s) => CloseTab(s);
|
||||
internal void CloseAllTabsExt() => CloseAllTabs();
|
||||
internal void CloseOtherTabsExt(DocumentSession? keep = null)
|
||||
{
|
||||
var target = keep ?? _active;
|
||||
if (target != null) CloseOtherTabs(target);
|
||||
}
|
||||
internal void CycleTabExt(int dir) => CycleTab(dir);
|
||||
internal void EnsureInitialSessionExt() => EnsureInitialSession();
|
||||
internal void MaterializeDeferredExt(DocumentSession target) => MaterializeDeferred(target);
|
||||
internal void SwitchToTabExt(DocumentSession target) => SwitchToTab(target);
|
||||
|
||||
// ── The load handshake (FileOperations / ImportAndZip drive this) ────────────────────
|
||||
internal DocumentSession BeginTabLoadExt(out DocumentSession? prev, out bool createdNew)
|
||||
=> BeginTabLoad(out prev, out createdNew);
|
||||
internal void AbortTabLoadExt(DocumentSession target, DocumentSession? prev, bool createdNew)
|
||||
=> AbortTabLoad(target, prev, createdNew);
|
||||
|
||||
// ── Session state ────────────────────────────────────────────────────────────────────
|
||||
internal void CaptureSessionStateExt(DocumentSession s) => CaptureSessionState(s);
|
||||
|
||||
/// <summary>Fold this pane's live fields back into its own active session, if it has one.
|
||||
/// The close path has to do this for BOTH panes before asking about unsaved work.</summary>
|
||||
internal void CaptureActiveIfAny()
|
||||
{
|
||||
if (_active != null) CaptureSessionState(_active);
|
||||
}
|
||||
internal void ApplySessionStateExt(DocumentSession s) => ApplySessionState(s);
|
||||
|
||||
/// <summary>Swap this pane's active session into the window's shared document fields. The
|
||||
/// counterpart to CaptureActiveIfAny; FocusPane runs both across a pane switch.
|
||||
///
|
||||
/// The empty-pane branch is not an optimization, it prevents cross-pane corruption. The
|
||||
/// shared fields still describe the pane we just left, and EnsureInitialSession ends with
|
||||
/// CaptureSessionState, which copies _doc, _annotations, _undoStack and the rest BY
|
||||
/// REFERENCE. So the first session an empty pane created would alias the other pane's live
|
||||
/// document: opening a file in one pane replaced the other's, and switching tabs in one
|
||||
/// moved the other. Blanking the shared fields here means there is nothing to alias.</summary>
|
||||
internal void ApplyActiveSessionIfAny()
|
||||
{
|
||||
if (_active != null) { ApplySessionState(_active); return; }
|
||||
|
||||
var blank = new DocumentSession(); // every collection field has its own initializer
|
||||
_sessions.Add(blank);
|
||||
SetActiveSession(blank);
|
||||
ApplySessionState(blank);
|
||||
ShowEmptyState();
|
||||
}
|
||||
|
||||
/// <summary>Put this pane's active session back into the shared fields and nothing else -
|
||||
/// pure assignment, no UI, and no session created if there is none. Used by WithOwnSession,
|
||||
/// which runs from layout events and must not cause any further layout.</summary>
|
||||
internal void RestoreActiveFieldsOnly()
|
||||
{
|
||||
if (_active != null) ApplySessionState(_active);
|
||||
}
|
||||
|
||||
/// <summary>Run view math with THIS pane's document in the window's shared fields.
|
||||
///
|
||||
/// _doc, _viewMode, _fitMode, _zoomLevel and _gridColumns are WINDOW fields, but the
|
||||
/// handlers that do view math - the viewport's SizeChanged, the resize-settle timer,
|
||||
/// ReapplyGridOrFit - are per-pane: each pane's own ScrollViewer raises them. So an
|
||||
/// unfocused pane whose viewport ticked was refitting itself against the FOCUSED pane's
|
||||
/// document and fit mode, and writing that pane's zoom back out. That is why switching
|
||||
/// tabs in one pane kept changing the other pane's view.
|
||||
///
|
||||
/// Swap this pane's session in, run, fold the view values back, then restore the focused
|
||||
/// pane. Only the view fields are folded back, not a full CaptureSessionState: that also
|
||||
/// writes DocStates to the registry, which a resize would do dozens of times a second.</summary>
|
||||
private bool _inOwnSessionScope;
|
||||
internal void WithOwnSession(System.Action work)
|
||||
{
|
||||
if (Host == null || _inOwnSessionScope || Host.IsViewerFocused(this))
|
||||
{
|
||||
work();
|
||||
return;
|
||||
}
|
||||
|
||||
_inOwnSessionScope = true;
|
||||
// The element accessors have to follow the fields. Swapping only the document state
|
||||
// left the render path resolving PageHost / PreviewScroller through ActiveViewer, so
|
||||
// this pane's fit measured the OTHER pane's viewport and painted into its tiles.
|
||||
try
|
||||
{
|
||||
Host.RunWithViewerContext(this, () =>
|
||||
{
|
||||
if (_active != null)
|
||||
{
|
||||
ApplySessionState(_active);
|
||||
// ApplySessionState deliberately leaves PageIndex to RenderActiveSession,
|
||||
// which never runs on this path. Seed it from this pane's session.
|
||||
State.CurrentPage = _active.PageIndex;
|
||||
}
|
||||
work();
|
||||
if (_active != null)
|
||||
{
|
||||
_active.ZoomLevel = _zoomLevel;
|
||||
_active.LastRenderZoom = _lastRenderZoom;
|
||||
_active.Fit = _fitMode;
|
||||
_active.View = _viewMode;
|
||||
_active.GridColumns = _gridColumns;
|
||||
_active.ScrollH = PagePreviewPanel?.HorizontalOffset ?? _active.ScrollH;
|
||||
_active.ScrollV = PagePreviewPanel?.VerticalOffset ?? _active.ScrollV;
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inOwnSessionScope = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>This pane's sidebar thumbnails, kept so focusing it again does not re-decode the
|
||||
/// document. Read by RestorePageListForActivePane (PageOperations.cs).</summary>
|
||||
internal PageThumbnailVm[]? ThumbCache { get; set; }
|
||||
internal string? ThumbCacheFile { get; set; }
|
||||
|
||||
/// <summary>This pane's thumbnail loader cancellation. PER PANE, not per window: one shared
|
||||
/// token meant focusing either pane canceled whatever the other was still decoding, and
|
||||
/// since the half-filled cache still matched the page count it counted as usable - so the
|
||||
/// list re-seated with the labels and no pictures, permanently.</summary>
|
||||
internal System.Threading.CancellationTokenSource? ThumbCts { get; set; }
|
||||
|
||||
/// <summary>Highlight this pane's current page after the list is re-seated: assigning
|
||||
/// ItemsSource clears the selection.</summary>
|
||||
internal int CurrentPageIndex => State.CurrentPage;
|
||||
|
||||
internal void SyncPageListSelection(int? preservedPage = null)
|
||||
{
|
||||
if (preservedPage.HasValue) State.CurrentPage = preservedPage.Value;
|
||||
if (State.CurrentPage < 0) return;
|
||||
Host?.ViewerPageChanged(this, State.CurrentPage);
|
||||
if (Host != null) Host.PageJumpText = (State.CurrentPage + 1).ToString();
|
||||
Host?.EnsureSidebarPageVisible(this, State.CurrentPage);
|
||||
}
|
||||
internal void SaveDocStateExt(string? path, FitMode fit, double zoom, ViewMode view, int page)
|
||||
=> SaveDocState(path, fit, zoom, view, page);
|
||||
internal bool TryGetDocStateExt(string? path, out FitMode fit, out double zoom,
|
||||
out ViewMode view, out int page)
|
||||
=> TryGetDocState(path, out fit, out zoom, out view, out page);
|
||||
|
||||
// ── Strip and render ─────────────────────────────────────────────────────────────────
|
||||
internal void InitTabStripExt() => InitTabStrip();
|
||||
internal void RebuildTabStripExt() => RebuildTabStrip();
|
||||
/// <summary>The band changed width. Kept under the old name because the window still wires
|
||||
/// the focused pane's SizeChanged to it; each pane also raises its own now, and the call is
|
||||
/// guarded and idempotent, so the two agreeing costs nothing.</summary>
|
||||
internal void ScheduleTabReflowExt() => TabBarResized();
|
||||
internal void RenderActiveSessionExt() => RenderActiveSession();
|
||||
internal void ShowEmptyStateExt() => ShowEmptyState();
|
||||
internal void FlushAllRenderCachesExt() => FlushAllRenderCaches();
|
||||
internal void InvalidateRenderCacheExt(DocumentSession? s) => InvalidateRenderCache(s);
|
||||
|
||||
/// <summary>Make a brand-new empty session the active one. The startup restore builds the
|
||||
/// session list itself, so it needs to place the result rather than go through
|
||||
/// EnsureInitialSession.</summary>
|
||||
internal void SetSessionsExt(IEnumerable<DocumentSession> sessions, DocumentSession? active)
|
||||
{
|
||||
_sessions.Clear();
|
||||
foreach (var s in sessions) _sessions.Add(s); // ObservableCollection has no AddRange
|
||||
SetActiveSession(active);
|
||||
}
|
||||
|
||||
/// <summary>Build a deferred (lazy) session for the restore path - a tab that shows its
|
||||
/// title but does not load its document until it is first switched to.</summary>
|
||||
internal static DocumentSession MakeDeferredSession(string path)
|
||||
=> new() { OriginalFile = path, CurrentFile = path, DeferredPath = path };
|
||||
|
||||
// ── This pane's strip elements, for the window chrome that still positions them ──────
|
||||
// AppScale scales them, FullScreen hides them, SidebarLayout flips their margins - all of
|
||||
// which now have to act on BOTH panes rather than one window-level band.
|
||||
internal System.Windows.Controls.Border TabStripBorderCtl => TabStripBorder;
|
||||
internal System.Windows.Controls.Border TabStripFadeCtl => TabStripFade;
|
||||
internal System.Windows.Controls.Border TabBarRingCtl => TabBarRing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
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 KillerPDF.Services;
|
||||
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// Moved from Shell/TextEditing.cs; the namespace and class line are the only changes. Window
|
||||
// members spelled bare here resolve through PdfViewer.Bridge.cs.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
// ============================================================
|
||||
// Inline text editing (double-click)
|
||||
// ============================================================
|
||||
|
||||
// While a paired text is being re-edited, trace its cover with a dashed outline so the cover stays
|
||||
// visible (its opaque fill often matches the page, so without this it looks like the cover vanished).
|
||||
private void ShowReeditCoverOutline(string pairId, int pageIdx)
|
||||
{
|
||||
RemoveReeditCoverOutline();
|
||||
if (pairId.Length == 0 || !_annotations.TryGetValue(pageIdx, out var list)) return;
|
||||
var cover = list.OfType<CoverAnnotation>().FirstOrDefault(c => c.PairId == pairId);
|
||||
if (cover is null) return;
|
||||
double inv = 1.0;
|
||||
if (_activeCanvas.LayoutTransform is ScaleTransform st && st.ScaleX > 0.0001) inv = 1.0 / st.ScaleX;
|
||||
var pb = cover.Bounds;
|
||||
_reeditCoverOutline = new Rectangle
|
||||
{
|
||||
Width = pb.Width + 4,
|
||||
Height = pb.Height + 4,
|
||||
Stroke = DarkerAccentBrush(),
|
||||
StrokeThickness = 1.5 * inv,
|
||||
StrokeDashArray = [4, 3],
|
||||
Fill = Brushes.Transparent,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
Canvas.SetLeft(_reeditCoverOutline, pb.X - 2);
|
||||
Canvas.SetTop(_reeditCoverOutline, pb.Y - 2);
|
||||
_activeCanvas.Children.Add(_reeditCoverOutline);
|
||||
}
|
||||
|
||||
private void RemoveReeditCoverOutline()
|
||||
{
|
||||
if (_reeditCoverOutline is not null)
|
||||
{
|
||||
(_reeditCoverOutline.Parent as Canvas)?.Children.Remove(_reeditCoverOutline);
|
||||
_reeditCoverOutline = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Heuristic for a broken glyph->Unicode CMap (common on OCR'd scans): the extracted text comes out
|
||||
// as mojibake - replacement chars, private-use glyphs, or words peppered with currency/math symbols.
|
||||
// We don't pre-fill an in-place edit from text that looks like this. Conservative on purpose so clean
|
||||
// PDFs are never flagged; all-letter garbling (wrong letters that are still valid) can't be caught.
|
||||
private static bool LooksGarbled(string s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return false;
|
||||
const string ok = ".,;:!?'\"()[]{}-/\\&%@#*+=<>|~`^_$";
|
||||
int letters = 0, weird = 0, total = 0;
|
||||
foreach (char c in s)
|
||||
{
|
||||
if (char.IsWhiteSpace(c)) continue;
|
||||
total++;
|
||||
if (char.IsLetterOrDigit(c)) { letters++; continue; }
|
||||
if (ok.IndexOf(c) >= 0) continue; // ordinary punctuation is fine
|
||||
weird++; // replacement / PUA / stray symbol = mapping break
|
||||
}
|
||||
if (total == 0) return false;
|
||||
return (double)weird / total > 0.15 || (double)letters / total < 0.35;
|
||||
}
|
||||
|
||||
private void EditTextAtPosition(Point canvasPos, int pageIdx)
|
||||
{
|
||||
if (_currentFile is null || !_renderDims.ContainsKey(pageIdx)) return;
|
||||
|
||||
// Commit any existing edit first
|
||||
if (_activeTextBox is not null)
|
||||
{
|
||||
CommitActiveTextBox();
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-edit a user-placed text annotation: lift it into an editable box
|
||||
// pre-filled with its content, size (shown in points), and color.
|
||||
if (_annotations.TryGetValue(pageIdx, out var placedPage))
|
||||
{
|
||||
var placed = placedPage.OfType<TextAnnotation>()
|
||||
.LastOrDefault(a => HitTestAnnotation(a, canvasPos, out _));
|
||||
if (placed is not null)
|
||||
{
|
||||
var pcol = placed.GetColor();
|
||||
_textColor = pcol;
|
||||
_textOpacity = pcol.A; // keep the opacity slider in sync with the edited text
|
||||
_textFillColor = placed.GetFill(); // and the fill swatches in sync with the box
|
||||
double syp = 1.0;
|
||||
if (_doc is not null && _renderDims.TryGetValue(pageIdx, out var prd) && prd.h > 0)
|
||||
syp = _doc.Pages[pageIdx].Height.Point / prd.h;
|
||||
_textFontSize = Math.Max(1, Math.Round(placed.FontSize * syp));
|
||||
// Sync the bar's typeface + B/I/S to the box being re-edited.
|
||||
_textFontName = string.IsNullOrEmpty(placed.FontName) ? "Segoe UI" : placed.FontName;
|
||||
_textBold = placed.Bold; _textItalic = placed.Italic; _textStrike = placed.Strike; _textUnderline = placed.Underline;
|
||||
|
||||
_reeditOriginal = placed;
|
||||
placedPage.Remove(placed);
|
||||
RenderAllAnnotations(pageIdx);
|
||||
// Keep the paired cover visible (outlined) for the duration of the edit.
|
||||
ShowReeditCoverOutline(placed.PairId, pageIdx);
|
||||
|
||||
var ptb = new TextBox
|
||||
{
|
||||
Text = placed.Content,
|
||||
Background = TextEditBackground(),
|
||||
Foreground = new SolidColorBrush(pcol),
|
||||
BorderBrush = (SolidColorBrush)FindResource("PrimaryBrush"),
|
||||
SelectionBrush = AccentBrush(),
|
||||
CaretBrush = new SolidColorBrush(pcol),
|
||||
Template = FlatTextBoxTemplate(),
|
||||
BorderThickness = new Thickness(1),
|
||||
FontFamily = UiKit.UiFont,
|
||||
FontSize = placed.FontSize,
|
||||
Width = placed.Width > 0 ? placed.Width : TextBoxDefaultWidth,
|
||||
MinHeight = 24,
|
||||
Padding = new Thickness(2),
|
||||
AcceptsReturn = true,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Tag = pageIdx
|
||||
};
|
||||
Canvas.SetLeft(ptb, placed.Position.X);
|
||||
Canvas.SetTop(ptb, placed.Position.Y);
|
||||
_activeCanvas.Children.Add(ptb);
|
||||
_activeTextBox = ptb;
|
||||
StyleEditBox(ptb); // restore the box's typeface + B/I/S
|
||||
ptb.PreviewKeyDown += TextBox_PreviewKeyDown;
|
||||
ptb.Loaded += (s, ev) => { ptb.Focus(); Keyboard.Focus(ptb); ptb.SelectAll(); ptb.LostFocus += TextBox_LostFocus; AttachTextEditResizeHandles(ptb); };
|
||||
ShowTextSettings();
|
||||
SetStatus(Loc("Str_St_EditingText"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// The click landed on an existing text cover but not its replacement text (handled above).
|
||||
// Don't start a fresh detection over an edit that already exists - that would stack a second
|
||||
// cover+text. Bail so the user grabs the existing text/cover instead of duplicating it.
|
||||
if (_annotations.TryGetValue(pageIdx, out var coverPage)
|
||||
&& coverPage.OfType<CoverAnnotation>().Any(c => { var b = c.Bounds; b.Inflate(6, 6); return b.Contains(canvasPos); }))
|
||||
{
|
||||
SetStatus(Loc("Str_St_AlreadyEditHere"));
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (renderW, renderH) = _renderDims[pageIdx];
|
||||
|
||||
using var pigDoc = PdfPigDoc.Open(_currentFile);
|
||||
if (pageIdx >= pigDoc.NumberOfPages) return;
|
||||
var page = pigDoc.GetPage(pageIdx + 1);
|
||||
|
||||
double pdfW = page.Width;
|
||||
double pdfH = page.Height;
|
||||
double sxInv = (double)renderW / pdfW; // pdf->canvas
|
||||
double syInv = (double)renderH / pdfH;
|
||||
|
||||
// Convert all words to canvas coordinates upfront
|
||||
var canvasWords = page.GetWords().Select(w =>
|
||||
{
|
||||
double cx = w.BoundingBox.Left * sxInv;
|
||||
double cy = renderH - (w.BoundingBox.Top * syInv);
|
||||
double cw = (w.BoundingBox.Right - w.BoundingBox.Left) * sxInv;
|
||||
double ch = (w.BoundingBox.Top - w.BoundingBox.Bottom) * syInv;
|
||||
return new { Word = w, Rect = new Rect(cx, cy, cw, ch) };
|
||||
}).ToList();
|
||||
|
||||
if (canvasWords.Count == 0)
|
||||
{
|
||||
// Scanned / image-only page: no text layer to detect. Fall back to a manual edit -
|
||||
// drop a cover + empty text box at the click so the user can white out the scanned
|
||||
// text and type over it by hand (resize the cover to fit).
|
||||
double mf = Math.Max(_textFontSize * syInv, 8); // current text size in canvas units
|
||||
StartCoverTextEdit(pageIdx, new Rect(canvasPos.X, canvasPos.Y, 200, mf * 1.35), "", mf, "Segoe UI", syInv);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find words on the same line as the click (Y overlap with tolerance)
|
||||
var clickY = canvasPos.Y;
|
||||
var lineWords = canvasWords
|
||||
.Where(cw => clickY >= cw.Rect.Top - 3 && clickY <= cw.Rect.Bottom + 3)
|
||||
.OrderBy(cw => cw.Rect.Left) // strictly left-to-right
|
||||
.ToList();
|
||||
|
||||
if (lineWords.Count == 0)
|
||||
{
|
||||
// Try nearest line within 20px
|
||||
var nearest = canvasWords
|
||||
.OrderBy(cw => Math.Abs((cw.Rect.Top + cw.Rect.Bottom) / 2 - clickY))
|
||||
.First();
|
||||
double nearMidY = (nearest.Rect.Top + nearest.Rect.Bottom) / 2;
|
||||
lineWords = [..canvasWords
|
||||
.Where(cw => Math.Abs((cw.Rect.Top + cw.Rect.Bottom) / 2 - nearMidY) < 5)
|
||||
.OrderBy(cw => cw.Rect.Left)];
|
||||
}
|
||||
|
||||
if (lineWords.Count == 0)
|
||||
{
|
||||
SetStatus(Loc("Str_St_NoTextLine"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Narrow to the contiguous run of words around the click. Words at the same Y in a
|
||||
// second column are separated by a large horizontal gap, so stop there instead of
|
||||
// merging both columns into one edit (the "weird text" / page-spanning edit).
|
||||
if (lineWords.Count > 1)
|
||||
{
|
||||
int ci = 0; double bestDx = double.MaxValue;
|
||||
for (int i = 0; i < lineWords.Count; i++)
|
||||
{
|
||||
var r = lineWords[i].Rect;
|
||||
double dx = canvasPos.X < r.Left ? r.Left - canvasPos.X
|
||||
: canvasPos.X > r.Right ? canvasPos.X - r.Right : 0;
|
||||
if (dx < bestDx) { bestDx = dx; ci = i; }
|
||||
}
|
||||
double gapMax = Math.Max(lineWords[ci].Rect.Height * 1.5, 24); // word spacing is small; a column gap is large
|
||||
int lo = ci, hi = ci;
|
||||
while (lo > 0 && lineWords[lo].Rect.Left - lineWords[lo - 1].Rect.Right <= gapMax) lo--;
|
||||
while (hi < lineWords.Count - 1 && lineWords[hi + 1].Rect.Left - lineWords[hi].Rect.Right <= gapMax) hi++;
|
||||
lineWords = lineWords.GetRange(lo, hi - lo + 1);
|
||||
}
|
||||
|
||||
// Compute bounding box in canvas space
|
||||
double cLeft = lineWords.Min(w => w.Rect.Left);
|
||||
double cTop = lineWords.Min(w => w.Rect.Top);
|
||||
double cRight = lineWords.Max(w => w.Rect.Right);
|
||||
double cBottom = lineWords.Max(w => w.Rect.Bottom);
|
||||
double cWidth = cRight - cLeft;
|
||||
double cHeight = cBottom - cTop;
|
||||
|
||||
string lineText = string.Join(" ", lineWords.Select(w => w.Word.Text));
|
||||
|
||||
// If this line is already covered by an edit, don't detect it again - that just stacks a
|
||||
// duplicate cover+text on top of the existing one. The original PDF text under a cover is
|
||||
// "consumed": re-edit by clicking the replacement text instead.
|
||||
var lineRect = new Rect(cLeft, cTop, Math.Max(1, cWidth), Math.Max(1, cHeight));
|
||||
if (_annotations.TryGetValue(pageIdx, out var coveredPage)
|
||||
&& coveredPage.OfType<CoverAnnotation>().Any(c => c.Bounds.IntersectsWith(lineRect)))
|
||||
{
|
||||
SetStatus(Loc("Str_St_LineAlreadyEdited"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get actual font info from PdfPig letter data
|
||||
double canvasFontSize = cHeight * 0.75; // fallback
|
||||
string fontName = "Segoe UI"; // fallback
|
||||
bool fontBold = false;
|
||||
bool fontItalic = false;
|
||||
var firstWord = lineWords.First().Word;
|
||||
try
|
||||
{
|
||||
if (firstWord.Letters.Count > 0)
|
||||
{
|
||||
var letter = firstWord.Letters[0];
|
||||
// PointSize is the glyph size in points. FontSize is the size as written in the
|
||||
// content stream, which only matches the point size when the text matrix doesn't
|
||||
// scale: a generator that emits "/F1 1 Tf" and scales through Tm reports FontSize
|
||||
// 1, which collapsed the replacement onto the floor below and read back as 3pt
|
||||
// (#163). Fall back to FontSize, then to the line-height estimate above, since
|
||||
// PointSize can be 0 on fonts with no usable metrics (e.g. some Type3).
|
||||
double pdfFontPts = letter.PointSize > 0 ? letter.PointSize : letter.FontSize;
|
||||
if (pdfFontPts > 0)
|
||||
canvasFontSize = pdfFontPts * syInv;
|
||||
|
||||
// Font family from the LETTER, never the word (#166, thanks Ryokoxx):
|
||||
// Word.FontName joins its letters' names ("Helvetica Helvetica Helvetica
|
||||
// ..."), which FontFamily cannot resolve - so that fallback landed on the
|
||||
// default font, exactly where no fallback at all would have. The outer
|
||||
// catch already covers a read that throws, so this needs no inner one.
|
||||
string? rawFont = letter.FontName;
|
||||
if (!string.IsNullOrEmpty(rawFont))
|
||||
{
|
||||
var detected = PdfFontStyle.FromPdfName(rawFont!);
|
||||
fontName = detected.Family;
|
||||
fontBold = detected.Bold;
|
||||
fontItalic = detected.Italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* use fallbacks */ }
|
||||
|
||||
// Drop the cover + editable text box for the detected line. Detected size carries the
|
||||
// EditTextSizeCorrection (WPF renders the source point size ~25% large); manual edits don't.
|
||||
// Scanned PDFs with a broken glyph->Unicode map extract as mojibake; in that case start the
|
||||
// box empty (like a manual edit) instead of pre-filling garbage - the user types over the
|
||||
// whited-out original.
|
||||
string prefill = LooksGarbled(lineText) ? "" : lineText;
|
||||
StartCoverTextEdit(pageIdx, new Rect(cLeft, cTop, cWidth, cHeight), prefill,
|
||||
Math.Max(canvasFontSize * EditTextSizeCorrection, 8), fontName, syInv,
|
||||
fontBold, fontItalic);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetStatus(string.Format(Loc("Str_St_TextEditError"), ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
// Drops an opaque cover at the given line and opens an editable text box on top of it - the two
|
||||
// halves of an in-place edit. Used for a detected PDF-text line and, on a scanned page with no
|
||||
// text layer, for a manual edit at the click point. boxFontCanvas is the on-canvas font size;
|
||||
// the cover fill and text ink are sampled from the page so the edit blends in.
|
||||
private void StartCoverTextEdit(int pageIdx, Rect lineRect, string text, double boxFontCanvas,
|
||||
string fontName, double syInv, bool bold = false, bool italic = false)
|
||||
{
|
||||
double cLeft = lineRect.X, cTop = lineRect.Y, cWidth = lineRect.Width, cHeight = lineRect.Height;
|
||||
// Pair id shared with the replacement text - the cover renders dashed while paired.
|
||||
var cover = new CoverAnnotation
|
||||
{
|
||||
PageIndex = pageIdx,
|
||||
PairId = Guid.NewGuid().ToString("N"),
|
||||
Bounds = new Rect(cLeft - 3, cTop - 3, cWidth + 6, cHeight + 6)
|
||||
};
|
||||
var sampleRect = new Rect(cLeft, cTop, cWidth, cHeight);
|
||||
Color coverBg = SampleCoverColor(pageIdx, sampleRect);
|
||||
Color inkColor = SampleTextColor(pageIdx, sampleRect, coverBg);
|
||||
cover.SetColor(coverBg);
|
||||
_textColor = inkColor; _textOpacity = inkColor.A;
|
||||
_textFontSize = Math.Max(1, Math.Round(boxFontCanvas / syInv)); // canvas units -> points
|
||||
// Replacing raw PDF text starts from the detected font and its face styling. PDF fonts
|
||||
// encode bold and italic in the font name, so resetting these flags made every detected
|
||||
// line plain as soon as it was double-clicked (#182).
|
||||
_textFontName = string.IsNullOrEmpty(fontName) ? "Segoe UI" : fontName;
|
||||
_textBold = bold;
|
||||
_textItalic = italic;
|
||||
_textStrike = _textUnderline = false;
|
||||
_pendingEditWasDirty = _isDirty; // capture before the cover dirties the doc
|
||||
if (!_annotations.ContainsKey(pageIdx)) _annotations[pageIdx] = [];
|
||||
_annotations[pageIdx].Add(cover);
|
||||
_pendingCover = cover;
|
||||
MarkDirty();
|
||||
RenderAllAnnotations(pageIdx);
|
||||
|
||||
var tb = new TextBox
|
||||
{
|
||||
Text = text,
|
||||
Background = Brushes.Transparent, // the opaque cover behind supplies the backdrop
|
||||
Foreground = new SolidColorBrush(inkColor),
|
||||
BorderBrush = (SolidColorBrush)FindResource("PrimaryBrush"),
|
||||
SelectionBrush = AccentBrush(),
|
||||
CaretBrush = new SolidColorBrush(inkColor),
|
||||
Template = FlatTextBoxTemplate(),
|
||||
BorderThickness = new Thickness(1),
|
||||
FontFamily = new FontFamily(fontName),
|
||||
FontSize = boxFontCanvas,
|
||||
Width = Math.Max(cWidth + 20, 80),
|
||||
MinHeight = 24,
|
||||
Padding = new Thickness(2, 0, 2, 0),
|
||||
AcceptsReturn = true,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Tag = pageIdx
|
||||
};
|
||||
Canvas.SetLeft(tb, cLeft);
|
||||
Canvas.SetTop(tb, cTop);
|
||||
_activeCanvas.Children.Add(tb);
|
||||
_activeTextBox = tb;
|
||||
StyleEditBox(tb);
|
||||
tb.PreviewKeyDown += TextBox_PreviewKeyDown;
|
||||
tb.Loaded += (s, ev) => { tb.Focus(); Keyboard.Focus(tb); tb.SelectAll(); tb.LostFocus += TextBox_LostFocus; AttachTextEditResizeHandles(tb); };
|
||||
ShowTextSettings();
|
||||
SetStatus(string.IsNullOrEmpty(text)
|
||||
? "Type your text, then drag the cover over the original - Enter to save, Escape to cancel"
|
||||
: "Editing text - change size/color above, Enter to save, Escape to cancel");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Text box handling
|
||||
// ============================================================
|
||||
|
||||
// A flat TextBox template (just a themed border hosting the text) so the OS default focus border
|
||||
// and selection chrome - the stray WPF "blue" - never show on the in-canvas text editor.
|
||||
private static ControlTemplate FlatTextBoxTemplate()
|
||||
{
|
||||
var b = new FrameworkElementFactory(typeof(Border));
|
||||
b.SetBinding(Border.BackgroundProperty, new System.Windows.Data.Binding("Background")
|
||||
{ RelativeSource = new System.Windows.Data.RelativeSource(System.Windows.Data.RelativeSourceMode.TemplatedParent) });
|
||||
b.SetBinding(Border.BorderBrushProperty, new System.Windows.Data.Binding("BorderBrush")
|
||||
{ RelativeSource = new System.Windows.Data.RelativeSource(System.Windows.Data.RelativeSourceMode.TemplatedParent) });
|
||||
b.SetBinding(Border.BorderThicknessProperty, new System.Windows.Data.Binding("BorderThickness")
|
||||
{ RelativeSource = new System.Windows.Data.RelativeSource(System.Windows.Data.RelativeSourceMode.TemplatedParent) });
|
||||
b.SetValue(Border.CornerRadiusProperty, new CornerRadius(2));
|
||||
var sv = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" };
|
||||
b.AppendChild(sv);
|
||||
return new ControlTemplate(typeof(TextBox)) { VisualTree = b };
|
||||
}
|
||||
|
||||
// Background shown WHILE editing a text box: the chosen fill if one is set, otherwise a faint
|
||||
// translucent neutral gray. Gray (not white) so the empty editable box stays visible on both
|
||||
// light/white pages and dark pages; it's only shown during editing and never committed.
|
||||
private Brush TextEditBackground()
|
||||
=> _textFillColor.A > 0 ? new SolidColorBrush(_textFillColor)
|
||||
: new SolidColorBrush(Color.FromArgb(64, 128, 128, 128));
|
||||
|
||||
// True when 'pos' (in _activeCanvas coordinates) falls inside the text box currently being
|
||||
// edited AND that box lives on _activeCanvas. Used so a click inside the box doesn't get
|
||||
// treated as a request to place a new one (the Grid-view "box jumps to cursor" bug).
|
||||
private bool ClickInsideActiveTextBox(Point pos)
|
||||
{
|
||||
if (_activeTextBox is null || !ReferenceEquals(_activeTextBox.Parent, _activeCanvas)) return false;
|
||||
double x = Canvas.GetLeft(_activeTextBox), y = Canvas.GetTop(_activeTextBox);
|
||||
if (double.IsNaN(x) || double.IsNaN(y)) return false;
|
||||
double w = _activeTextBox.ActualWidth > 0 ? _activeTextBox.ActualWidth : _activeTextBox.Width;
|
||||
double h = _activeTextBox.ActualHeight > 0 ? _activeTextBox.ActualHeight : Math.Max(_activeTextBox.MinHeight, 24);
|
||||
return pos.X >= x && pos.X <= x + w && pos.Y >= y && pos.Y <= y + h;
|
||||
}
|
||||
|
||||
private void PlaceTextBox(Point pos, int pageIdx)
|
||||
{
|
||||
// _textFontSize is a point size; convert to the page's canvas (render-dim) units so
|
||||
// it renders and exports as real points. DrawAnnotationsOnDocument multiplies by
|
||||
// sy = page.Height.Point / renderH, so dividing by sy here makes "14" export as 14pt.
|
||||
double fontCanvas = _textFontSize;
|
||||
if (_doc is not null && _renderDims.TryGetValue(pageIdx, out var rdims) && rdims.h > 0)
|
||||
{
|
||||
double sy = _doc.Pages[pageIdx].Height.Point / rdims.h;
|
||||
if (sy > 0) fontCanvas = _textFontSize / sy;
|
||||
}
|
||||
// A default-size box dropped at the click point. Width is fixed (text wraps to it) and the
|
||||
// box auto-grows downward as you type; resize the width later via the corner handles.
|
||||
var tb = new TextBox
|
||||
{
|
||||
Background = TextEditBackground(),
|
||||
Foreground = new SolidColorBrush(_textColor),
|
||||
BorderBrush = (SolidColorBrush)FindResource("PrimaryBrush"),
|
||||
SelectionBrush = AccentBrush(),
|
||||
CaretBrush = new SolidColorBrush(_textColor),
|
||||
Template = FlatTextBoxTemplate(),
|
||||
BorderThickness = new Thickness(1),
|
||||
FontFamily = UiKit.UiFont,
|
||||
FontSize = fontCanvas,
|
||||
Width = TextBoxDefaultWidth,
|
||||
MinHeight = 24,
|
||||
Padding = new Thickness(2),
|
||||
AcceptsReturn = true,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Tag = pageIdx
|
||||
};
|
||||
Canvas.SetLeft(tb, pos.X);
|
||||
Canvas.SetTop(tb, pos.Y);
|
||||
_activeCanvas.Children.Add(tb);
|
||||
_activeTextBox = tb;
|
||||
StyleEditBox(tb); // current typeface + B/I/S
|
||||
tb.PreviewKeyDown += TextBox_PreviewKeyDown;
|
||||
tb.LostFocus += TextBox_LostFocus;
|
||||
// Focus the box and attach its live resize handles once laid out. Loaded fires on first
|
||||
// placement; a dispatcher fallback covers re-entry (Text tool -> Select -> Text again),
|
||||
// where Loaded may have already run - without it the new box silently took no typing and
|
||||
// showed no handles. Activate is idempotent (guards against double focus/handle attach).
|
||||
void Activate()
|
||||
{
|
||||
if (!ReferenceEquals(_activeTextBox, tb)) return;
|
||||
tb.Focus();
|
||||
Keyboard.Focus(tb);
|
||||
if (!ReferenceEquals(_tehBox, tb)) AttachTextEditResizeHandles(tb);
|
||||
}
|
||||
tb.Loaded += (s, e) => Activate();
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, new Action(Activate));
|
||||
}
|
||||
|
||||
// ── Live resize handles around the editing TextBox ──────────────────────────────
|
||||
// Corner squares the user can drag to resize the box mid-edit, then keep typing. The
|
||||
// box auto-grows in height until a handle is dragged, after which the height is free-form.
|
||||
private void AttachTextEditResizeHandles(TextBox tb)
|
||||
{
|
||||
RemoveTextEditHandles();
|
||||
_tehBox = tb;
|
||||
double inv = 1.0;
|
||||
if (_activeCanvas.LayoutTransform is ScaleTransform sc && sc.ScaleX > 0.0001) inv = 1.0 / sc.ScaleX;
|
||||
double hs = 12 * inv;
|
||||
foreach (string tag in new[] { "NW", "NE", "SE", "SW" })
|
||||
{
|
||||
var hd = new Rectangle
|
||||
{
|
||||
Width = hs,
|
||||
Height = hs,
|
||||
Fill = AccentBrush(),
|
||||
Stroke = Brushes.White,
|
||||
StrokeThickness = 1 * inv,
|
||||
Cursor = (tag is "NW" or "SE") ? Cursors.SizeNWSE : Cursors.SizeNESW,
|
||||
Focusable = false, // so grabbing a handle does not blur (and commit) the TextBox
|
||||
Tag = tag
|
||||
};
|
||||
Panel.SetZIndex(hd, 200);
|
||||
// Hit detection + drag are handled in the canvas gesture handlers (which run as
|
||||
// PreviewMouseLeftButtonDown and would otherwise intercept the click), mirroring the
|
||||
// committed-annotation resize handles.
|
||||
_textEditHandles.Add(hd);
|
||||
_activeCanvas.Children.Add(hd);
|
||||
}
|
||||
tb.SizeChanged += TextEditBox_SizeChanged;
|
||||
LayoutTextEditHandles();
|
||||
}
|
||||
|
||||
private void TextEditBox_SizeChanged(object sender, SizeChangedEventArgs e) => LayoutTextEditHandles();
|
||||
|
||||
private void LayoutTextEditHandles()
|
||||
{
|
||||
if (_tehBox is null || _textEditHandles.Count == 0) return;
|
||||
double x = Canvas.GetLeft(_tehBox), y = Canvas.GetTop(_tehBox);
|
||||
double w = _tehBox.ActualWidth > 0 ? _tehBox.ActualWidth : _tehBox.Width;
|
||||
double h = _tehBox.ActualHeight;
|
||||
foreach (var hd in _textEditHandles)
|
||||
{
|
||||
double hsz = hd.Width;
|
||||
(double cx, double cy) = (hd.Tag as string) switch
|
||||
{
|
||||
"NW" => (x, y),
|
||||
"NE" => (x + w, y),
|
||||
"SW" => (x, y + h),
|
||||
_ => (x + w, y + h) // SE
|
||||
};
|
||||
Canvas.SetLeft(hd, cx - hsz / 2);
|
||||
Canvas.SetTop(hd, cy - hsz / 2);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveTextEditHandles()
|
||||
{
|
||||
if (_tehBox is not null) _tehBox.SizeChanged -= TextEditBox_SizeChanged;
|
||||
foreach (var hd in _textEditHandles) RemoveFromParent(hd);
|
||||
_textEditHandles.Clear();
|
||||
_tehBox = null;
|
||||
_draggingTextEditHandle = false;
|
||||
}
|
||||
|
||||
// Remove a canvas child from whatever Panel actually parents it, instead of assuming it lives
|
||||
// on _activeCanvas. In continuous/grid view _activeCanvas follows the mouse to whichever page
|
||||
// was last clicked, so a text-edit box, its whiteout, or its handles - placed earlier on a
|
||||
// different page's canvas - would otherwise survive removal and become orphaned: still painted,
|
||||
// but unreachable by Delete, Clear All, or resize. Its live Parent is always the correct host.
|
||||
private static void RemoveFromParent(UIElement? el)
|
||||
{
|
||||
if (el is FrameworkElement fe && fe.Parent is Panel p)
|
||||
p.Children.Remove(el);
|
||||
}
|
||||
|
||||
// Hit-test a live text-edit handle at the given canvas point; returns its corner tag or null.
|
||||
private string? TextEditHandleAt(Point pos)
|
||||
{
|
||||
foreach (var hd in _textEditHandles)
|
||||
{
|
||||
double hx = Canvas.GetLeft(hd), hy = Canvas.GetTop(hd);
|
||||
if (pos.X >= hx && pos.X <= hx + hd.Width &&
|
||||
pos.Y >= hy && pos.Y <= hy + hd.Height)
|
||||
return hd.Tag as string ?? "SE";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Attached as PreviewKeyDown (tunneling) so Enter is caught before the TextBox inserts a line
|
||||
// break: Enter commits, Shift+Enter falls through to make a newline (the box is AcceptsReturn).
|
||||
private void TextBox_PreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
CancelActiveTextEdit();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.Z && (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control
|
||||
&& sender is TextBox ztb && !ztb.CanUndo)
|
||||
{
|
||||
// The box has no typed text left to undo, so Ctrl+Z backs out of the whole in-place edit
|
||||
// (same as Escape) instead of being a no-op - otherwise a fresh edit could only be undone
|
||||
// after committing it. While the box still has edits to undo, WPF's TextBox handles Ctrl+Z.
|
||||
CancelActiveTextEdit();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.Enter && Keyboard.Modifiers != ModifierKeys.Shift)
|
||||
{
|
||||
CommitActiveTextBox();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (Keyboard.Modifiers == ModifierKeys.Control
|
||||
&& (e.Key == Key.B || e.Key == Key.I || e.Key == Key.U))
|
||||
{
|
||||
// 1.6.6: the standard formatting chords while typing in a box - mirror the text
|
||||
// bar's B/I/U toggles (whole-annotation style, like the buttons). Ctrl+I became
|
||||
// available when Invert moved to the bare N key.
|
||||
if (e.Key == Key.B) _textBold = !_textBold;
|
||||
else if (e.Key == Key.I) _textItalic = !_textItalic;
|
||||
else _textUnderline = !_textUnderline;
|
||||
if (sender is TextBox stb) StyleEditBox(stb);
|
||||
ApplyTextStyleToSelection();
|
||||
ShowTextSettings();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Abandons the in-progress text edit: removes the editing box and its handles, drops a pending
|
||||
// cover (placed un-undone), and restores a re-edited annotation. Shared by Escape and the
|
||||
// "Ctrl+Z with nothing left in the box" path.
|
||||
private void CancelActiveTextEdit()
|
||||
{
|
||||
RemoveTextEditHandles();
|
||||
RemoveReeditCoverOutline(); // edit canceled; drop the cover hint (repaint follows)
|
||||
if (_activeTextBox is not null)
|
||||
{
|
||||
RemoveFromParent(_activeTextBox);
|
||||
_activeTextBox = null;
|
||||
}
|
||||
// Canceling an existing-text edit drops the cover too (it was placed un-undone).
|
||||
if (_pendingCover is not null) DiscardPendingCover();
|
||||
if (_reeditOriginal is not null)
|
||||
{
|
||||
int rp = _reeditOriginal.PageIndex;
|
||||
if (!_annotations.TryGetValue(rp, out var rlist)) { rlist = []; _annotations[rp] = rlist; }
|
||||
rlist.Add(_reeditOriginal);
|
||||
_reeditOriginal = null;
|
||||
RenderAllAnnotations(rp);
|
||||
}
|
||||
if (_currentTool != EditTool.Text) HideTextSettings();
|
||||
}
|
||||
|
||||
private void TextBox_LostFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// Don't commit while a resize handle is being dragged (the box temporarily loses focus).
|
||||
if (_draggingTextEditHandle) return;
|
||||
// Commit if the box has content, or (for an existing-text edit) even when emptied, so the
|
||||
// pending cover is resolved instead of lingering when the user clicks away from a blank edit.
|
||||
if (_activeTextBox is not null && (!string.IsNullOrWhiteSpace(_activeTextBox.Text) || _pendingCover is not null))
|
||||
{
|
||||
Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
// Keep the edit box open if focus moved into the size/color bar so the
|
||||
// user can restyle (the Size ComboBox takes focus; color swatches do not).
|
||||
if (_textSettingsBar is not null && Keyboard.FocusedElement is DependencyObject fe
|
||||
&& IsDescendantOf(fe, _textSettingsBar))
|
||||
return;
|
||||
CommitActiveTextBox();
|
||||
}),
|
||||
System.Windows.Threading.DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
private void CommitActiveTextBox()
|
||||
{
|
||||
if (_activeTextBox is null) return;
|
||||
var tb = _activeTextBox;
|
||||
_activeTextBox = null;
|
||||
RemoveTextEditHandles();
|
||||
RemoveReeditCoverOutline(); // the re-edit is ending; drop its cover hint (repaint follows)
|
||||
string reeditPair = _reeditOriginal?.PairId ?? ""; // preserve a re-edited text's cover pairing
|
||||
_reeditOriginal = null; // committing replaces any annotation being re-edited
|
||||
|
||||
string content = tb.Text.Trim();
|
||||
int pageIdx = tb.Tag is int idx ? idx : _currentPage;
|
||||
double x = Canvas.GetLeft(tb);
|
||||
double y = Canvas.GetTop(tb);
|
||||
|
||||
// Remove the editing box from whatever canvas actually parents it. _activeCanvas may have
|
||||
// moved to another page when the user clicked away to commit (continuous/grid), and a
|
||||
// re-looked-up page canvas can be a different instance than the one the box was placed on,
|
||||
// either of which leaves the box orphaned (visible, but immune to Delete/Clear All). Its
|
||||
// live Parent is the correct host.
|
||||
RemoveFromParent(tb);
|
||||
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
double boxW = (!double.IsNaN(tb.Width) && tb.Width > 0) ? tb.Width
|
||||
: (tb.ActualWidth > 0 ? tb.ActualWidth : TextBoxDefaultWidth);
|
||||
var ta = new TextAnnotation
|
||||
{
|
||||
PageIndex = pageIdx,
|
||||
Position = new Point(x, y),
|
||||
Content = content,
|
||||
FontSize = tb.FontSize,
|
||||
FontName = _textFontName,
|
||||
Bold = _textBold,
|
||||
Italic = _textItalic,
|
||||
Strike = _textStrike,
|
||||
Underline = _textUnderline,
|
||||
Width = boxW
|
||||
};
|
||||
ta.SetColor(tb.Foreground is SolidColorBrush scb ? scb.Color : Colors.Black);
|
||||
// A cover-paired edit gets no fill of its own - the opaque cover behind it is the backdrop.
|
||||
ta.SetFill(_pendingCover is not null ? Colors.Transparent : _textFillColor);
|
||||
// Free-form height if the box was manually resized; otherwise fit to the wrapped text.
|
||||
ta.Height = (!double.IsNaN(tb.Height) && tb.Height > 0)
|
||||
? tb.Height
|
||||
: MeasureTextBoxHeight(content, boxW, tb.FontSize);
|
||||
// Keep the placed box fully on-page so its corners (and resize handles) stay reachable.
|
||||
ta.Position = ClampRectToPage(pageIdx, new Rect(ta.Position, new Size(ta.Width, ta.Height))).Location;
|
||||
// Carry the pairing so the cover knows its partner text exists (renders dashed).
|
||||
ta.PairId = _pendingCover is not null ? _pendingCover.PairId : reeditPair;
|
||||
if (_pendingCover is not null)
|
||||
{
|
||||
// Existing-text edit: the cover is already in _annotations. Add the text beside it and
|
||||
// push ONE grouped undo so a single Ctrl+Z right after cancels the whole edit. After
|
||||
// this, cover and text are independent annotations (move/resize/recolor separately).
|
||||
_annotations[pageIdx].Add(ta);
|
||||
PushUndo(new UndoEntry(UndoKind.AnnotationGroup, pageIdx,
|
||||
WasDirty: _pendingEditWasDirty, AnnotGroup: [_pendingCover, ta]));
|
||||
_pendingCover = null;
|
||||
MarkDirty();
|
||||
}
|
||||
else
|
||||
{
|
||||
AddAnnotation(ta);
|
||||
}
|
||||
RenderAllAnnotations(pageIdx); // redraw on the correct page's canvas
|
||||
WarnIfGlyphsWillBeLost(ta); // #168: say so NOW, not after saving and reopening
|
||||
}
|
||||
else if (_pendingCover is not null)
|
||||
{
|
||||
// Edit left empty - abandon it and drop the cover (added without its own undo entry).
|
||||
DiscardPendingCover();
|
||||
}
|
||||
if (_currentTool != EditTool.Text) HideTextSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #168: the editor borrows glyphs from any installed font, so text ALWAYS looks right while
|
||||
/// being typed - but a save can only embed fonts, and a character no installed font carries
|
||||
/// becomes a box in the file. That used to be invisible until the user saved, closed and
|
||||
/// reopened. Now it is said at the moment the text is placed, while it can still be fixed.
|
||||
///
|
||||
/// Only fires when the whole fallback chain comes up short (a box mixing two non-Latin
|
||||
/// scripts, or a script with no font installed at all), so it does not nag: ordinary
|
||||
/// Japanese, Chinese, Korean or Bengali text resolves silently.
|
||||
/// </summary>
|
||||
private void WarnIfGlyphsWillBeLost(TextAnnotation ta)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(ta.Content)) return;
|
||||
string want = string.IsNullOrEmpty(ta.FontName) ? "Segoe UI" : ta.FontName;
|
||||
string family = Services.FontCoverage.PickFamily(want, ta.Content);
|
||||
string missing = Services.FontCoverage.UncoveredChars(family, ta.Content);
|
||||
if (missing.Length == 0) return;
|
||||
KillerDialog.Show(Host!.Window, string.Format(Loc("Str_Font_NoGlyphs"), missing), "KillerPDF",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
catch { /* the warning must never be the thing that breaks placing text */ }
|
||||
}
|
||||
|
||||
// Remove the not-yet-committed cover when an existing-text edit is canceled or left empty. The
|
||||
// cover was added straight to _annotations without an undo entry, so just drop it and repaint.
|
||||
private void DiscardPendingCover()
|
||||
{
|
||||
if (_pendingCover is null) return;
|
||||
int pg = _pendingCover.PageIndex;
|
||||
if (_annotations.TryGetValue(pg, out var list)) list.Remove(_pendingCover);
|
||||
_pendingCover = null;
|
||||
MarkDirty(_pendingEditWasDirty);
|
||||
RenderAllAnnotations(pg);
|
||||
}
|
||||
|
||||
// ── Cover background sampling ───────────────────────────────────────────────
|
||||
// Reads the page background color around an existing-text line so a cover blends into colored
|
||||
// headers/panels instead of showing a white box. Best-effort: returns white on any failure.
|
||||
|
||||
// The page's rendered bitmap: the Image sibling of its overlay canvas (continuous/grid/two-page),
|
||||
// or the single-view PageImage. View-mode independent so sampling works everywhere.
|
||||
private System.Windows.Media.Imaging.BitmapSource? PageBitmapFor(int pageIdx)
|
||||
{
|
||||
if (_continuousCanvases.TryGetValue(pageIdx, out var overlay) && overlay.Parent is Panel mp)
|
||||
foreach (var ch in mp.Children)
|
||||
if (ch is Image im && im.Source is System.Windows.Media.Imaging.BitmapSource bs) return bs;
|
||||
if (pageIdx == _currentPage && PageImage.Source is System.Windows.Media.Imaging.BitmapSource pbs)
|
||||
return pbs;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Color ReadBgraPixel(System.Windows.Media.Imaging.BitmapSource bmp, int x, int y)
|
||||
{
|
||||
x = Math.Max(0, Math.Min(x, bmp.PixelWidth - 1));
|
||||
y = Math.Max(0, Math.Min(y, bmp.PixelHeight - 1));
|
||||
var px = new byte[4];
|
||||
bmp.CopyPixels(new Int32Rect(x, y, 1, 1), px, 4, 0); // Bgra32: B,G,R,A
|
||||
// Composite over white (the PDF page background) so transparent pixels - common on repaired
|
||||
// or scanned renders - read as white, not black. Returns an opaque color for sampling.
|
||||
double a = px[3] / 255.0;
|
||||
byte r = (byte)(px[2] * a + 255 * (1 - a));
|
||||
byte g = (byte)(px[1] * a + 255 * (1 - a));
|
||||
byte b = (byte)(px[0] * a + 255 * (1 - a));
|
||||
return Color.FromRgb(r, g, b);
|
||||
}
|
||||
|
||||
/// <summary>Background color around a text line, in canvas (render-dim) coordinates. White on failure.</summary>
|
||||
private Color SampleCoverColor(int pageIdx, Rect textBounds)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bmp = PageBitmapFor(pageIdx);
|
||||
if (bmp is null || !_renderDims.TryGetValue(pageIdx, out var rd) || rd.w <= 0 || rd.h <= 0)
|
||||
return Colors.White;
|
||||
double sx = bmp.PixelWidth / (double)rd.w; // render-dim -> bitmap pixels
|
||||
double sy = bmp.PixelHeight / (double)rd.h;
|
||||
// Sample the whitespace just above and below the line (usually pure background) at a few
|
||||
// x offsets; take the median by luminance to shrug off a stray glyph or anti-aliased edge.
|
||||
double gap = Math.Max(3.0, textBounds.Height * 0.4);
|
||||
var cols = new List<Color>();
|
||||
foreach (double f in new[] { 0.2, 0.5, 0.8 })
|
||||
{
|
||||
double x = textBounds.Left + textBounds.Width * f;
|
||||
cols.Add(ReadBgraPixel(bmp, (int)Math.Round(x * sx), (int)Math.Round((textBounds.Top - gap) * sy)));
|
||||
cols.Add(ReadBgraPixel(bmp, (int)Math.Round(x * sx), (int)Math.Round((textBounds.Bottom + gap) * sy)));
|
||||
}
|
||||
if (cols.Count == 0) return Colors.White;
|
||||
cols.Sort((a, b) => (0.299 * a.R + 0.587 * a.G + 0.114 * a.B)
|
||||
.CompareTo(0.299 * b.R + 0.587 * b.G + 0.114 * b.B));
|
||||
return cols[cols.Count / 2];
|
||||
}
|
||||
catch { return Colors.White; }
|
||||
}
|
||||
|
||||
private static double ColorDist(Color a, Color b)
|
||||
{
|
||||
double dr = a.R - b.R, dg = a.G - b.G, db = a.B - b.B;
|
||||
return Math.Sqrt(dr * dr + dg * dg + db * db);
|
||||
}
|
||||
|
||||
/// <summary>The text "ink" color of a line: the color inside the glyph box farthest from the
|
||||
/// page background. Averages the purest-ink samples so anti-aliased edges don't desaturate it.
|
||||
/// Black on failure or when no real contrast is found.</summary>
|
||||
private Color SampleTextColor(int pageIdx, Rect textBounds, Color bg)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bmp = PageBitmapFor(pageIdx);
|
||||
if (bmp is null || !_renderDims.TryGetValue(pageIdx, out var rd) || rd.w <= 0 || rd.h <= 0)
|
||||
return Colors.Black;
|
||||
double sx = bmp.PixelWidth / (double)rd.w;
|
||||
double sy = bmp.PixelHeight / (double)rd.h;
|
||||
int cols = 16, rows = Math.Max(3, (int)Math.Min(8, textBounds.Height / 3));
|
||||
var scored = new List<(double dist, Color c)>();
|
||||
for (int ix = 0; ix < cols; ix++)
|
||||
for (int iy = 0; iy < rows; iy++)
|
||||
{
|
||||
double x = textBounds.Left + textBounds.Width * (ix + 0.5) / cols;
|
||||
double y = textBounds.Top + textBounds.Height * (iy + 0.5) / rows;
|
||||
var c = ReadBgraPixel(bmp, (int)Math.Round(x * sx), (int)Math.Round(y * sy));
|
||||
scored.Add((ColorDist(c, bg), c));
|
||||
}
|
||||
if (scored.Count == 0) return Colors.Black;
|
||||
scored.Sort((a, b) => b.dist.CompareTo(a.dist)); // most ink-like first
|
||||
double maxDist = scored[0].dist;
|
||||
if (maxDist < 24) return Colors.Black; // no real contrast -> default
|
||||
double thresh = maxDist * 0.7; // purest-ink cluster only
|
||||
double r = 0, g = 0, bl = 0; int n = 0;
|
||||
foreach (var (dist, c) in scored) { if (dist < thresh) break; r += c.R; g += c.G; bl += c.B; n++; }
|
||||
return n == 0 ? Colors.Black : Color.FromRgb((byte)(r / n), (byte)(g / n), (byte)(bl / n));
|
||||
}
|
||||
catch { return Colors.Black; }
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
||||
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 KillerPDF.Services;
|
||||
using PdfPigDoc = UglyToad.PdfPig.PdfDocument;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
// Wheel zoom, wheel scroll, and the pointer gestures that start on the page surface.
|
||||
//
|
||||
// Moved from Shell/Zoom.cs; this namespace and class line are the only changes. It lives with
|
||||
// the render pipeline because the two share the zoom state and the gesture routing
|
||||
// (_activeCanvas / _gestureCanvas) that decides which page a press landed on.
|
||||
//
|
||||
// Window members referenced bare here resolve through PdfViewer.Bridge.cs.
|
||||
public partial class PdfViewer
|
||||
{
|
||||
private readonly WheelPageFlipGate _wheelPageFlipGate = new();
|
||||
|
||||
// ============================================================
|
||||
// Zoom
|
||||
// ============================================================
|
||||
|
||||
// internal: PdfViewer's XAML binds this and forwards to it.
|
||||
internal void PagePreview_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
|
||||
{
|
||||
// #209: match the standard Windows/browser gesture and reuse the same path as a
|
||||
// physical tilt wheel. Wheel-down moves right; wheel-up moves left.
|
||||
if (Keyboard.Modifiers == ModifierKeys.Shift)
|
||||
{
|
||||
e.Handled = true;
|
||||
ScrollHorizontalExt(-e.Delta);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Keyboard.Modifiers == ModifierKeys.Control)
|
||||
{
|
||||
e.Handled = true;
|
||||
if (_viewMode == ViewMode.Grid) { GridZoomStep(e.Delta < 0); return; }
|
||||
|
||||
// Capture cursor position and scroll offsets BEFORE zoom changes so we can
|
||||
// compute the new offsets that keep the point under the cursor stationary.
|
||||
Point cursorInViewport = e.GetPosition(PagePreviewPanel);
|
||||
double oldZoom = _zoomLevel;
|
||||
double oldHOff = PagePreviewPanel.HorizontalOffset;
|
||||
double oldVOff = PagePreviewPanel.VerticalOffset;
|
||||
|
||||
// Smooth wheel zoom. Two parts:
|
||||
// 1) A multiplicative step - every notch changes the zoom by the same RATIO. The
|
||||
// old additive ZoomStep was a ~50% jump when zoomed out and barely visible when
|
||||
// zoomed in. The exponent scales with e.Delta, so a precision touchpad's small
|
||||
// frequent deltas produce proportionally small ratios (a continuous glide).
|
||||
// 2) A lite apply - only the ScaleTransform moves during the gesture (instant,
|
||||
// flicker-free, same path as live window-resize); the expensive tile/link
|
||||
// refresh and hi-res re-sharpen run ONCE when the wheel rests (settle timer)
|
||||
// instead of on every notch, which is what made zooming feel steppy.
|
||||
_fitMode = FitMode.None;
|
||||
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax,
|
||||
_zoomLevel * Math.Pow(WheelZoomFactor, e.Delta / 120.0)));
|
||||
ApplyZoom(lite: true);
|
||||
StartZoomSettleTimer();
|
||||
|
||||
// After layout settles, reposition the scroll so the cursor point stays fixed.
|
||||
// Formula: newOffset = (oldOffset + cursorPos) * (newZoom / oldZoom) - cursorPos
|
||||
double ratio = _zoomLevel / oldZoom;
|
||||
double newHOff = (oldHOff + cursorInViewport.X) * ratio - cursorInViewport.X;
|
||||
double newVOff = (oldVOff + cursorInViewport.Y) * ratio - cursorInViewport.Y;
|
||||
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, (Action)(() =>
|
||||
{
|
||||
PagePreviewPanel.ScrollToHorizontalOffset(Math.Max(0, newHOff));
|
||||
PagePreviewPanel.ScrollToVerticalOffset(Math.Max(0, newVOff));
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular scroll. Grid and Continuous are a single scroll over the WHOLE document, so the
|
||||
// wheel must never be hijacked for page navigation there - it always scrolls. (Page-nav
|
||||
// hijacking here was the old grid-refuses-to-scroll bug: right after a zoom/column change
|
||||
// the extent can momentarily measure as zero and the nav fallback fired instead.)
|
||||
if (_viewMode == ViewMode.Grid || _viewMode == ViewMode.Continuous)
|
||||
{
|
||||
ScrollWheel(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single / Two-Page: a page often fits the viewport, so at the scroll boundary fall
|
||||
// through to page navigation so the user can reach adjacent pages without the sidebar.
|
||||
if (PagePreviewPanel.ScrollableHeight <= 0)
|
||||
{
|
||||
e.Handled = true;
|
||||
if (_wheelPageFlipGate.TryConfirm(e.Delta, DateTime.UtcNow))
|
||||
NavigatePageByWheel(e.Delta);
|
||||
return;
|
||||
}
|
||||
|
||||
bool atTop = PagePreviewPanel.VerticalOffset <= 0;
|
||||
bool atBottom = PagePreviewPanel.VerticalOffset >= PagePreviewPanel.ScrollableHeight - 1;
|
||||
if ((atTop && e.Delta > 0) || (atBottom && e.Delta < 0))
|
||||
{
|
||||
e.Handled = true;
|
||||
if (_wheelPageFlipGate.TryConfirm(e.Delta, DateTime.UtcNow))
|
||||
NavigatePageByWheel(e.Delta);
|
||||
return;
|
||||
}
|
||||
_wheelPageFlipGate.NoteContentScroll(DateTime.UtcNow);
|
||||
ScrollWheel(e);
|
||||
}
|
||||
|
||||
// Zoom ratio per full wheel notch (e.Delta = 120) for Ctrl+scroll. 1.1 lands close to the
|
||||
// old additive step at 100% zoom but stays a constant 10% everywhere on the range.
|
||||
private const double WheelZoomFactor = 1.1;
|
||||
|
||||
// Wheel over the toolbar zoom dropdown: same multiplicative step as Ctrl+scroll, without
|
||||
// the cursor anchoring (the cursor is on the toolbar, not the page). Handled is set so the
|
||||
// ComboBox does not cycle its preset items under the wheel.
|
||||
internal void ZoomBoxWheel(MouseWheelEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
if (_doc is null) return;
|
||||
if (_viewMode == ViewMode.Grid) { GridZoomStep(e.Delta < 0); return; }
|
||||
_fitMode = FitMode.None;
|
||||
_zoomLevel = Math.Max(ZoomMin, Math.Min(ZoomMax,
|
||||
_zoomLevel * Math.Pow(WheelZoomFactor, e.Delta / 120.0)));
|
||||
ApplyZoom(lite: true); // SyncZoomBox inside keeps the shown % live per notch
|
||||
StartZoomSettleTimer();
|
||||
}
|
||||
|
||||
// Debounced full zoom apply, shared by every Ctrl+scroll notch: while the wheel is moving
|
||||
// only the lite ScaleTransform runs; once it rests for a beat, do the one full ApplyZoom
|
||||
// (tile/link refresh, and the hi-res re-sharpen it queues) plus the status-bar update that
|
||||
// SetZoom would have shown per notch.
|
||||
private System.Windows.Threading.DispatcherTimer? _zoomSettleTimer;
|
||||
|
||||
private void StartZoomSettleTimer()
|
||||
{
|
||||
if (_zoomSettleTimer is null)
|
||||
{
|
||||
_zoomSettleTimer = new System.Windows.Threading.DispatcherTimer
|
||||
{ Interval = TimeSpan.FromMilliseconds(200) };
|
||||
_zoomSettleTimer.Tick += (_, _) =>
|
||||
{
|
||||
_zoomSettleTimer!.Stop();
|
||||
if (_doc is null) return;
|
||||
ApplyZoom();
|
||||
if (_currentPage >= 0)
|
||||
SetStatus(string.Format(Loc("Str_PageOf"), _currentPage + 1, _doc.PageCount) + $" - {DisplayZoomPct():F0}%");
|
||||
};
|
||||
}
|
||||
_zoomSettleTimer.Stop();
|
||||
_zoomSettleTimer.Start();
|
||||
}
|
||||
|
||||
// The ScrollViewer default (3 lines = 48 DIP per wheel notch) feels slow on tall documents,
|
||||
// so scroll WheelScrollFactor times that instead. e.Delta is +-120 per notch on a standard
|
||||
// wheel (precision touchpads send smaller, more frequent deltas, which scale the same way).
|
||||
// ScrollToVerticalOffset clamps to the valid range itself.
|
||||
// internal: PageSelection.cs reuses it so the sidebar scrolls at the document's speed.
|
||||
internal const double WheelScrollFactor = 3.0;
|
||||
|
||||
private void ScrollWheel(MouseWheelEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
PagePreviewPanel.ScrollToVerticalOffset(
|
||||
PagePreviewPanel.VerticalOffset - e.Delta * (48.0 / 120.0) * WheelScrollFactor);
|
||||
}
|
||||
|
||||
// #196: horizontal scroll fed from the window's WM_MOUSEHWHEEL hook (WPF surfaces no
|
||||
// event for it). Same per-delta distance as the vertical wheel; positive = right.
|
||||
internal void ScrollHorizontalExt(int delta)
|
||||
{
|
||||
if (_doc is null || PagePreviewPanel.Visibility != Visibility.Visible) return;
|
||||
PagePreviewPanel.ScrollToHorizontalOffset(
|
||||
PagePreviewPanel.HorizontalOffset + delta * (48.0 / 120.0) * WheelScrollFactor);
|
||||
}
|
||||
|
||||
// Walks up the visual tree from the press's hit element to see if it landed on the scrollbar
|
||||
// (thumb, track, or repeat buttons). Used to exempt scrollbar presses from pane pan/marquee/crop.
|
||||
private static bool PressIsOnScrollBar(MouseButtonEventArgs e)
|
||||
{
|
||||
DependencyObject? d = e.OriginalSource as DependencyObject;
|
||||
while (d is not null)
|
||||
{
|
||||
if (d is System.Windows.Controls.Primitives.ScrollBar) return true;
|
||||
d = d is System.Windows.Media.Visual or System.Windows.Media.Media3D.Visual3D
|
||||
? System.Windows.Media.VisualTreeHelper.GetParent(d)
|
||||
: LogicalTreeHelper.GetParent(d);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
internal void PagePreviewPanel_PreviewMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
// A press that lands on the document scrollbar must reach the scrollbar itself (thumb drag,
|
||||
// track paging). The pan/crop/marquee handling below otherwise claims the press first and sets
|
||||
// e.Handled, so the thumb could never be grabbed. Let scrollbar presses fall through untouched.
|
||||
if (PressIsOnScrollBar(e)) return;
|
||||
|
||||
bool spaceDown = Keyboard.IsKeyDown(Key.Space);
|
||||
if (e.ChangedButton == MouseButton.Middle ||
|
||||
(e.ChangedButton == MouseButton.Left && spaceDown))
|
||||
{
|
||||
_isPanning = true;
|
||||
_panStart = e.GetPosition(PagePreviewPanel);
|
||||
_panScrollH = PagePreviewPanel.HorizontalOffset;
|
||||
_panScrollV = PagePreviewPanel.VerticalOffset;
|
||||
PagePreviewPanel.CaptureMouse();
|
||||
PagePreviewPanel.Cursor = Cursors.SizeAll;
|
||||
e.Handled = true;
|
||||
}
|
||||
// Crop: allow starting the selection OUTSIDE the page - catch margin clicks, route them to the
|
||||
// nearest page overlay, and clamp the start to the page edge so the crop rect stays on the page.
|
||||
else if (e.ChangedButton == MouseButton.Left && !spaceDown
|
||||
&& _currentTool == EditTool.Crop && _doc is not null)
|
||||
{
|
||||
Canvas? target = ResolveMarginOverlay(e);
|
||||
if (target is not null && target.Width > 0 && target.Height > 0)
|
||||
{
|
||||
_activeCanvas = target;
|
||||
// Pin the gesture surface/page so mouse-move/up resolve against this overlay
|
||||
// (a margin crop start doesn't go through Canvas_MouseLeftButtonDown).
|
||||
_gestureCanvas = target;
|
||||
_gesturePage = target.Tag is int gt ? gt : _currentPage;
|
||||
var p = e.GetPosition(target);
|
||||
p.X = Math.Max(0, Math.Min(target.Width, p.X));
|
||||
p.Y = Math.Max(0, Math.Min(target.Height, p.Y));
|
||||
StartCropDraw(p);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
// Marquee select: start a selection rectangle in the margin so it can span onto the pages. Same
|
||||
// routing as crop, but the start point is NOT clamped, so the box can begin off-page.
|
||||
else if (e.ChangedButton == MouseButton.Left && !spaceDown
|
||||
&& _currentTool == EditTool.Select && _doc is not null)
|
||||
{
|
||||
Canvas? target = ResolveMarginOverlay(e);
|
||||
if (target is not null && target.Width > 0 && target.Height > 0)
|
||||
{
|
||||
StartMarqueeDraw(target, e.GetPosition(target));
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolves which page overlay a margin (off-page) click attaches to, or null when the click is
|
||||
// actually on a page (left to that page's own surface). Shared by off-page crop and marquee starts.
|
||||
private Canvas? ResolveMarginOverlay(MouseButtonEventArgs e)
|
||||
{
|
||||
if (_viewMode == ViewMode.Continuous)
|
||||
{
|
||||
if (e.OriginalSource is DependencyObject osc && IsWithinPageOverlay(osc)) return null;
|
||||
int pg = _currentPage;
|
||||
if (pg < 0 || !_continuousCanvases.ContainsKey(pg))
|
||||
pg = NearestContinuousPage(e.GetPosition(_continuousPanel).Y);
|
||||
return pg >= 0 && _continuousCanvases.TryGetValue(pg, out var c) ? c : null;
|
||||
}
|
||||
bool onPrimary = e.OriginalSource is DependencyObject oss && IsDescendantOf(oss, _annotationCanvas);
|
||||
bool onTile = e.OriginalSource is DependencyObject ost && IsWithinPageOverlay(ost);
|
||||
return (!onPrimary && !onTile) ? _annotationCanvas : null;
|
||||
}
|
||||
|
||||
// Begins a marquee anchored to refCanvas at posInRef (that page's coords, possibly off-page and
|
||||
// un-clamped). The box draws on the cross-page MarqueeLayer; the existing move/up handlers finish it.
|
||||
private void StartMarqueeDraw(Canvas refCanvas, Point posInRef)
|
||||
{
|
||||
_activeCanvas = refCanvas;
|
||||
_gestureCanvas = refCanvas;
|
||||
_gesturePage = refCanvas.Tag is int gt ? gt : _currentPage;
|
||||
ClearSelection();
|
||||
ClearTextSelection();
|
||||
_isSelecting = true;
|
||||
_selectStart = posInRef;
|
||||
_selectRect = new Rectangle
|
||||
{
|
||||
Fill = AccentBrush(40),
|
||||
Stroke = AccentBrush(150),
|
||||
StrokeThickness = 1,
|
||||
Width = 0, Height = 0,
|
||||
IsHitTestVisible = false
|
||||
};
|
||||
MarqueeLayer.Children.Add(_selectRect);
|
||||
UpdateMarquee(posInRef, posInRef);
|
||||
refCanvas.CaptureMouse();
|
||||
}
|
||||
|
||||
// Begin a crop selection on the active overlay at pos (render-dim coords).
|
||||
private void StartCropDraw(Point pos)
|
||||
{
|
||||
_cropPageIndex = _activeCanvas.Tag is int cpi ? cpi : (_viewMode == ViewMode.Grid ? 0 : _currentPage);
|
||||
ClearSelection();
|
||||
_isDrawing = true;
|
||||
_drawStart = pos;
|
||||
// Draw the NEW box as a separate rect; the existing box, handles, and bar stay put until this
|
||||
// draw is committed on mouse-up (so a mouse-down never wipes the current box or bar).
|
||||
var cropDrawRect = new Rectangle
|
||||
{
|
||||
Stroke = Brushes.White,
|
||||
StrokeThickness = 1.5,
|
||||
StrokeDashArray = [5, 3],
|
||||
Fill = AccentBrush(55),
|
||||
Width = 0,
|
||||
Height = 0,
|
||||
IsHitTestVisible = false,
|
||||
Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||||
{ Color = Colors.Black, ShadowDepth = 0, BlurRadius = 3, Opacity = 0.7 },
|
||||
};
|
||||
Canvas.SetLeft(cropDrawRect, pos.X);
|
||||
Canvas.SetTop(cropDrawRect, pos.Y);
|
||||
Panel.SetZIndex(cropDrawRect, 2);
|
||||
_activeCanvas.Children.Add(cropDrawRect);
|
||||
_activePreview = cropDrawRect;
|
||||
_activeCanvas.CaptureMouse();
|
||||
}
|
||||
|
||||
private bool IsWithinPageOverlay(DependencyObject node)
|
||||
{
|
||||
var cur = node;
|
||||
while (cur != null)
|
||||
{
|
||||
if (cur is Canvas c && _continuousCanvases.ContainsValue(c)) return true;
|
||||
cur = VisualTreeHelper.GetParent(cur);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
internal void PagePreviewPanel_PreviewMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (!_isPanning) return;
|
||||
var pos = e.GetPosition(PagePreviewPanel);
|
||||
PagePreviewPanel.ScrollToHorizontalOffset(_panScrollH - (pos.X - _panStart.X));
|
||||
PagePreviewPanel.ScrollToVerticalOffset(_panScrollV - (pos.Y - _panStart.Y));
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
internal void PagePreviewPanel_PreviewMouseUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (!_isPanning) return;
|
||||
if (e.ChangedButton != MouseButton.Middle && e.ChangedButton != MouseButton.Left) return;
|
||||
_isPanning = false;
|
||||
PagePreviewPanel.ReleaseMouseCapture();
|
||||
PagePreviewPanel.Cursor = _spaceHeld ? Cursors.Hand : Cursors.Arrow;
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
<UserControl x:Class="KillerPDF.Controls.PdfViewer"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!-- One document view: this pane's tab strip and its card.
|
||||
|
||||
The card's MARGIN lives on this control, not on the borders below - ApplySidebarSide sets
|
||||
it to flip the 8px gutter to whichever side the document is on. -->
|
||||
<Grid>
|
||||
<!-- Row 0 is the strip, row 1 the card. Each pane carrying its own strip means the strip
|
||||
tracks its pane's width and position for free, including through a boundary drag.
|
||||
Row 0 is Auto and the band collapses below two tabs. -->
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- ZIndex above the card: the card's -1 top margin tucks its top border into this band's
|
||||
row, and the active tab - which carries the pane's own BgCanvas - covers that pixel so
|
||||
the two read as one surface. -->
|
||||
<Border Grid.Row="0" x:Name="TabStripBorder" Panel.ZIndex="10"
|
||||
Height="{DynamicResource TabBandHeight}"
|
||||
Background="Transparent" Visibility="Collapsed"
|
||||
SizeChanged="TabStripBorder_SizeChanged">
|
||||
<Grid>
|
||||
<!-- Match KillerShell: the strip is transparent and does not paint another grain
|
||||
tile over the window's existing background. Only the opaque active tab
|
||||
replaces that shared grain below. -->
|
||||
<Border x:Name="TabStripFade" IsHitTestVisible="False"
|
||||
Opacity="{DynamicResource BarShadowOpacity}">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#00000000" Offset="0"/>
|
||||
<GradientStop Color="#00000000" Offset="0.45"/>
|
||||
<GradientStop Color="#59000000" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
<!-- TabBarRing, ported from KillerShell. The card's top border drawn again, radii
|
||||
and all - 7px tall with a -6 bottom margin, so its 1px top edge lands in the
|
||||
band's last row and its curved sides drop out of the band onto the card's own
|
||||
left/right border. A flat 1px strip would run past the card's rounded corners.
|
||||
SyncPaneLeadingCorner keeps the radii in step with the card's; SetFocusHalo
|
||||
drives its brush alongside the card's.
|
||||
Declared after the fade (whose dark bottom would wash it out) and before the
|
||||
tabs, so the active tab covers its own segment and breaks the line at the tab. -->
|
||||
<Border x:Name="TabBarRing" Height="7" VerticalAlignment="Bottom" Margin="0,0,0,-6"
|
||||
BorderThickness="1,1,1,0" CornerRadius="{DynamicResource TabCornerRadius}" IsHitTestVisible="False"
|
||||
BorderBrush="{DynamicResource PaneEdgeBrush}"/>
|
||||
<DockPanel LastChildFill="True">
|
||||
<!-- Overflow chevron: every tab in this pane, hidden ones included
|
||||
(TabOverflowMenu). Docked BEFORE the strip so the strip measures against
|
||||
the band's REMAINING width - docked after, the strip would take the whole
|
||||
band and the chevron would sit on top of the last tab.
|
||||
Collapsed while everything fits, which is the normal case: a chevron that
|
||||
is always there is a control that does nothing most of the time, and its
|
||||
26px would come out of the tabs to say so. -->
|
||||
<!-- DynamicResource, NOT StaticResource, on the style - same reason as
|
||||
GrainBrushShared above. TabNewButton lives in MainWindow.Resources, and
|
||||
StaticResource resolves at parse time against this control's own scope,
|
||||
before the control is in the window's tree, so it throws from
|
||||
InitializeComponent. -->
|
||||
<Button x:Name="TabOverflowBtn" DockPanel.Dock="Right" Visibility="Collapsed"
|
||||
Style="{DynamicResource TabNewButton}"
|
||||
Content="" FontFamily="{DynamicResource IconFont}" FontSize="10"
|
||||
Width="22" Height="20" Margin="2,4,2,3" VerticalAlignment="Center"
|
||||
FocusVisualStyle="{x:Null}"
|
||||
ToolTip="{DynamicResource Str_TT_TabOverflow}" Click="TabOverflow_Click"/>
|
||||
<!-- No side insets. The strip has to be flush with the card beneath it, or the
|
||||
first tab's left edge sits 6px inside the card's left border and the ring
|
||||
steps sideways where it should run straight up. -->
|
||||
<!-- Horizontal scrolling is DISABLED, not hidden: hidden still measures the
|
||||
content at infinite width, which would let the tabs size to their own
|
||||
content and defeat the UniformGrid below. Disabled constrains them to the
|
||||
band's actual width, which is the point.
|
||||
FocusVisualStyle nulled: a ScrollViewer is focusable, and once focus lands
|
||||
here WPF draws its stock dotted rectangle around the whole strip. -->
|
||||
<ScrollViewer x:Name="TabScroll" VerticalScrollBarVisibility="Disabled" HorizontalScrollBarVisibility="Disabled"
|
||||
Background="Transparent" FocusVisualStyle="{x:Null}"
|
||||
UseLayoutRounding="True" SnapsToDevicePixels="True"
|
||||
MouseLeftButtonDown="TabScroll_MouseLeftButtonDown">
|
||||
<!-- Keep the edge overlays in the SAME viewport as the tab containers.
|
||||
When they were siblings of the ScrollViewer they started from the
|
||||
band's origin while the tabs started from the viewport's origin,
|
||||
producing the three-pixel vertical mismatch at the scrollbar corner. -->
|
||||
<Grid UseLayoutRounding="True" SnapsToDevicePixels="True">
|
||||
<ItemsControl x:Name="TabStrip" UseLayoutRounding="True" SnapsToDevicePixels="True">
|
||||
<!-- The ACTIVE tab draws above its neighbors. Items paint in index
|
||||
order, so the tab to the RIGHT of the active one painted over the
|
||||
active tab's 1px right border and the ring stopped dead at that
|
||||
side - which is why a middle tab lost its right edge while the last
|
||||
tab, having no neighbor after it, kept both. -->
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsActive}" Value="True">
|
||||
<Setter Property="Panel.ZIndex" Value="1"/>
|
||||
</DataTrigger>
|
||||
<!-- Windowed out of the strip and living in the chevron
|
||||
instead (ApplyTabWindow). Collapsed rather than removed
|
||||
from the collection, which is what makes this cheap:
|
||||
UniformGrid does not count a collapsed child when it
|
||||
divides the band, so the survivors fill it edge to edge
|
||||
on their own, and nothing about the tabs' order or
|
||||
their drag indices moves. -->
|
||||
<DataTrigger Binding="{Binding IsStripVisible}" Value="False">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemsPanel>
|
||||
<!-- Tabs share the band equally and fill it, browser-style, instead
|
||||
of hugging their titles and leaving dead space on the right.
|
||||
This is also what makes the last tab always REACH the strip's
|
||||
right edge, so edge ownership is a fact rather than something
|
||||
that has to be measured after every reflow. -->
|
||||
<ItemsPanelTemplate><UniformGrid Rows="1"/></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<!-- No DataType: the item is PdfViewer.DocumentSession, an internal
|
||||
nested type, which x:Type cannot name. -->
|
||||
<DataTemplate>
|
||||
<!-- No MaxWidth: it would cap each cell and put the dead space
|
||||
back. The MinWidth floor is low so a lot of open tabs still
|
||||
divide the band rather than overflowing it.
|
||||
CornerRadius matches the card's 6: the rightmost tab sits
|
||||
directly above the card's top-right corner, and a 4px curve
|
||||
stacked on a 6px one reads as a mistake rather than as two
|
||||
rounded things.
|
||||
No right MARGIN: the 1px right BorderThickness below
|
||||
already separates one tab from the next, and a margin only
|
||||
stopped the last tab reaching the strip's right edge -
|
||||
which is the card's edge, so it left a 1px step under the
|
||||
corner.
|
||||
1px bottom margin keeps an inactive tab clear of
|
||||
TabBarRing; the active tab drops it and covers its own
|
||||
segment, which is what breaks that line exactly at the tab
|
||||
and makes the browser-tab join. -->
|
||||
<Border x:Name="tabBd" CornerRadius="{DynamicResource TabCornerRadius}" Margin="{DynamicResource TabMargin}" Padding="{DynamicResource TabPadding}"
|
||||
MinWidth="60"
|
||||
UseLayoutRounding="True" SnapsToDevicePixels="True"
|
||||
Cursor="Hand" Background="Transparent"
|
||||
BorderThickness="0,0,1,0" BorderBrush="{DynamicResource PaneEdgeBrush}"
|
||||
ToolTip="{Binding TabTip}"
|
||||
MouseDown="Tab_MouseDown"
|
||||
MouseRightButtonUp="Tab_RightClick"
|
||||
PreviewMouseLeftButtonDown="Tab_DragDown"
|
||||
PreviewMouseMove="Tab_DragMove"
|
||||
PreviewMouseLeftButtonUp="Tab_DragUp">
|
||||
<Grid>
|
||||
<!-- Grain follows the tab's radius, or the texture
|
||||
squares off the corner the border just rounded. -->
|
||||
<Border x:Name="tabGrain" IsHitTestVisible="False"
|
||||
CornerRadius="{DynamicResource TabCornerRadius}" Margin="-12,-4,-5,-5"
|
||||
Background="{DynamicResource GrainTileBrush}" Opacity="0"/>
|
||||
<DockPanel LastChildFill="True">
|
||||
<Button DockPanel.Dock="Right" Content=""
|
||||
Style="{DynamicResource TabCloseButton}"
|
||||
FontFamily="{DynamicResource IconFont}" FontSize="9"
|
||||
Width="16" Height="16" Margin="6,0,0,0" Padding="0"
|
||||
VerticalAlignment="Center" FocusVisualStyle="{x:Null}"
|
||||
ToolTip="{DynamicResource Str_TT_CloseTab}"
|
||||
Tag="{Binding}" Click="CloseTab_Click"/>
|
||||
<TextBlock x:Name="tabLbl" Text="{Binding TabLabel}"
|
||||
FontFamily="{DynamicResource UiFont}" FontSize="11"
|
||||
Foreground="{DynamicResource MutedTextBrush}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
<Border x:Name="tabBevelLight" IsHitTestVisible="False" Panel.ZIndex="4"
|
||||
Margin="{DynamicResource TabBevelMargin}" SnapsToDevicePixels="True"
|
||||
BorderBrush="{DynamicResource BevelLightBrush}" BorderThickness="{DynamicResource BevelLightThickness}"/>
|
||||
<Border x:Name="tabBevelDark" IsHitTestVisible="False" Panel.ZIndex="4"
|
||||
Margin="{DynamicResource TabBevelMargin}" SnapsToDevicePixels="True"
|
||||
BorderBrush="{DynamicResource BevelDarkBrush}" BorderThickness="{DynamicResource TabInactiveBevelDarkThickness}"/>
|
||||
<!-- Second, inset highlight of the selected 98SE tab. Together
|
||||
with tabBevelLight this makes the tab a raised two-tone edge,
|
||||
matching the pane's outer and inner highlights instead of
|
||||
degenerating into a flat rule at the tab/pane join. Other
|
||||
themes resolve these resources to a transparent zero edge. -->
|
||||
<Border x:Name="tabActiveRetroInnerBevel" IsHitTestVisible="False" Panel.ZIndex="6"
|
||||
Margin="{DynamicResource TabActiveInnerBevelMargin}"
|
||||
BorderBrush="{DynamicResource TabActiveInnerBevelBrush}"
|
||||
BorderThickness="{DynamicResource TabActiveInnerBevelThickness}"
|
||||
SnapsToDevicePixels="True" Visibility="Collapsed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<DataTemplate.Triggers>
|
||||
<!-- Last tab drops its right border: that 1px is a divider
|
||||
BETWEEN tabs, and on the strip's right edge it reads as
|
||||
a stray rule instead. Declared BEFORE the IsActive
|
||||
trigger so the active tab's accent-stripe thickness
|
||||
still wins when the last tab is also the active one. -->
|
||||
<DataTrigger Binding="{Binding IsLast}" Value="True">
|
||||
<Setter TargetName="tabBd" Property="BorderThickness" Value="0"/>
|
||||
</DataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||
<Condition Binding="{Binding IsActive}" Value="False"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabInactiveFirstMargin}"/>
|
||||
</MultiDataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||
<Condition Binding="{Binding IsActive}" Value="False"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabInactiveLastMargin}"/>
|
||||
</MultiDataTrigger>
|
||||
<DataTrigger Binding="{Binding IsActive}" Value="True">
|
||||
<!-- Front tab takes the card's own canvas color, so the
|
||||
tab and the card read as one surface. -->
|
||||
<Setter TargetName="tabBd" Property="Background" Value="{DynamicResource BgCanvas}"/>
|
||||
<Setter TargetName="tabGrain" Property="Opacity" Value="{DynamicResource GrainOpacity}"/>
|
||||
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabStripeThickness}"/>
|
||||
<!-- Reaches the foot of the band so its fill breaks the
|
||||
ring line where the tab is. -->
|
||||
<Setter TargetName="tabBd" Property="Margin" Value="0,3,0,0"/>
|
||||
<!-- Top padding gives back the 3px the accent stripe
|
||||
takes, so the title does not drop on activation. -->
|
||||
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabActivePadding}"/>
|
||||
<Setter TargetName="tabBd" Property="BorderBrush" Value="{DynamicResource TabActiveRingBrush}"/>
|
||||
<!-- Modern themes use the original raised-tab shadow. 98SE
|
||||
disables it in the theme-gated trigger below because an Effect
|
||||
rasterizes the label and destroys the crisp classic text. -->
|
||||
<Setter TargetName="tabBd" Property="Effect" Value="{DynamicResource BarShadowEffect}"/>
|
||||
<Setter TargetName="tabLbl" Property="Foreground" Value="{DynamicResource TextBrush}"/>
|
||||
<Setter TargetName="tabLbl" Property="FontWeight" Value="Bold"/>
|
||||
<Setter TargetName="tabLbl" Property="FontSize" Value="11.5"/>
|
||||
<Setter TargetName="tabBevelDark" Property="BorderThickness" Value="{DynamicResource TabActiveBevelDarkThickness}"/>
|
||||
<Setter TargetName="tabBevelDark" Property="Margin" Value="{DynamicResource TabActiveBevelDarkMargin}"/>
|
||||
<Setter TargetName="tabActiveRetroInnerBevel" Property="Visibility" Value="{DynamicResource RetroActiveTabOutlineVisibility}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding UseRetroTabChrome}" Value="True">
|
||||
<Setter TargetName="tabBd" Property="Effect" Value="{x:Null}"/>
|
||||
</DataTrigger>
|
||||
<!-- A first 98SE tab follows the pane's inner light bevel,
|
||||
one pixel inside its dark outer frame. Modern themes
|
||||
resolve this token to the normal flush active margin. -->
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsActive}" Value="True"/>
|
||||
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabActiveFirstMargin}"/>
|
||||
</MultiDataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsActive}" Value="True"/>
|
||||
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabActiveLastMargin}"/>
|
||||
</MultiDataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsActive}" Value="True"/>
|
||||
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="Margin" Value="{DynamicResource TabActiveOnlyMargin}"/>
|
||||
</MultiDataTrigger>
|
||||
<!-- Unfocused pane: the lip drops off the accent entirely
|
||||
and takes the card's own border color, so the whole
|
||||
unlit ring - lip, band line and card border - is ONE
|
||||
value. At full accent both panes claimed to be the live
|
||||
one. The active tab is still obvious on an unfocused
|
||||
pane from its canvas fill and bold title, which is how
|
||||
a browser marks it too.
|
||||
Mutually exclusive with PaneFocused below, and NOT
|
||||
simply !PaneFocused: with one pane open both are false
|
||||
and the lone pane's lip stays bright. -->
|
||||
<DataTrigger Binding="{Binding PaneDimmed}" Value="True">
|
||||
<Setter TargetName="tabBd" Property="BorderBrush" Value="{DynamicResource PaneBorderBrush}"/>
|
||||
</DataTrigger>
|
||||
<!-- Focused pane: the ring continues UP both sides of the
|
||||
active tab, so the tab and the card read as one
|
||||
outlined surface instead of the ring stopping dead at
|
||||
the strip. Declared LAST on purpose - PaneFocused
|
||||
implies IsActive, both triggers match, and with
|
||||
multiple matches the last one wins. Put earlier,
|
||||
IsActive would overwrite the thickness straight back to
|
||||
a top-only stripe.
|
||||
One brush for the whole lip, matching the card's ring:
|
||||
a top stripe in one color meeting sides in another
|
||||
reads as two edges rather than one ring.
|
||||
Padding drops 1px each side to pay for the new borders,
|
||||
so the title does not shift when focus arrives. -->
|
||||
<DataTrigger Binding="{Binding PaneFocused}" Value="True">
|
||||
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabFocusThickness}"/>
|
||||
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabFocusPadding}"/>
|
||||
<Setter TargetName="tabBd" Property="BorderBrush" Value="{DynamicResource TabActiveRingBrush}"/>
|
||||
</DataTrigger>
|
||||
<!-- Pane shading is the 98SE focus cue only. Keeping it behind an
|
||||
explicit theme flag preserves every modern palette's original
|
||||
active-tab BgCanvas fill. -->
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding UseRetroTabChrome}" Value="True"/>
|
||||
<Condition Binding="{Binding PaneDimmed}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="Background" Value="{DynamicResource TabInactiveBrush}"/>
|
||||
</MultiDataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding UseRetroTabChrome}" Value="True"/>
|
||||
<Condition Binding="{Binding PaneFocused}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="Background" Value="{DynamicResource FocusedPaneBrush}"/>
|
||||
</MultiDataTrigger>
|
||||
<!-- The OUTERMOST side is the band's to draw (TabEdgeLeft /
|
||||
TabEdgeRight), so the first and last tab must not draw
|
||||
it as well. Two 1px borders at the same x is a 2px edge
|
||||
on a ring that is 1px everywhere else - and only
|
||||
SOMETIMES, because whether the tab's own outer border
|
||||
clears the ScrollViewer's clip depends on how the
|
||||
UniformGrid divided a fractional band width. That is
|
||||
where the halo came out uneven, and why it changed when
|
||||
the split moved.
|
||||
The padding gives the pixel back on that side so the
|
||||
title does not shift, exactly as PaneFocused does.
|
||||
Declared after PaneFocused - all three match on a first
|
||||
or last tab and the last match wins. -->
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding PaneFocused}" Value="True"/>
|
||||
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabFocusFirstThickness}"/>
|
||||
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabFocusFirstPadding}"/>
|
||||
</MultiDataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding PaneFocused}" Value="True"/>
|
||||
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabFocusLastThickness}"/>
|
||||
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabFocusLastPadding}"/>
|
||||
</MultiDataTrigger>
|
||||
<!-- One tab in a focused pane owns BOTH edges, so it draws
|
||||
neither. Last of the three for the same reason. -->
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding PaneFocused}" Value="True"/>
|
||||
<Condition Binding="{Binding IsFirst}" Value="True"/>
|
||||
<Condition Binding="{Binding IsLast}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter TargetName="tabBd" Property="BorderThickness" Value="{DynamicResource TabFocusOnlyThickness}"/>
|
||||
<Setter TargetName="tabBd" Property="Padding" Value="{DynamicResource TabFocusOnlyPadding}"/>
|
||||
</MultiDataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<!-- The outer gray frame pixels. The tabs reserve their edge pixel and
|
||||
draw the inner white/gray bevel themselves; these borders complete
|
||||
only the outermost frame and therefore cannot thicken the bevel. -->
|
||||
<Border x:Name="TabEdgeLeft" Width="1" HorizontalAlignment="Left"
|
||||
IsHitTestVisible="False" Visibility="Collapsed"/>
|
||||
<Border x:Name="TabEdgeRight" Width="1" HorizontalAlignment="Right"
|
||||
IsHitTestVisible="False" Visibility="Collapsed"/>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ── This pane's card ───────────────────────────────────────────────────────────
|
||||
Row 1. The -1 top margin tucks the card's top border up into the strip band so the
|
||||
active tab and the card read as one surface (see TabBarRing).
|
||||
|
||||
Margin is set in CODE, not here: it must be -1 only while this pane HAS a strip.
|
||||
A pane with one tab collapses its strip, and the -1 then lifts that pane a pixel above
|
||||
the other one instead of tucking under anything. RebuildTabStrip owns both. -->
|
||||
<Grid x:Name="CardRow" Grid.Row="1">
|
||||
<!-- Separate shadow caster keeps the pane content crisp while allowing each modern
|
||||
theme to supply its intended elevation. 98SE sets PaneShadowOpacity to zero. -->
|
||||
<!-- DynamicResource PaneShadowEffect, built per theme in ThemeManager (null on 98SE) -
|
||||
the old app-level StaticResource effect froze with its startup opacity and 98SE's
|
||||
zero never applied. -->
|
||||
<Border x:Name="PaneShadow" IsHitTestVisible="False" Margin="0,1,0,0"
|
||||
Background="{DynamicResource BgCanvas}"
|
||||
CornerRadius="{DynamicResource RadCard}"
|
||||
Effect="{DynamicResource PaneShadowEffect}"/>
|
||||
<Border x:Name="PaneBorder" Panel.ZIndex="6"
|
||||
Background="{DynamicResource BgCanvas}"
|
||||
BorderBrush="{DynamicResource PaneBorderBrush}" BorderThickness="1"
|
||||
CornerRadius="{DynamicResource RadCard}">
|
||||
<!-- Clipped to the card's own corners (DocPane_SizeChanged). A Border with a
|
||||
CornerRadius does NOT clip its child, so the canvas and the page square the
|
||||
corners straight back off. Same treatment as KillerShell's PaneContent. -->
|
||||
<Grid x:Name="DocPaneContent" SizeChanged="DocPane_SizeChanged">
|
||||
<Border x:Name="PaneBevelOuterDark" Panel.ZIndex="100" IsHitTestVisible="False" BorderBrush="{DynamicResource PaneBevelDarkBrush}" BorderThickness="{DynamicResource PaneBevelLightThickness}"/>
|
||||
<Border x:Name="PaneBevelOuterLight" Panel.ZIndex="100" IsHitTestVisible="False" BorderBrush="{DynamicResource PaneBevelLightBrush}" BorderThickness="{DynamicResource PaneBevelDarkThickness}"/>
|
||||
<Border x:Name="PaneBevelInnerDark" Panel.ZIndex="100" IsHitTestVisible="False" Margin="{DynamicResource PaneBevelInnerMargin}" BorderBrush="{DynamicResource PaneBevelDark2Brush}" BorderThickness="{DynamicResource PaneBevel2LightThickness}"/>
|
||||
<Border x:Name="PaneBevelInnerLight" Panel.ZIndex="100" IsHitTestVisible="False" Margin="{DynamicResource PaneBevelInnerMargin}" BorderBrush="{DynamicResource PaneBevelLight2Brush}" BorderThickness="{DynamicResource PaneBevel2DarkThickness}"/>
|
||||
<!-- Film grain - sits on the canvas background, behind the document.
|
||||
Needs no CornerRadius of its own: the clip on DocPaneContent rounds
|
||||
everything inside the card, grain included. -->
|
||||
<Border IsHitTestVisible="False" Opacity="{DynamicResource GrainOpacity}">
|
||||
<Border.Background>
|
||||
<ImageBrush x:Name="GrainBrush"
|
||||
TileMode="Tile"
|
||||
ViewportUnits="Absolute"
|
||||
Viewport="0,0,256,256"
|
||||
Stretch="None"/>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<!-- Drop zone -->
|
||||
<Border x:Name="DropZone" Background="Transparent"
|
||||
AllowDrop="True" Drop="DropZone_Drop" DragOver="DropZone_DragOver"
|
||||
MouseLeftButtonDown="DropZone_Click">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Drop target, centered in the remaining space -->
|
||||
<Border Grid.Column="0" BorderBrush="{DynamicResource DropBorder}" BorderThickness="2"
|
||||
CornerRadius="{DynamicResource PanelCornerRadius}" Padding="40"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Background="Transparent" Style="{x:Null}">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<TextBlock Text="{DynamicResource Str_Drop_Title}" FontFamily="Segoe UI, Microsoft JhengHei UI, Nirmala UI" FontSize="20"
|
||||
Foreground="{DynamicResource MutedTextBrush}" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="{DynamicResource Str_Drop_Sub}" FontFamily="Segoe UI, Microsoft JhengHei UI, Nirmala UI" FontSize="13"
|
||||
Foreground="{DynamicResource MutedTextBrush}" HorizontalAlignment="Center" Margin="0,8,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Recent files sidebar (populated in code-behind; hidden when empty).
|
||||
Width and visibility are driven by SyncRecentBoxWidth: at 340 fixed it
|
||||
swamped the drop target in a half-width pane, so it now scales with the
|
||||
pane and drops out entirely below a threshold. -->
|
||||
<Border x:Name="RecentFilesBox" Grid.Column="1" Width="340" Visibility="Collapsed"
|
||||
Background="{DynamicResource BgRecentPanel}"
|
||||
BorderBrush="{DynamicResource PaneBorderBrush}" BorderThickness="1,0,0,0">
|
||||
<Grid>
|
||||
<Border Background="{DynamicResource GrainBrushShared}" IsHitTestVisible="False"
|
||||
Opacity="{DynamicResource GrainOpacity}"/>
|
||||
<DockPanel Margin="18,22,12,16">
|
||||
<DockPanel DockPanel.Dock="Top" Margin="2,0,0,10">
|
||||
<!-- Clear all (#146): one click empties the whole list, matching the
|
||||
dropdown's Clear list item. Foreground lives in the style so the
|
||||
hover trigger can override it (a local value would win over it). -->
|
||||
<TextBlock DockPanel.Dock="Right" Text="{DynamicResource Str_Menu_ClearList}"
|
||||
FontFamily="Segoe UI, Microsoft JhengHei UI, Nirmala UI"
|
||||
FontSize="11" Cursor="Hand" Margin="8,0,6,0"
|
||||
MouseLeftButtonDown="RecentClearAll_Click">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource MutedTextBrush}"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryBrush}"/>
|
||||
<Setter Property="TextDecorations" Value="Underline"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{DynamicResource Str_RecentHeading}" FontFamily="Segoe UI, Microsoft JhengHei UI, Nirmala UI"
|
||||
FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource PrimaryBrush}"/>
|
||||
</DockPanel>
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl x:Name="RecentFilesList"/>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- PDF page with annotation overlay -->
|
||||
<ScrollViewer x:Name="PagePreviewPanel" Visibility="Collapsed"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Background="Transparent"
|
||||
FocusVisualStyle="{x:Null}"
|
||||
PreviewMouseWheel="PagePreview_PreviewMouseWheel"
|
||||
SizeChanged="PagePreviewPanel_SizeChanged"
|
||||
PreviewMouseDown="PagePreviewPanel_PreviewMouseDown"
|
||||
PreviewMouseMove="PagePreviewPanel_PreviewMouseMove"
|
||||
PreviewMouseUp="PagePreviewPanel_PreviewMouseUp"
|
||||
MouseRightButtonUp="DocPaneBackground_RightClick"
|
||||
AllowDrop="True" Drop="DropZone_Drop" DragOver="DropZone_DragOver">
|
||||
<!-- Custom template: the VERTICAL scrollbar spans the full height (RowSpan=2),
|
||||
covering the bottom-right corner cell, and the horizontal bar butts up
|
||||
against it. The default template puts a white system-colored corner
|
||||
rectangle there, which read as a stray white square against the dark
|
||||
canvas whenever both scrollbars were visible.
|
||||
The PART_ names below are TEMPLATE-scoped and so are unaffected by this
|
||||
control being its own namescope - do not "fix" them. -->
|
||||
<ScrollViewer.Template>
|
||||
<ControlTemplate TargetType="ScrollViewer">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<ScrollContentPresenter Grid.Row="0" Grid.Column="0"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
CanContentScroll="{TemplateBinding CanContentScroll}"/>
|
||||
<ScrollBar x:Name="PART_VerticalScrollBar" Grid.Row="0" Grid.Column="1" Grid.RowSpan="2"
|
||||
Panel.ZIndex="101"
|
||||
Value="{TemplateBinding VerticalOffset}"
|
||||
Maximum="{TemplateBinding ScrollableHeight}"
|
||||
ViewportSize="{TemplateBinding ViewportHeight}"
|
||||
Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}"/>
|
||||
<ScrollBar x:Name="PART_HorizontalScrollBar" Grid.Row="1" Grid.Column="0"
|
||||
Panel.ZIndex="101"
|
||||
Orientation="Horizontal"
|
||||
Value="{TemplateBinding HorizontalOffset}"
|
||||
Maximum="{TemplateBinding ScrollableWidth}"
|
||||
ViewportSize="{TemplateBinding ViewportWidth}"
|
||||
Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}"/>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</ScrollViewer.Template>
|
||||
<Border x:Name="DocSurfacePad" VerticalAlignment="Top" HorizontalAlignment="Center" Padding="12">
|
||||
<!-- Override the window's Display/ClearType text mode for the zoomed
|
||||
document surface. Display + ClearType pixel-snaps and color-fringes
|
||||
when scaled, giving hard-edged text on annotations and form fields;
|
||||
Ideal + Grayscale stays smoothly anti-aliased at any zoom, matching
|
||||
how other PDF editors render. -->
|
||||
<Grid x:Name="PageContentGrid"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
TextOptions.TextRenderingMode="Grayscale">
|
||||
<Grid.LayoutTransform>
|
||||
<ScaleTransform ScaleX="1" ScaleY="1"/>
|
||||
</Grid.LayoutTransform>
|
||||
<WrapPanel x:Name="PageContentPanel" Orientation="Horizontal">
|
||||
<!-- Tile 0 (the primary page) is built in code by BuildPrimaryTile and
|
||||
inserted at index 0; additional pages are rendered dynamically too. -->
|
||||
</WrapPanel>
|
||||
<!-- Continuous scroll panel - shown in Continuous view mode -->
|
||||
<StackPanel x:Name="ContinuousPanel"
|
||||
HorizontalAlignment="Center"
|
||||
Orientation="Vertical"
|
||||
Visibility="Collapsed"/>
|
||||
<!-- Top-most layer for the selection marquee, so a drag can span pages -->
|
||||
<Canvas x:Name="MarqueeLayer" IsHitTestVisible="False"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- #197: current-page badge. Replaces the cursor-following page tooltips: slides
|
||||
up from the bottom corner on scroll and page changes, slides back down when
|
||||
the view settles (ShowPageBadge, Viewport.cs). Never hit-testable, so it
|
||||
cannot eat a page click. 26px right margin clears the vertical scrollbar. -->
|
||||
<Grid x:Name="PageBadge" HorizontalAlignment="Right" VerticalAlignment="Bottom"
|
||||
Margin="0,0,26,14" Panel.ZIndex="40" IsHitTestVisible="False" Opacity="0">
|
||||
<Grid.RenderTransform>
|
||||
<TranslateTransform x:Name="PageBadgeSlide" Y="46"/>
|
||||
</Grid.RenderTransform>
|
||||
|
||||
<!-- Cast from a separate empty rectangle so the effect never rasterizes the
|
||||
badge text. BarShadowOpacity keeps the weight consistent with the chrome
|
||||
and lets 98SE suppress it without a special-case code path. -->
|
||||
<Border Background="{DynamicResource MenuBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect Color="Black" BlurRadius="7" ShadowDepth="2"
|
||||
Direction="270" Opacity="{DynamicResource BarShadowOpacity}"
|
||||
RenderingBias="Quality"/>
|
||||
</Border.Effect>
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource MenuBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource MenuBorderBrush}" BorderThickness="1"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}" Padding="9,3">
|
||||
<Grid>
|
||||
<TextBlock x:Name="PageBadgeText" FontFamily="Consolas" FontSize="12"
|
||||
Foreground="{DynamicResource TextBrush}"/>
|
||||
<!-- Grain OVER the content, family rule -->
|
||||
<Border IsHitTestVisible="False" Margin="-9,-3"
|
||||
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||
Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid><!-- /card row -->
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using KillerPDF.Features;
|
||||
|
||||
namespace KillerPDF.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// One document view: its tab strip, its card and everything inside. Two instances make the
|
||||
/// split.
|
||||
///
|
||||
/// The handlers here are one-line forwards to the host, following KillerShell's FilePane idiom -
|
||||
/// chrome that belongs to the window stays on the window.
|
||||
/// </summary>
|
||||
public partial class PdfViewer : UserControl
|
||||
{
|
||||
/// <summary>The explicit shell boundary used by the viewer.</summary>
|
||||
internal IViewerHost? Host { get; private set; }
|
||||
|
||||
/// <summary>Document dark-mode invert, PER PANE (was a global that flipped both panes of
|
||||
/// a split at once). Display only; every render path in this pane reads this flag. The
|
||||
/// moon toggles the focused pane and its lit state follows pane focus.</summary>
|
||||
internal bool DocInvert;
|
||||
|
||||
internal void AttachHost(IViewerHost host) => Host = host;
|
||||
|
||||
/// <summary>This viewer's per-view state - page maps, view mode, zoom, render cancellation,
|
||||
/// continuous bookkeeping. MainWindow's `_view` reads it back from here, so a second pane
|
||||
/// gets its own simply by existing.</summary>
|
||||
internal ViewerState State { get; } = new();
|
||||
|
||||
public PdfViewer() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// Build this pane's tile tree. Every pane must do this for itself: routed through
|
||||
/// ActiveViewer it would run twice on pane A and leave pane B's State.AnnotationCanvas
|
||||
/// null, which AllPageCanvases() yields first and the first text-selection repaint then
|
||||
/// dereferences. The panel references must be assigned before BuildPrimaryTile, which
|
||||
/// inserts into PageContentPanel.
|
||||
/// </summary>
|
||||
internal void InitTiles()
|
||||
{
|
||||
State.PageContentGrid = PageContentGrid;
|
||||
State.PageContentPanel = PageContentPanel;
|
||||
State.ContinuousPanel = ContinuousPanel;
|
||||
BuildPrimaryTile();
|
||||
State.ActiveCanvas = State.AnnotationCanvas;
|
||||
}
|
||||
|
||||
/// <summary>Accent ring marking this pane as the focused one in a split.
|
||||
///
|
||||
/// SetResourceReference, not a brush snapshot, on both states: an assigned brush would not
|
||||
/// follow a live theme switch.
|
||||
///
|
||||
/// "SelectionAccent", not "AccentBrush". KillerPDF uses the older family resource set
|
||||
/// (BgCanvas / AccentLogo / TextPrimary) and has no AccentBrush key in any theme.
|
||||
/// SetResourceReference to a missing key does not throw, it silently leaves the property
|
||||
/// unset, which blanks the border instead of accenting it.
|
||||
///
|
||||
/// Both borders, matching KillerShell's UpdatePaneFocusRing: TabBarRing is the card's top
|
||||
/// border drawn again inside the tab band, so lighting only the card leaves the ring open
|
||||
/// along its whole top edge.
|
||||
///
|
||||
/// The brush moves, never the thickness - a thickness change would reflow the pane on every
|
||||
/// click between panes.
|
||||
///
|
||||
/// PaneHasFocus below records the same state for the tab halo, which continues the ring up
|
||||
/// around the active tab in the focused pane only.</summary>
|
||||
internal bool PaneHasFocus { get; private set; }
|
||||
|
||||
internal void SetFocusHalo(bool focused)
|
||||
{
|
||||
PaneHasFocus = focused;
|
||||
bool retro = Services.ThemeManager.Current == Services.Theme.SE98;
|
||||
string key = focused && !retro ? "SelectionAccent" : "PaneBorderBrush";
|
||||
PaneBorder.SetResourceReference(Border.BorderBrushProperty, key);
|
||||
// The 98SE band is the raised client's white top ledge. Replacing it with the gray
|
||||
// outer-frame brush on focus made the tab/pane join visibly change after a click.
|
||||
TabBarRing.SetResourceReference(Border.BorderBrushProperty,
|
||||
retro ? "BevelLightBrush" : key);
|
||||
// The ring runs on around the active tab, so it moves with the pane border. The tab's own
|
||||
// share of that is a template trigger on PaneFocused / PaneDimmed, which this sets, plus
|
||||
// the band-drawn outer verticals.
|
||||
UpdatePaneFocusRing();
|
||||
}
|
||||
|
||||
// ---- Element access for the window --------------------------------------------------
|
||||
// A UserControl is its OWN NAMESCOPE, so the window's FindName cannot reach any of these -
|
||||
// and it fails SILENTLY, returning null rather than throwing. The window ctor assigns
|
||||
// _view.X from these instead, which works because those fields are forwarding properties
|
||||
// onto ViewerState.
|
||||
internal Border PaneShadowBorder => PaneShadow;
|
||||
internal Border PaneCardBorder => PaneBorder;
|
||||
internal Grid ContentHost => DocPaneContent;
|
||||
internal StackPanel ContinuousHost => ContinuousPanel;
|
||||
internal WrapPanel PageHost => PageContentPanel;
|
||||
internal Grid PageGrid => PageContentGrid;
|
||||
internal ScrollViewer PreviewScroller => PagePreviewPanel;
|
||||
internal Border DropSurface => DropZone;
|
||||
internal Border RecentBox => RecentFilesBox;
|
||||
internal ItemsControl RecentList => RecentFilesList;
|
||||
internal Canvas Marquee => MarqueeLayer;
|
||||
internal Border SurfacePad => DocSurfacePad;
|
||||
internal System.Windows.Media.ImageBrush Grain => GrainBrush;
|
||||
|
||||
// ---- Forwards to the owning window ----------------------------------------------------
|
||||
// MainWindow's copies were private; they are internal now purely so these can reach them.
|
||||
|
||||
private void DocPane_SizeChanged(object s, SizeChangedEventArgs e)
|
||||
{
|
||||
Host?.ViewerSizeChanged(this, s, e);
|
||||
|
||||
// Window resizing changes a pane's usable width without going through the split-pane
|
||||
// callbacks. Keep the empty-state recents panel on the same width gate in that path too.
|
||||
// SyncRecentBoxWidth only writes materially changed values and guards re-entry, so the
|
||||
// follow-up layout pass caused by crossing the threshold settles immediately.
|
||||
if (e.WidthChanged) SyncRecentBoxWidth();
|
||||
}
|
||||
|
||||
/// <summary>Size the start screen's Recent panel to this pane, and drop it entirely once the
|
||||
/// pane is too narrow to carry both it and the drop target. At its old fixed 340 it took
|
||||
/// most of a half-width pane and left the "Drop PDF here" zone as a sliver. Owns the
|
||||
/// panel's visibility outright, so PopulateRecentFilesList defers to it rather than the two
|
||||
/// of them setting it from different rules.</summary>
|
||||
private bool _syncingRecentBox;
|
||||
internal void SyncRecentBoxWidth()
|
||||
{
|
||||
if (RecentFilesBox is null || RecentFilesList is null || _syncingRecentBox) return;
|
||||
_syncingRecentBox = true;
|
||||
try
|
||||
{
|
||||
double w = ActualWidth;
|
||||
double want = Math.Min(340, Math.Max(220, w * 0.4));
|
||||
var vis = RecentFilesList.Items.Count > 0 && w >= 560
|
||||
? Visibility.Visible : Visibility.Collapsed;
|
||||
// Only write when the value actually changes. Both of these re-trigger layout, and
|
||||
// this runs FROM a size handler - an unconditional assignment gives the layout pass
|
||||
// something new to react to every time round and it never settles.
|
||||
if (Math.Abs(RecentFilesBox.Width - want) > 0.5) RecentFilesBox.Width = want;
|
||||
if (RecentFilesBox.Visibility != vis) RecentFilesBox.Visibility = vis;
|
||||
}
|
||||
finally { _syncingRecentBox = false; }
|
||||
}
|
||||
|
||||
// Focus THIS pane before forwarding: the open path routes through ActiveViewer, and a
|
||||
// drag-drop raises no PreviewMouseDown (the focus trigger), so a drop on the unfocused
|
||||
// pane opened the file in the OTHER pane. FocusPane is cheap and idempotent.
|
||||
private void DropZone_Drop(object s, DragEventArgs e) => Host?.ViewerDrop(this, s, e);
|
||||
private void DropZone_DragOver(object s, DragEventArgs e) => Host?.ViewerDragOver(s, e);
|
||||
private void DropZone_Click(object s, MouseButtonEventArgs e) => Host?.ViewerDropZoneClick(s, e);
|
||||
|
||||
private void RecentClearAll_Click(object s, MouseButtonEventArgs e) => Host?.ClearRecentFiles(s, e);
|
||||
|
||||
// The five preview/scroll handlers do NOT forward from here: their bodies are in this class
|
||||
// (PdfViewer.Zoom.cs, PdfViewer.Viewport.cs), so a forward would call straight back into
|
||||
// itself. The XAML binds them directly.
|
||||
private void DocPaneBackground_RightClick(object s, MouseButtonEventArgs e) => Host?.ViewerBackgroundRightClick(s, e);
|
||||
|
||||
/// <summary>Empty space on this pane's tab strip drags the window, the way a strip in the
|
||||
/// title-bar row would. Named apart from MainWindow's TitleBar_MouseLeftButtonDown, which
|
||||
/// keeps the body - it is window chrome, not pane behavior.</summary>
|
||||
private void TabScroll_MouseLeftButtonDown(object s, MouseButtonEventArgs e) => Host?.ViewerTabStripMouseDown(s, e);
|
||||
|
||||
/// <summary>This pane's active document, for the window's chrome. The session list lives
|
||||
/// here, so the window asks the focused pane rather than owning one itself.</summary>
|
||||
internal DocumentSession? ActiveSessionRef => _active;
|
||||
|
||||
/// <summary>Every open document in THIS pane. The quit prompt has to union both panes to
|
||||
/// decide whether anything is unsaved, and the settings writer needs each pane's list.</summary>
|
||||
internal System.Collections.ObjectModel.ObservableCollection<DocumentSession> SessionsRef => _sessions;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user