vendor: import KillerPDF 1.7.5 source (GPL-3.0) as the base for MMD PDF
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user