vendor: import KillerPDF 1.7.5 source (GPL-3.0) as the base for MMD PDF

This commit is contained in:
2026-08-27 06:58:22 +02:00
commit 532485a830
577 changed files with 149058 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
using System;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Animation;
// ============================================================
// SHARED FADE / SLIDE
//
// One place for the timing and easing every surface animates with, so the main window, the
// dialogs, the overlays and the rail flyouts all appear the same way. An app that hand-rolls a
// DoubleAnimation per call site ends up with four durations and three easings, which reads as
// four different products.
//
// THIS IS THE CANONICAL COPY (consolidated 2026-08-08). Every app carries a byte-identical
// copy of this file - only the namespace line differs - so a diff against this file IS the
// drift check. It replaced five copies that had each grown a different subset: the kit shipped
// no fade-out at all, so KillerNotes invented FadeOutAndClose and KillerPDF invented
// FadeOut(element, done) independently, while KillerScan, KillerShell and Killendar closed
// every dialog with no fade. When something here needs to change, change it HERE first, then
// re-copy into every app.
//
// COPY THIS FILE INTO THE APP and change the namespace - it is a plain static helper with no
// dependencies. Usage:
//
// Loaded += (_, _) => Anim.FadeIn(RootBorder); // a dialog, on its root Border
// Anim.SlideInX(flyout, -12); // a rail flyout, gliding out of the rail
//
// The dialog's root Border must start at Opacity="0" in XAML, or the first frame paints solid
// before the animation takes over and the fade is a flicker rather than a fade.
//
// Two fade-outs, for two shapes of close:
// - FadeOutAndClose(window, ref flag): call from an OnClosing override; it cancels that close,
// fades the whole window, then closes for real. The default for a Window.
// - FadeOut(element, done): fades a named element and runs a callback. For a dialog that must
// hold its DialogResult until after the fade: assigning DialogResult is itself a close
// request, and WPF resets DialogResult to null whenever a close is canceled, so such a
// dialog records the result, fades, and assigns it in the callback (see KillerPDF's
// FileDialog.OnClosing).
// ============================================================
namespace KillerPDF.Controls
{
internal static class Anim
{
/// <summary>Standard fade duration in milliseconds, shared by all surfaces.</summary>
public const int FadeMs = 150;
/// <summary>Fades an element's opacity from 0 to 1 over FadeMs with an ease-out curve.</summary>
public static void FadeIn(UIElement element)
{
element.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, new Duration(TimeSpan.FromMilliseconds(FadeMs)))
{
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
});
}
/// <summary>Fades an element out to 0 and calls <paramref name="done"/> when it lands.
/// EaseIn mirrors FadeIn's EaseOut, so the surface accelerates away as smoothly as it
/// arrived. Windows use this to fade before actually closing; without it a dialog that
/// fades in vanishes instantly, which reads as a glitch.</summary>
public static void FadeOut(UIElement element, Action done)
{
var a = new DoubleAnimation(element.Opacity, 0, new Duration(TimeSpan.FromMilliseconds(FadeMs)))
{
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseIn }
};
// Completed fires even if the value is already 0, so the callback cannot be stranded.
a.Completed += (_, _) => done?.Invoke();
element.BeginAnimation(UIElement.OpacityProperty, a);
}
/// <summary>
/// Fades a window out and then closes it for real. Call from an OnClosing override, or
/// wire it to a close button; it returns true if it took over the close, in which case
/// the caller must cancel this one and do nothing else.
///
/// Driven per composition frame rather than with a DoubleAnimation, matching the palette
/// fade in each app's ThemeManager and for the same reason: Timeline-based animation is
/// suppressed outright in some environments (remote sessions, "show animations in
/// Windows" turned off) and fails silently when it is, which reads as a window that
/// vanishes instead of fading. A per-frame opacity write always runs.
/// </summary>
public static bool FadeOutAndClose(Window window, ref bool alreadyFaded)
{
if (alreadyFaded || !window.IsLoaded || window.Opacity <= 0.01) return false;
alreadyFaded = true;
// Release FadeIn's animation FIRST. It is a DoubleAnimation with the default
// FillBehavior.HoldEnd, so it keeps holding Opacity after it finishes - and a held
// animation outranks a local value, which means every per-frame write below would be
// silently discarded and the window would sit at full opacity until the timer closed it.
window.BeginAnimation(UIElement.OpacityProperty, null);
window.Opacity = 1;
var clock = System.Diagnostics.Stopwatch.StartNew();
double from = window.Opacity;
EventHandler? tick = null;
tick = (_, _) =>
{
double t = clock.Elapsed.TotalMilliseconds / FadeMs;
if (t >= 1)
{
CompositionTarget.Rendering -= tick;
window.Opacity = 0;
// Off the render callback before closing: tearing the window down inside a
// Rendering handler reenters composition.
//
// Hand foreground back to the owner BEFORE the teardown. This close is
// deferred to a dispatcher callback with no input message behind it, and
// Win32 is free to ignore the activation it would otherwise do for us when
// an owned window is destroyed. With an owner chain (main window -> modeless
// pad -> modal dialog) nothing reclaimed foreground on the way back out and
// the MAIN window sank behind other applications. Activating first means the
// window being destroyed is not the foreground one, so there is nothing to
// hand off. For a modal child the owner is Win32-disabled (ShowDialog
// disables the thread's windows without touching WPF's IsEnabled, so it
// cannot be tested for here) and Activate is a harmless no-op; WPF's own
// dialog teardown re-enables and reactivates that case.
window.Dispatcher.BeginInvoke(new Action(() =>
{
Window? owner = window.Owner;
if (owner != null && owner.IsVisible) owner.Activate();
window.Close();
}));
return;
}
window.Opacity = from * (1 - t * t); // quadratic ease-in, mirrors FadeIn
};
CompositionTarget.Rendering += tick;
return true;
}
/// <summary>Fade plus a horizontal glide from dx px to rest (negative dx = in from
/// the left). Used by the rail flyouts so they read as sliding out of the rail.</summary>
public static void SlideInX(UIElement element, double dx)
{
var tt = new TranslateTransform(dx, 0);
element.RenderTransform = tt;
FadeIn(element);
var a = new DoubleAnimation(dx, 0, new Duration(TimeSpan.FromMilliseconds(FadeMs)))
{
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
};
// Clear the transform when it lands: a RenderTransform left in place on a laid-out
// element is a permanent extra composition layer for no benefit.
a.Completed += (_, _) => element.RenderTransform = null;
tt.BeginAnimation(TranslateTransform.XProperty, a);
}
}
}
+486
View File
@@ -0,0 +1,486 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
namespace KillerPDF
{
/// <summary>
/// A small, themed RGB color picker: saturation/value square + hue strip, RGB and HTML-hex inputs,
/// a desktop-wide crosshair eyedropper, and a row of 9 fixed swatches that double as the annotate-bar
/// palette (shared "UserSwatches" setting). Replace overwrites one slot with the current color;
/// Reset restores defaults. Opacity is left to the annotate bar's slider, so this is opaque-RGB only.
/// </summary>
internal sealed class ColorPickerDialog : Window
{
public Color SelectedColor { get; private set; }
/// <summary>True once OK committed. Callers must read THIS, not ShowDialog's return: the
/// eyedropper opens a nested modal (the capture window, owned by this dialog), and a
/// nested modal closing inside an outer one can corrupt the outer frame's result - OK set
/// DialogResult = true and ShowDialog still returned false, silently discarding the pick.
/// Proven by trace 2026-08-01: Accept -> #FFFEFEFE, PickerClosed(result=False), and every
/// "shapes draw the wrong color" report back to the purple era was this one drop.</summary>
public bool Accepted { get; private set; }
private double _h, _s = 1, _v = 1; // HSV state (h 0..360, s/v 0..1)
private bool _updating; // guards the field<->thumb<->preview sync from feedback loops
private Border _svArea = null!;
private Canvas _svThumb = null!;
private Border _hueThumb = null!;
private Rectangle _svHue = null!;
private TextBox _rBox = null!, _gBox = null!, _bBox = null!, _hexBox = null!;
private Border _newSwatch = null!;
private WrapPanel _savedRow = null!;
private Border _replaceBtn = null!;
private bool _replaceArmed; // when on, the next swatch click is overwritten, not selected
public event Action? SwatchesChanged; // raised when the shared palette is edited, so the bar can live-update
private const int SvW = 220, SvH = 170, HueW = 18;
private const int SwatchCell = 24, SwatchCols = 9, SwatchMax = 9; // one clean row of 9 fixed slots
// Shared with the annotate-bar palette: editing these swatches reconfigures the toolbar colors.
private const string SavedKey = "UserSwatches";
// First-run / Reset palette: 9 fixed slots, last one white.
private static readonly Color[] DefaultSwatches = UiKit.DefaultSwatches;
private static SolidColorBrush R(string key) => (SolidColorBrush)Application.Current.Resources[key];
private static string L(string key) => Application.Current.TryFindResource(key) as string ?? key;
public ColorPickerDialog(Window? owner, Color initial)
{
Title = "KillerPDF - " + L("Str_Color_Name");
Width = 300;
SizeToContent = SizeToContent.Height;
DialogChrome.Configure(this, owner);
UseLayoutRounding = true;
SelectedColor = initial;
(_h, _s, _v) = RgbToHsv(initial);
BuildUi();
SyncFromHsv();
KeyDown += (_, e) => { if (e.Key == Key.Escape) { DialogResult = false; Close(); } else if (e.Key == Key.Enter) Accept(); };
}
// ── UI ──────────────────────────────────────────────────────────────────
private void BuildUi()
{
var panel = new StackPanel { Margin = new Thickness(18, 14, 18, 16) };
// 98SE: the hand-rolled card (black outline + 1px AddBevels ring) never read as a
// classic window. Use the shared DialogChrome.Frame instead - the same classic caption
// bar and five-ring raised frame KillerDialog gets - and let the caption carry the
// title, so the in-panel accent heading is skipped.
if (Services.ThemeManager.Current == Services.Theme.SE98)
{
Content = DialogChrome.Frame(this, Owner, L("Str_Color_Pick"),
() => { DialogResult = false; Close(); }, panel);
}
else
{
var card = new Border
{
Background = R("MenuBackgroundBrush"),
BorderBrush = UiKit.Brush("DialogFrameBrush"),
BorderThickness = Application.Current.TryFindResource("DialogFrameThickness") is Thickness dft ? dft : new Thickness(1),
Padding = Application.Current.TryFindResource("DialogFramePadding") is Thickness dfp ? dfp : new Thickness(0),
CornerRadius = UiKit.RadWindow,
Margin = Application.Current.TryFindResource("DialogHaloMargin") is Thickness hm ? hm : new Thickness(14),
Effect = UiKit.ShadowDialog()
};
// Film-grain overlay so the dialog carries the same texture as the rest of the app - dimmed
// by the shared GrainOpacity so it stays subtle (was rendering at full strength before).
var root = new Grid();
if (Owner?.TryFindResource("GrainBrushShared") is Brush grain)
{
double grainOp = Owner?.TryFindResource("GrainOpacity") is double go ? go : 0.12;
root.Children.Add(new Border { Background = grain, Opacity = grainOp, CornerRadius = UiKit.RadWindow, IsHitTestVisible = false });
}
DialogChrome.AddBevels(root, Owner);
root.Children.Add(panel);
card.Child = root;
Content = card;
// Accent heading with a 1px drop shadow - the shared style for these secondary-window titles.
var title = new TextBlock
{
Text = L("Str_Color_Pick"), Foreground = R("PrimaryBrush"),
FontSize = 14, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 0, 0, 12),
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 2, ShadowDepth = 1, Direction = 270, Opacity = 0.7 },
Cursor = Cursors.SizeAll
};
title.MouseLeftButtonDown += (_, e) => { if (e.ButtonState == MouseButtonState.Pressed) DragMove(); };
panel.Children.Add(title);
}
// SV square + hue strip
var pickRow = new StackPanel { Orientation = Orientation.Horizontal };
_svHue = new Rectangle { Width = SvW, Height = SvH };
var svWhite = new Rectangle { Width = SvW, Height = SvH, IsHitTestVisible = false,
Fill = new LinearGradientBrush(Color.FromArgb(255, 255, 255, 255), Color.FromArgb(0, 255, 255, 255), 0) };
var svBlack = new Rectangle { Width = SvW, Height = SvH, IsHitTestVisible = false,
Fill = new LinearGradientBrush(Color.FromArgb(0, 0, 0, 0), Color.FromArgb(255, 0, 0, 0), 90) };
_svThumb = new Canvas { Width = SvW, Height = SvH, IsHitTestVisible = false };
var svDot = new Ellipse { Width = 12, Height = 12, Stroke = Brushes.White, StrokeThickness = 2, Fill = Brushes.Transparent,
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 2, ShadowDepth = 0, Opacity = 0.8 } };
_svThumb.Children.Add(svDot);
var svGrid = new Grid { Width = SvW, Height = SvH };
svGrid.Children.Add(_svHue); svGrid.Children.Add(svWhite); svGrid.Children.Add(svBlack); svGrid.Children.Add(_svThumb);
// ClipToBounds off so the indicator dot shows fully when it sits at an edge/corner.
_svArea = new Border { Width = SvW, Height = SvH, CornerRadius = UiKit.RadControl, ClipToBounds = false,
BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1), Child = svGrid, Cursor = Cursors.Cross };
_svArea.MouseLeftButtonDown += (s, e) => { _svArea.CaptureMouse(); SvPick(e.GetPosition(svGrid)); };
_svArea.MouseMove += (s, e) => { if (e.LeftButton == MouseButtonState.Pressed) SvPick(e.GetPosition(svGrid)); };
_svArea.MouseLeftButtonUp += (s, e) => _svArea.ReleaseMouseCapture();
pickRow.Children.Add(_svArea);
var hueRect = new Rectangle { Width = HueW, Height = SvH, Fill = HueStripBrush() };
// Themed handle, matching the annotate-bar slider thumbs (accent fill, light outline).
_hueThumb = new Border { Width = HueW + 6, Height = 6, BorderBrush = Brushes.White, BorderThickness = new Thickness(1.5),
Background = R("PrimaryBrush"), CornerRadius = UiKit.RadControl, IsHitTestVisible = false };
var hueCanvas = new Canvas { Width = HueW + 6, Height = SvH };
Canvas.SetLeft(_hueThumb, -3);
hueCanvas.Children.Add(_hueThumb);
var hueGrid = new Grid { Margin = new Thickness(8, 0, 0, 0) };
hueGrid.Children.Add(hueRect); hueGrid.Children.Add(hueCanvas);
var hueArea = new Border { Child = hueGrid, Cursor = Cursors.SizeNS, CornerRadius = UiKit.RadControl,
BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1) };
hueArea.MouseLeftButtonDown += (s, e) => { hueArea.CaptureMouse(); HuePick(e.GetPosition(hueRect)); };
hueArea.MouseMove += (s, e) => { if (e.LeftButton == MouseButtonState.Pressed) HuePick(e.GetPosition(hueRect)); };
hueArea.MouseLeftButtonUp += (s, e) => hueArea.ReleaseMouseCapture();
pickRow.Children.Add(hueArea);
panel.Children.Add(pickRow);
// RGB + hex + preview + eyedropper
var inputRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 12, 0, 0) };
_newSwatch = new Border { Width = 34, Height = 34, CornerRadius = UiKit.RadControl,
BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1), Margin = new Thickness(0, 0, 10, 0) };
inputRow.Children.Add(_newSwatch);
_rBox = NumBox(); _gBox = NumBox(); _bBox = NumBox();
inputRow.Children.Add(FieldGroup("R", _rBox));
inputRow.Children.Add(FieldGroup("G", _gBox));
inputRow.Children.Add(FieldGroup("B", _bBox));
_eyedropBtn = new Button
{
Width = 28, Height = 22, Margin = new Thickness(8, 14, 0, 0),
Background = R("BgCanvas"), BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1),
// No Cursor here: the crosshair belongs to the CAPTURE window that opens on click.
// On the button it appeared on hover, before the pick had started (2026-08-01).
Content = CrosshairIcon(), ToolTip = L("Str_Color_EyedropTT"),
Template = MakeBtnTemplate()
};
// Same hover treatment as the dialog's chips (grayer fill), and RunEyedropper holds the
// armed tint + accent border for as long as the capture is live (2026-08-01).
_eyedropBtn.MouseEnter += (_, _) => { if (!_eyedropArmed) _eyedropBtn.Background = R("CardBorderBrush"); };
_eyedropBtn.MouseLeave += (_, _) => { if (!_eyedropArmed) _eyedropBtn.Background = R("BgCanvas"); };
_eyedropBtn.Click += (_, _) => RunEyedropper();
inputRow.Children.Add(_eyedropBtn);
panel.Children.Add(inputRow);
var hexRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 8, 0, 0) };
hexRow.Children.Add(new TextBlock { Text = L("Str_Color_Hex"), Foreground = R("MutedTextBrush"), FontSize = 11,
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0) });
_hexBox = MakeTextBox(96);
_hexBox.MaxLength = 7;
_hexBox.LostFocus += (_, _) => CommitHex();
_hexBox.KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitHex(); };
hexRow.Children.Add(_hexBox);
panel.Children.Add(hexRow);
// Swatch header: Replace (assign current color to a slot) on the left, Reset on the far right.
var swHeader = new Grid { Margin = new Thickness(0, 12, 0, 5), Width = SwatchCols * SwatchCell };
_replaceBtn = Chip(L("Str_Color_Replace"), L("Str_Color_ReplaceTT"));
_replaceBtn.HorizontalAlignment = HorizontalAlignment.Left;
_replaceBtn.MouseLeftButtonUp += (_, _) => { _replaceArmed = !_replaceArmed; UpdateReplaceChip(); RebuildSavedRow(); };
var resetBtn = Chip(L("Str_Color_Reset"), L("Str_Color_ResetTT"));
resetBtn.HorizontalAlignment = HorizontalAlignment.Right;
resetBtn.MouseLeftButtonUp += (_, _) => { StoreSaved([.. DefaultSwatches]); _replaceArmed = false; UpdateReplaceChip(); RebuildSavedRow(); SwatchesChanged?.Invoke(); };
swHeader.Children.Add(_replaceBtn);
swHeader.Children.Add(resetBtn);
panel.Children.Add(swHeader);
_savedRow = new WrapPanel { Width = SwatchCols * SwatchCell };
panel.Children.Add(_savedRow);
UpdateReplaceChip();
RebuildSavedRow();
// OK / Cancel
var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 14, 0, 0) };
var cancel = MakeButton(L("Str_Btn_CancelDlg"), false); cancel.Click += (_, _) => { DialogResult = false; Close(); }; cancel.IsCancel = true;
var ok = MakeButton(L("Str_Btn_OK"), true); ok.Margin = new Thickness(8, 0, 0, 0); ok.Click += (_, _) => Accept(); ok.IsDefault = true;
btnRow.Children.Add(cancel); btnRow.Children.Add(ok);
panel.Children.Add(btnRow);
}
private void Accept()
{
SelectedColor = HsvToRgb(_h, _s, _v);
Accepted = true;
// Best-effort only - see Accepted. Setting DialogResult can also throw once the
// nested capture modal has run, and the commit must not die with it.
try { DialogResult = true; } catch (InvalidOperationException) { }
Close();
}
// ── Interaction ─────────────────────────────────────────────────────────
private void SvPick(Point p) { _s = Clamp01(p.X / SvW); _v = Clamp01(1 - p.Y / SvH); SyncFromHsv(); }
private void HuePick(Point p) { _h = Clamp01(p.Y / SvH) * 360; SyncFromHsv(); }
private void CommitHex() { if (TryParseHex(_hexBox.Text, out Color c)) SetFromColor(c); else SyncFromHsv(); }
private void CommitRgb()
{
if (byte.TryParse(_rBox.Text, out byte r) && byte.TryParse(_gBox.Text, out byte g) && byte.TryParse(_bBox.Text, out byte b))
SetFromColor(Color.FromRgb(r, g, b));
else SyncFromHsv();
}
private void SetFromColor(Color c) { (_h, _s, _v) = RgbToHsv(c); SyncFromHsv(); }
// Push current HSV out to every control (hue background, thumbs, RGB, hex, preview).
private void SyncFromHsv()
{
if (_updating) return;
_updating = true;
var c = HsvToRgb(_h, _s, _v);
_svHue.Fill = new SolidColorBrush(HsvToRgb(_h, 1, 1));
Canvas.SetLeft((UIElement)_svThumb.Children[0], _s * SvW - 6);
Canvas.SetTop((UIElement)_svThumb.Children[0], (1 - _v) * SvH - 6);
Canvas.SetTop(_hueThumb, Math.Max(0, Math.Min(SvH - 6, _h / 360.0 * SvH - 3))); // keep the handle inside the strip
_rBox.Text = c.R.ToString(); _gBox.Text = c.G.ToString(); _bBox.Text = c.B.ToString();
_hexBox.Text = $"#{c.R:X2}{c.G:X2}{c.B:X2}";
_newSwatch.Background = new SolidColorBrush(c);
_updating = false;
}
// ── Eyedropper (desktop-wide) ───────────────────────────────────────────
private Button? _eyedropBtn;
private bool _eyedropArmed;
private void RunEyedropper()
{
// Armed look while the capture is live: accent border + selected-row tint, so the
// active state is visible even with the crosshair off in another corner of the screen.
_eyedropArmed = true;
if (_eyedropBtn != null)
{
_eyedropBtn.SetResourceReference(Button.BackgroundProperty, "RowSelectedBrush");
_eyedropBtn.SetResourceReference(Button.BorderBrushProperty, "PrimaryBrush");
}
try { RunEyedropperCore(); }
finally
{
_eyedropArmed = false;
if (_eyedropBtn != null)
{
_eyedropBtn.Background = R("BgCanvas");
_eyedropBtn.BorderBrush = R("CardBorderBrush");
}
}
}
private void RunEyedropperCore()
{
var capture = new Window
{
WindowStyle = WindowStyle.None, AllowsTransparency = true, Background = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0)),
ResizeMode = ResizeMode.NoResize, ShowInTaskbar = false, Topmost = true, Cursor = Cursors.Cross,
Left = SystemParameters.VirtualScreenLeft, Top = SystemParameters.VirtualScreenTop,
Width = SystemParameters.VirtualScreenWidth, Height = SystemParameters.VirtualScreenHeight, Owner = this
};
capture.MouseLeftButtonDown += (_, _) =>
{
// GetCursorPos returns physical screen pixels; the desktop DC's GetPixel uses the same
// space, so this is correct regardless of per-monitor DPI scaling.
if (GetCursorPos(out POINT pt))
{
IntPtr dc = GetDC(IntPtr.Zero);
uint cref = GetPixel(dc, pt.X, pt.Y);
ReleaseDC(IntPtr.Zero, dc);
capture.DialogResult = true; capture.Close();
SetFromColor(Color.FromRgb((byte)(cref & 0xFF), (byte)((cref >> 8) & 0xFF), (byte)((cref >> 16) & 0xFF)));
return;
}
capture.DialogResult = false; capture.Close();
};
capture.KeyDown += (_, e) => { if (e.Key == Key.Escape) { capture.DialogResult = false; capture.Close(); } };
capture.ShowDialog();
}
// ── Saved swatches ──────────────────────────────────────────────────────
private List<Color> LoadSaved()
{
var raw = App.GetSetting(SavedKey);
if (string.IsNullOrWhiteSpace(raw)) return [.. DefaultSwatches]; // first run = defaults
var list = new List<Color>();
foreach (var part in raw!.Split(','))
if (TryParseHex(part.Trim(), out Color c)) list.Add(c);
return list.Count > 0 ? list : [.. DefaultSwatches];
}
private void StoreSaved(List<Color> list) =>
App.SetSetting(SavedKey, string.Join(",", list.Take(SwatchMax).Select(c => $"#{c.R:X2}{c.G:X2}{c.B:X2}")));
private void UpdateReplaceChip()
{
if (_replaceBtn is null) return;
_replaceBtn.Background = _replaceArmed ? R("RowSelectedBrush") : R("PaneBrush");
_replaceBtn.SetResourceReference(Border.BorderBrushProperty, _replaceArmed ? "PrimaryBrush" : "CardBorderBrush");
}
private void RebuildSavedRow()
{
_savedRow.Children.Clear();
var saved = LoadSaved().Take(SwatchMax).ToList();
for (int i = 0; i < saved.Count; i++)
{
var c = saved[i];
int idx = i;
var sw = new Border { Width = 20, Height = 20, CornerRadius = UiKit.RadControl, Margin = new Thickness(0, 0, 4, 4),
Background = new SolidColorBrush(c), BorderThickness = new Thickness(_replaceArmed ? 2 : 1), Cursor = Cursors.Hand,
ToolTip = _replaceArmed ? L("Str_Color_SwatchSetTT") : L("Str_Color_SwatchUseTT") };
if (_replaceArmed) sw.SetResourceReference(Border.BorderBrushProperty, "PrimaryBrush"); else sw.BorderBrush = R("CardBorderBrush");
sw.MouseLeftButtonUp += (_, _) =>
{
if (_replaceArmed)
{
var list = LoadSaved();
if (idx < list.Count) { list[idx] = HsvToRgb(_h, _s, _v); StoreSaved(list); }
_replaceArmed = false; UpdateReplaceChip(); RebuildSavedRow(); SwatchesChanged?.Invoke();
}
else SetFromColor(c);
};
_savedRow.Children.Add(sw);
}
}
// ── Small themed control builders ───────────────────────────────────────
private StackPanel FieldGroup(string label, TextBox box)
{
var sp = new StackPanel { Margin = new Thickness(0, 0, 6, 0) };
sp.Children.Add(new TextBlock { Text = label, Foreground = R("MutedTextBrush"), FontSize = 10, HorizontalAlignment = HorizontalAlignment.Center });
sp.Children.Add(box);
return sp;
}
private TextBox NumBox()
{
var b = MakeTextBox(34);
b.MaxLength = 3;
b.TextAlignment = TextAlignment.Center;
b.LostFocus += (_, _) => CommitRgb();
b.KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitRgb(); };
return b;
}
private TextBox MakeTextBox(double width)
{
// Use the one shared field implementation. In particular, TextFieldBrush is white on
// 98SE while the document canvas is gray; a local BgCanvas field was visibly wrong.
var box = UiKit.Field(width);
box.Height = 22;
box.VerticalContentAlignment = VerticalAlignment.Center;
box.Padding = new Thickness(4, 0, 4, 0);
return box;
}
// A crosshair/target glyph drawn in vectors, to match the KillerPDF look.
private UIElement CrosshairIcon()
{
var g = new Grid { Width = 14, Height = 14 };
var fg = R("TextBrush");
g.Children.Add(new Rectangle { Width = 1.4, Fill = fg, HorizontalAlignment = HorizontalAlignment.Center });
g.Children.Add(new Rectangle { Height = 1.4, Fill = fg, VerticalAlignment = VerticalAlignment.Center });
g.Children.Add(new Ellipse { Width = 8, Height = 8, Stroke = fg, StrokeThickness = 1.4,
HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
Fill = Brushes.Transparent });
return g;
}
private Border Chip(string text, string tip)
{
var b = new Border { Height = 20, MinWidth = 22, CornerRadius = UiKit.RadControl, Cursor = Cursors.Hand,
BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1), Background = R("PaneBrush"),
Padding = new Thickness(6, 0, 6, 0), ToolTip = tip,
Child = new TextBlock { Text = text, Foreground = R("TextBrush"), FontSize = 11,
HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center } };
// Unified hover with the Cancel button (grayer fill), respecting Replace's armed highlight.
b.MouseEnter += (_, _) => { if (b != _replaceBtn || !_replaceArmed) b.Background = R("CardBorderBrush"); };
b.MouseLeave += (_, _) => { b.Background = (b == _replaceBtn && _replaceArmed) ? R("RowSelectedBrush") : R("PaneBrush"); };
return b;
}
private Button MakeButton(string text, bool primary)
{
// 98SE: the shared kit button already carries the beveled ChipFace treatment, so the
// dialog's OK / Cancel match the classic toolbar instead of the modern accent pair.
if (Services.ThemeManager.Current == Services.Theme.SE98)
{
var se = UiKit.Make(text, primary);
se.Height = 28; se.MinWidth = 74; se.Padding = new Thickness(12, 0, 12, 0);
return se;
}
var btn = new Button { Content = text, Height = 28, MinWidth = 74, Padding = new Thickness(12, 0, 12, 0),
BorderThickness = new Thickness(1), Cursor = Cursors.Hand };
var style = new Style(typeof(Button));
style.Setters.Add(new Setter(Control.TemplateProperty, MakeBtnTemplate()));
// Rest brushes match UiKit.Make's accent pair (SelectionFg on SelectionBg): the old
// PrimaryBrush-on-RowSelectedBrush pairing put accent text on an accent-tinted fill,
// which left "OK" unreadable at rest on several themes until hover repainted it (#227).
style.Setters.Add(new Setter(Control.ForegroundProperty, primary ? R("SelectionFg") : R("TextBrush")));
style.Setters.Add(new Setter(Control.BackgroundProperty, primary ? R("SelectionBg") : R("PaneBrush")));
style.Setters.Add(new Setter(Control.BorderBrushProperty, primary ? R("PrimaryBrush") : R("CardBorderBrush")));
// Hover: OK fills solid accent (OnPrimaryBrush text for contrast); Cancel goes a shade grayer.
var hover = new Trigger { Property = UIElement.IsMouseOverProperty, Value = true };
if (primary)
{
hover.Setters.Add(new Setter(Control.BackgroundProperty, R("PrimaryBrush")));
hover.Setters.Add(new Setter(Control.ForegroundProperty, R("OnPrimaryBrush")));
}
else
{
hover.Setters.Add(new Setter(Control.BackgroundProperty, R("CardBorderBrush")));
}
style.Triggers.Add(hover);
btn.Style = style;
return btn;
}
private static ControlTemplate MakeBtnTemplate()
{
var bf = new FrameworkElementFactory(typeof(Border));
foreach (var (dp, prop) in new[] { (Border.BackgroundProperty, "Background"), (Border.BorderBrushProperty, "BorderBrush"), (Border.BorderThicknessProperty, "BorderThickness") })
bf.SetBinding(dp, new Binding(prop) { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
bf.SetValue(Border.CornerRadiusProperty, UiKit.RadControl);
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
cp.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
bf.AppendChild(cp);
return new ControlTemplate(typeof(Button)) { VisualTree = bf };
}
private static LinearGradientBrush HueStripBrush()
{
var g = new LinearGradientBrush { StartPoint = new Point(0, 0), EndPoint = new Point(0, 1) };
for (int i = 0; i <= 6; i++) g.GradientStops.Add(new GradientStop(HsvToRgb(i * 60, 1, 1), i / 6.0));
return g;
}
// ── Color math / parsing ───────────────────────────────────────────────
private static double Clamp01(double v) => Math.Max(0, Math.Min(1, v));
private static bool TryParseHex(string? s, out Color c)
{
c = Colors.Black;
if (string.IsNullOrWhiteSpace(s)) return false;
s = s!.Trim().TrimStart('#');
if (s.Length == 3) s = string.Concat(s.Select(ch => $"{ch}{ch}"));
if (s.Length != 6) return false;
if (!int.TryParse(s, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int v)) return false;
c = Color.FromRgb((byte)((v >> 16) & 0xFF), (byte)((v >> 8) & 0xFF), (byte)(v & 0xFF));
return true;
}
private static (double h, double s, double v) RgbToHsv(Color c)
{
double r = c.R / 255.0, g = c.G / 255.0, b = c.B / 255.0;
double max = Math.Max(r, Math.Max(g, b)), min = Math.Min(r, Math.Min(g, b)), d = max - min;
double h = 0;
if (d > 0.00001)
{
if (max == r) h = 60 * (((g - b) / d) % 6);
else if (max == g) h = 60 * (((b - r) / d) + 2);
else h = 60 * (((r - g) / d) + 4);
}
if (h < 0) h += 360;
double s = max <= 0 ? 0 : d / max;
return (h, s, max);
}
private static Color HsvToRgb(double h, double s, double v)
{
h = ((h % 360) + 360) % 360;
double c = v * s, x = c * (1 - Math.Abs((h / 60.0 % 2) - 1)), m = v - c;
double r, g, b;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return Color.FromRgb((byte)Math.Round((r + m) * 255), (byte)Math.Round((g + m) * 255), (byte)Math.Round((b + m) * 255));
}
[StructLayout(LayoutKind.Sequential)] private struct POINT { public int X; public int Y; }
[DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT p);
[DllImport("user32.dll")] private static extern IntPtr GetDC(IntPtr hwnd);
[DllImport("user32.dll")] private static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
[DllImport("gdi32.dll")] private static extern uint GetPixel(IntPtr hdc, int x, int y);
}
}
+276
View File
@@ -0,0 +1,276 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Effects;
namespace KillerPDF
{
// Chrome for modal dialog windows: Configure (borderless window setup), Frame (the rounded card +
// title bar + grain), and BuildTitleBar (the KillerPDF wordmark + red close button).
internal static class DialogChrome
{
// Keep generated dialog captions on the same close mark as the main window.
// E711 renders noticeably smaller inside the 18x16 Win98 caption face; E8BB is
// the shared chrome glyph used by the main title bar and fills that face correctly.
public const string CloseGlyph = "";
// Brush from the owner (then app) resources, with a safe fallback so the helper never throws.
private static Brush Brush(Window? owner, string key, Brush fallback)
=> (owner?.TryFindResource(key) ?? Application.Current?.TryFindResource(key)) as Brush ?? fallback;
private static T Value<T>(Window? owner, string key, T fallback)
=> (owner?.TryFindResource(key) ?? Application.Current?.TryFindResource(key)) is T value ? value : fallback;
// Builds the title bar.
// win - the window being chromed (used for DragMove on the whole bar)
// owner - supplies the themed brushes + the ChromeCloseButton style (pass the window's owner)
// fullTitle - the complete title, e.g. "KillerPDF - Transform"; the "KillerPDF" part becomes the
// wordmark and the remainder (" - Transform") is rendered in the courier title font
// onClose - invoked when the red close button is clicked (e.g. set a result then Close())
public static Border BuildTitleBar(Window win, Window? owner, string? fullTitle, Action onClose)
{
// Transparent (not null) background so the WHOLE bar is hit-testable and acts as a drag handle.
bool caption = Value(owner, "UseDialogCaption", false);
var bar = new Border
{
Background = caption ? Brush(owner, "TitleBarBrush", Brushes.Navy) : Brushes.Transparent,
SnapsToDevicePixels = true,
UseLayoutRounding = true
};
bar.MouseLeftButtonDown += (_, e) => { if (e.ButtonState == MouseButtonState.Pressed) win.DragMove(); };
var grid = new Grid
{
// KillerNotes uses the shared title-bar inset for its dialog caption too. In
// particular, the 2px top inset keeps the 16px caption button centered in the
// 20px classic band instead of riding against its upper edge.
Margin = caption
? Value(owner, "TitleBarPadding", new Thickness(4, 2, 0, 0))
: new Thickness(0)
};
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var wordmark = UiKit.WordmarkFont;
var wordmarkPdf = UiKit.WordmarkFontPdf;
// Build the wordmark row. A DropShadowEffect applied directly to text rasterizes it and
// disables ClearType, which reads as blurry. So we LAYER it instead: a blurred black duplicate
// sits behind a crisp, effect-free copy - soft shadow, sharp text. `shadow` paints the duplicate.
StackPanel BuildWordmark(bool shadow)
{
var sp = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
Brush primary = shadow ? Brushes.Black : Brush(owner, "TextBrush", Brushes.White);
Brush logo = shadow ? Brushes.Black : Brush(owner, "AccentLogo", Brushes.LimeGreen);
Brush secondary = shadow ? Brushes.Black : Brush(owner, "MutedTextBrush", Brushes.Gray);
int kp = fullTitle?.IndexOf("KillerPDF", StringComparison.Ordinal) ?? -1;
if (kp >= 0)
{
// Killer + PDF in one TextBlock so the two sizes share a baseline (cohesive wordmark).
var logoTb = new TextBlock { VerticalAlignment = VerticalAlignment.Center };
logoTb.Inlines.Add(new System.Windows.Documents.Run("Killer") { FontFamily = wordmark, FontWeight = FontWeights.Normal, FontSize = 16, Foreground = primary });
logoTb.Inlines.Add(new System.Windows.Documents.Run("PDF") { FontFamily = wordmarkPdf, FontWeight = FontWeights.Bold, FontSize = 20.8, Foreground = logo });
sp.Children.Add(logoTb);
string after = fullTitle![(kp + "KillerPDF".Length)..];
if (!string.IsNullOrEmpty(after))
sp.Children.Add(new TextBlock { Text = after, FontFamily = UiKit.MonoFont, FontSize = 14, Foreground = secondary, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(4, 1, 0, 0) });
}
else
{
sp.Children.Add(new TextBlock { Text = fullTitle ?? "", FontFamily = UiKit.MonoFont, FontSize = 14, Foreground = primary, VerticalAlignment = VerticalAlignment.Center });
}
return sp;
}
var title = new Grid { Margin = caption ? new Thickness(0) : new Thickness(16, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center };
if (caption)
{
title.Children.Add(new TextBlock
{
Text = fullTitle ?? "KillerPDF", FontFamily = Value(owner, "ChromeFontFamily", new FontFamily("Tahoma")),
FontSize = 11, FontWeight = FontWeights.Bold,
Foreground = Brush(owner, "ChromeTextBrush", Brushes.White), VerticalAlignment = VerticalAlignment.Center
});
}
else
{
var shadowLayer = BuildWordmark(true);
shadowLayer.Opacity = 0.5;
shadowLayer.Effect = new BlurEffect { Radius = 2 };
shadowLayer.RenderTransform = new TranslateTransform(0.7, 1.2);
title.Children.Add(shadowLayer);
title.Children.Add(BuildWordmark(false));
}
Grid.SetColumn(title, 0);
grid.Children.Add(title);
// The close glyph and its complete raised/pressed face live in ChromeCloseButton.
// Supplying another glyph/font/background here was overriding that canonical style and
// produced the off-centre X and the exposed title-bar pixel seen in classic dialogs.
var close = new Button
{
HorizontalAlignment = HorizontalAlignment.Right,
// The hover face is part of the card's top-right corner. Centering a 26px
// button in the 40px caption left a visible 7px strip above it.
VerticalAlignment = VerticalAlignment.Top,
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
FocusVisualStyle = null,
SnapsToDevicePixels = true,
UseLayoutRounding = true
};
if (owner?.TryFindResource("ChromeCloseButton") is Style chromeClose)
{
close.Style = chromeClose;
}
else
{
close.Content = CloseGlyph;
close.FontFamily = UiKit.IconFont;
close.FontSize = 10;
close.Width = 46; close.Height = 36;
close.Foreground = Brush(owner, "DangerRed", Brushes.Red);
close.Background = Brushes.Transparent;
close.BorderThickness = new Thickness(0);
close.Cursor = Cursors.Hand;
}
close.SetResourceReference(FrameworkElement.WidthProperty, "DialogCloseWidth");
close.SetResourceReference(FrameworkElement.HeightProperty, "DialogCloseHeight");
close.SetResourceReference(FrameworkElement.MarginProperty, "DialogCaptionButtonsMargin");
// Resizable borderless dialogs use WindowChrome. Without this exemption its resize
// band wins the top-right hit test, turning the close button into a resize handle.
System.Windows.Shell.WindowChrome.SetIsHitTestVisibleInChrome(close, true);
// Get the click before the caption's DragMove handler starts its modal mouse loop.
close.PreviewMouseLeftButtonDown += (_, e) => { e.Handled = true; onClose(); };
Grid.SetColumn(close, 1);
grid.Children.Add(close);
bar.Child = grid;
return bar;
}
// Borderless transparent window setup shared by every dialog.
public static void Configure(Window win, Window? owner, bool resizable = false, bool fade = true)
{
win.Owner = owner;
win.WindowStyle = WindowStyle.None;
win.AllowsTransparency = true;
win.Background = Brushes.Transparent;
win.ResizeMode = resizable ? ResizeMode.CanResize : ResizeMode.NoResize;
win.WindowStartupLocation = owner != null ? WindowStartupLocation.CenterOwner : WindowStartupLocation.CenterScreen;
win.FontFamily = UiKit.UiFont;
TextOptions.SetTextFormattingMode(win, TextFormattingMode.Display);
TextOptions.SetTextRenderingMode(win, TextRenderingMode.Grayscale);
if (fade) WindowFx.EnableFadeClose(win);
}
private static Border FrameRing(Window? owner, string brushKey, string thicknessKey, string? marginKey = null)
{
var ring = new Border
{
IsHitTestVisible = false,
BorderBrush = Brush(owner, brushKey, Brushes.Transparent),
BorderThickness = Value(owner, thicknessKey, new Thickness(0))
};
if (marginKey != null)
ring.Margin = Value(owner, marginKey, new Thickness(0));
return ring;
}
private static UIElement WindowFrame(Window? owner)
{
var frame = new Grid { IsHitTestVisible = false };
frame.Children.Add(FrameRing(owner, "WindowFrameBrush", "DialogWindowFrameThickness", "WindowFrameMargin"));
frame.Children.Add(FrameRing(owner, "FrameInnerLightBrush", "FrameInnerLightThickness", "FrameInnerMargin"));
frame.Children.Add(FrameRing(owner, "FrameInnerDarkBrush", "FrameInnerDarkThickness", "FrameInnerMargin"));
frame.Children.Add(FrameRing(owner, "FrameOuterLightBrush", "FrameOuterLightThickness"));
frame.Children.Add(FrameRing(owner, "FrameOuterDarkBrush", "FrameOuterDarkThickness"));
return frame;
}
internal static UIElement WrapContent(Window? owner, UIElement content)
{
var host = new Grid { Margin = Value(owner, "DialogHaloMargin", new Thickness(12)) };
var radius = Value(owner, "WindowCornerRadius", new CornerRadius(7));
host.Children.Add(new Border
{
Background = Brush(owner, "WindowFrameBrush", UiKit.Brush("MenuBackgroundBrush")),
CornerRadius = radius,
IsHitTestVisible = false,
Effect = UiKit.ShadowDialog()
});
var card = new Grid();
card.Children.Add(new Border
{
Background = Brush(owner, "BackgroundBrush", UiKit.Brush("BackgroundBrush")),
CornerRadius = radius,
Margin = Value(owner, "DialogWindowFramePadding", new Thickness(0)),
Child = content
});
card.Children.Add(WindowFrame(owner));
// The 1px window outline every dialog was missing: same DialogFrameBrush the file
// picker draws (defaults to AppBorderBrush, the main window's DWM border tone).
card.Children.Add(new Border
{
BorderBrush = Brush(owner, "DialogFrameBrush", UiKit.Brush("MenuBorderBrush")),
BorderThickness = Value(owner, "DialogFrameThickness", new Thickness(1)),
CornerRadius = radius,
IsHitTestVisible = false,
});
host.Children.Add(card);
return host;
}
// Standard dialog: content is inset from the same five-layer frame used by KillerNotes.
public static UIElement Frame(Window win, Window? owner, string title, Action onClose, UIElement body)
{
win.KeyDown += (_, e) => { if (e.Key == Key.Escape) { e.Handled = true; onClose(); } };
var card = new Border
{
// Print Preview already used BackgroundBrush directly. The shared frame used
// MenuBackgroundBrush, making every other generated window a different color.
Background = Brush(owner, "BackgroundBrush", UiKit.Brush("BackgroundBrush")),
CornerRadius = UiKit.RadWindow,
Margin = Value(owner, "WindowFramePadding", new Thickness(0))
};
var root = new DockPanel();
var titleBar = BuildTitleBar(win, owner, title, onClose);
titleBar.Height = Value(owner, "DialogTitleBarHeight", 40.0);
DockPanel.SetDock(titleBar, Dock.Top);
root.Children.Add(titleBar);
root.Children.Add(body);
var grain = (owner as MainWindow)?.GrainTexture;
if (grain != null)
{
var grid = new Grid();
double op = Application.Current?.Resources["GrainOpacity"] is double go ? go : 0.05;
grid.Children.Add(new Border
{
CornerRadius = UiKit.RadWindow, IsHitTestVisible = false, Opacity = op,
Background = new ImageBrush(grain) { TileMode = TileMode.Tile, ViewportUnits = BrushMappingMode.Absolute, Viewport = new Rect(0, 0, 256, 256), Stretch = Stretch.None }
});
grid.Children.Add(root);
card.Child = grid;
}
else
{
var grid = new Grid();
grid.Children.Add(root);
card.Child = grid;
}
var framedContent = card.Child!;
card.Child = null;
return WrapContent(owner, framedContent);
}
internal static void AddBevels(Grid grid, Window? owner)
{
grid.Children.Add(new Border { IsHitTestVisible = false, BorderBrush = Brush(owner, "BevelLightBrush", Brushes.Transparent), BorderThickness = Value(owner, "BevelLightThickness", new Thickness(0)) });
grid.Children.Add(new Border { IsHitTestVisible = false, BorderBrush = Brush(owner, "BevelDarkBrush", Brushes.Transparent), BorderThickness = Value(owner, "BevelDarkThickness", new Thickness(0)) });
}
}
}
+108
View File
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using PdfSharpCore.Pdf;
namespace KillerPDF
{
// Read/edit the PDF Document Info dictionary (Title, Author, Subject, Keywords, Creator). Themed via
// DialogChrome, no preview pane. Producer/dates/structure are shown read-only.
internal sealed class DocumentInfoDialog : Window
{
private readonly PdfDocument _doc;
private TextBox _title = null!, _author = null!, _subject = null!, _keywords = null!, _creator = null!;
public bool Saved { get; private set; }
public DocumentInfoDialog(Window owner, PdfDocument doc, string? filePath)
{
_doc = doc;
Title = "KillerPDF - " + L("Str_DocInfo_Suffix");
Width = 460;
SizeToContent = SizeToContent.Height;
UseLayoutRounding = true;
DialogChrome.Configure(this, owner);
BuildUi(filePath);
}
private void BuildUi(string? filePath)
{
var body = new StackPanel { Margin = new Thickness(20, 6, 20, 16) };
_title = AddField(body, L("Str_DocInfo_Title"), _doc.Info.Title);
_author = AddField(body, L("Str_DocInfo_Author"), _doc.Info.Author);
_subject = AddField(body, L("Str_DocInfo_Subject"), _doc.Info.Subject);
_keywords = AddField(body, L("Str_DocInfo_Keywords"), _doc.Info.Keywords, wrap: true);
_creator = AddField(body, L("Str_DocInfo_Creator"), _doc.Info.Creator);
body.Children.Add(new TextBlock
{
Text = BuildSummary(filePath),
FontFamily = UiKit.MonoFont, FontSize = 11,
Foreground = UiKit.Brush("MutedTextBrush"),
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 12, 0, 0)
});
var cancel = UiKit.Make(L("Str_DocInfo_Cancel"), accent: false);
cancel.Click += (_, _2) => { DialogResult = false; Close(); };
cancel.IsCancel = true; // Esc
var save = UiKit.Make(L("Str_DocInfo_Save"), accent: true);
save.Click += (_, _2) => SaveAndClose();
save.IsDefault = true; // Enter
var row = UiKit.ButtonRow(cancel, save);
row.Margin = new Thickness(0, 16, 0, 0);
body.Children.Add(row);
Content = DialogChrome.Frame(this, Owner, "KillerPDF - " + L("Str_DocInfo_Suffix"),
() => { DialogResult = false; Close(); }, body);
Loaded += (_, _2) => _title.Focus();
}
private static TextBox AddField(StackPanel host, string label, string? value, bool wrap = false)
{
host.Children.Add(UiKit.GroupLabel(label));
var f = UiKit.Field();
f.Text = value ?? "";
f.Margin = new Thickness(0, 0, 0, 8);
// Every field wraps and grows with its content up to a cap, then scrolls - so long titles,
// subjects, or keyword lists aren't cramped on a single line. Enter is not a newline (each value
// stays a single metadata string). The `wrap` hint just gives the long-form fields more room.
f.TextWrapping = TextWrapping.Wrap;
f.AcceptsReturn = false;
f.VerticalContentAlignment = VerticalAlignment.Top;
f.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
f.MaxHeight = wrap ? 110 : 72; // grow up to ~5 lines (keywords) / ~3 lines (others), then scroll
host.Children.Add(f);
return f;
}
private string BuildSummary(string? filePath)
{
var parts = new List<string>();
string producer = ""; try { producer = _doc.Info.Producer ?? ""; } catch { }
if (producer.Length > 0) parts.Add($"Producer: {producer}");
parts.Add($"{_doc.PageCount} pages");
parts.Add($"PDF {_doc.Version / 10}.{_doc.Version % 10}");
try { var d = _doc.Info.CreationDate; if (d != default) parts.Add($"created {d:yyyy-MM-dd HH:mm}"); } catch { }
try { if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath)) parts.Add($"{new FileInfo(filePath).Length / 1024.0:N0} KB"); } catch { }
return string.Join("\n", parts);
}
private void SaveAndClose()
{
_doc.Info.Title = _title.Text;
_doc.Info.Author = _author.Text;
_doc.Info.Subject = _subject.Text;
_doc.Info.Keywords = _keywords.Text;
_doc.Info.Creator = _creator.Text;
Saved = true;
Close();
}
private static string L(string key) => Application.Current?.TryFindResource(key) as string ?? key;
}
}
+89
View File
@@ -0,0 +1,89 @@
using System.Windows;
using System.Windows.Controls;
namespace KillerPDF
{
// Export pages as images (#132): PNG/JPEG + DPI + page range, themed via DialogChrome like
// Document Info. The destination and base file name are picked afterwards with the standard
// save dialog; pages are written as <base>-page-NNN.<ext> through the same render pipeline
// the CLI --to-image command uses (FileOperations.ExportImages_Click).
internal sealed class ExportImagesDialog : Window
{
private RadioButton _png = null!, _jpg = null!;
private TextBox _dpi = null!, _range = null!;
public bool Confirmed { get; private set; }
public bool Jpeg { get; private set; }
public double Dpi { get; private set; } = 150;
public string Range { get; private set; } = "";
/// <summary>presetRange seeds the page-range field - the Pages panel's per-page export
/// (#207) opens the same dialog scoped to the clicked page(s), still editable.</summary>
public ExportImagesDialog(Window owner, string presetRange = "")
{
Title = "KillerPDF - " + L("Str_ExportImg_Suffix");
// Width follows the caption. "Export Pages as Images" is 22 characters in en-US and
// up to 35 translated, which ran the title under the close button at a fixed 380 (#223).
MinWidth = 380;
SizeToContent = SizeToContent.WidthAndHeight;
UseLayoutRounding = true;
DialogChrome.Configure(this, owner);
BuildUi();
_range.Text = presetRange;
}
private void BuildUi()
{
var body = new StackPanel { Margin = new Thickness(20, 6, 20, 16) };
body.Children.Add(UiKit.GroupLabel(L("Str_ExportImg_Format")));
var formatRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 8) };
_png = UiKit.Radio("PNG");
_png.IsChecked = true;
_png.Margin = new Thickness(0, 0, 14, 0);
_jpg = UiKit.Radio("JPEG");
formatRow.Children.Add(_png);
formatRow.Children.Add(_jpg);
body.Children.Add(formatRow);
body.Children.Add(UiKit.GroupLabel(L("Str_ExportImg_Dpi")));
_dpi = UiKit.Field();
_dpi.Text = "150";
_dpi.Margin = new Thickness(0, 0, 0, 8);
body.Children.Add(_dpi);
body.Children.Add(UiKit.GroupLabel(L("Str_Stamp_Pages")));
_range = UiKit.Field();
_range.ToolTip = L("Str_Crop_RangeTip");
_range.Margin = new Thickness(0, 0, 0, 8);
body.Children.Add(_range);
var cancel = UiKit.Make(L("Str_Tf_Cancel"), accent: false);
cancel.Click += (_, _2) => { Confirmed = false; Close(); };
cancel.IsCancel = true; // Esc
var export = UiKit.Make(L("Str_ExportImg_Export"), accent: true);
export.Click += (_, _2) => Commit();
export.IsDefault = true; // Enter
var row = UiKit.ButtonRow(cancel, export);
row.Margin = new Thickness(0, 8, 0, 0);
body.Children.Add(row);
Content = DialogChrome.Frame(this, Owner, "KillerPDF - " + L("Str_ExportImg_Suffix"),
() => { Confirmed = false; Close(); }, body);
Loaded += (_, _2) => _dpi.Focus();
}
private void Commit()
{
Jpeg = _jpg.IsChecked == true;
// Same accepted DPI window as the CLI (24-1200); anything unparsable falls back to 150.
Dpi = double.TryParse(_dpi.Text.Trim(), out double d) && d >= 24 && d <= 1200 ? d : 150;
Range = _range.Text.Trim();
Confirmed = true;
Close();
}
private static string L(string key) => Application.Current?.TryFindResource(key) as string ?? key;
}
}
+550
View File
@@ -0,0 +1,550 @@
<!-- Themed replacement for Microsoft.Win32.OpenFileDialog / SaveFileDialog.
Same chrome, places rail, view modes and sortable columns as FolderPickerDialog - the row
styles and templates are shared from Controls.xaml - plus a file name box, a filter combo
and Open/Save behavior. The property surface deliberately mirrors the Win32 dialogs
(Title, Filter, FilterIndex, FileName, InitialDirectory, DefaultExt, ...) so a call site
changes by one word. -->
<Window x:Class="KillerPDF.Controls.FileDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:kui="clr-namespace:KillerPDF.Controls"
Title="KillerPDF"
Width="720" Height="520" MinWidth="560" MinHeight="420"
WindowStyle="None" ResizeMode="CanResize" ShowInTaskbar="False"
AllowsTransparency="True"
Background="Transparent"
TextOptions.TextFormattingMode="Display"
TextOptions.TextRenderingMode="ClearType"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
WindowStartupLocation="CenterOwner">
<!-- The picker's styles are merged HERE, not into App.xaml. LocaleManager owns application
merged-dictionary slot [2] - it assigns the locale override there and, for English,
REMOVES it - so anything parked at [2] is deleted at startup. That is what "Cannot find
resource named 'DarkTextBox'" was. These styles are the picker's own, so scoping them to
the picker removes the index coupling entirely rather than competing for a slot. -->
<Window.Resources>
<ResourceDictionary Source="pack://application:,,,/Controls/PickerStyles.xaml"/>
</Window.Resources>
<!-- NO shell:WindowChrome. On an AllowsTransparency window it fills its own non-client area,
which paints as a flat band all the way round the card - the "halo". It was NOT the only
difference from the other four dialogs: the code-behind also called
DwmChrome.SetRoundedCorners/SetThemeBorder, and on a layered window the DWM corner
preference composites DWM's own rounded frame around the WINDOW rect (halo included),
tinted by the border color - the band survived the WindowChrome removal because of it.
Both are gone now; no WindowChrome, no DWM calls, same as the other four.
Resize is done by hand instead, on the halo itself: Resize_MouseDown works out which edge
or corner the pointer is in and hands the drag to Windows with WM_NCLBUTTONDOWN, the same
mechanism Shell/Chrome.cs uses for the main window's grip. That needs the halo to receive
mouse input, which is why this Grid is #01000000 (alpha 1/255) rather than Transparent -
a fully transparent area gets no mouse events at all.
Two earlier attempts blamed the wrong thing. The shadow WAS also wrong - it sat on the
content Border, and this is the only dialog with a ComboBox, whose dropdown is an
AllowsTransparency Popup; a WPF Effect over a subtree containing one renders as a filled
rectangle. That is fixed below and is a real bug, it just was not this one.
(2026-07-30, four attempts) -->
<!-- RootFade, not RootBorder, is what the open/close animation drives - and it starts at 0,
the same way KillerShell's RootGrid does. The fade used to run on RootBorder alone, but
the shadow layer below is a SIBLING carrying the identical BackgroundBrush and geometry,
with no starting opacity: it slammed in solid on the first frame and only the card faded
on top of it, so the dialog read as blinking into existence rather than fading. Fading the
shared parent takes the shadow with it. (2026-07-31) -->
<Grid x:Name="RootFade" Opacity="0" Background="#01000000"
MouseMove="Resize_MouseMove" MouseLeftButtonDown="Resize_MouseDown">
<!-- Shadow layer: same geometry, NO content, not hit-testable. -->
<Border Margin="{DynamicResource DialogHaloMargin}" CornerRadius="{DynamicResource PanelCornerRadius}" Background="{DynamicResource BackgroundBrush}"
IsHitTestVisible="False">
<Border.Effect>
<!-- RenderingBias Quality: at radius 18 the default Performance bias renders the
blur at reduced resolution and scales it up, which blocks up into stair-steps
on this card's rounded corners. Same fix as App.xaml's PaneShadow. -->
<!-- Opacity follows the theme. A flat palette sets FlyoutShadowOpacity to 0, which
removes the cast entirely rather than leaving a soft halo round a hard-edged
window. Every other theme keeps the 0.6 it had. -->
<DropShadowEffect Color="Black" BlurRadius="18" ShadowDepth="3" Direction="270"
Opacity="{DynamicResource FlyoutShadowOpacity}"
RenderingBias="Quality"/>
</Border.Effect>
</Border>
<!-- Corner radius follows the theme, not a hardcoded 6: a square-cornered palette was
getting a rounded card, and its square bevel then cut across the corners. -->
<Border x:Name="RootBorder" BorderBrush="{DynamicResource DialogFrameBrush}" BorderThickness="{DynamicResource DialogFrameThickness}"
Background="{DynamicResource BackgroundBrush}"
CornerRadius="{DynamicResource PanelCornerRadius}" Margin="{DynamicResource DialogHaloMargin}"
Padding="{DynamicResource DialogWindowFramePadding}">
<Grid>
<Grid.RowDefinitions>
<!-- Auto, with the height on the band itself: DialogTitleBarHeight is a Double and
a RowDefinition wants a GridLength, which will not convert. -->
<RowDefinition Height="Auto"/> <!-- title bar -->
<RowDefinition Height="Auto"/> <!-- heading -->
<RowDefinition Height="Auto"/> <!-- path row -->
<RowDefinition Height="*"/> <!-- places | entries -->
<RowDefinition Height="Auto"/> <!-- file name + filter -->
<RowDefinition Height="Auto"/> <!-- footer -->
</Grid.RowDefinitions>
<!-- Shared film grain over the whole dialog surface -->
<Border Grid.RowSpan="6" IsHitTestVisible="False"
Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
<!-- Title bar. TitleBarBrush, not Transparent: the row already spans the card, so this
gives it a real caption band - a gradient on the themes that define one, and
identical to BackgroundBrush on the themes that do not, which is why the other
palettes look unchanged. -->
<Border Grid.Row="0" Height="{DynamicResource DialogTitleBarHeight}"
Background="{DynamicResource DialogTitleBarBrush}" MouseLeftButtonDown="TitleBar_MouseLeftButtonDown">
<Grid>
<!-- The caption band paints OVER the dialog-wide grain (declared before this row),
so it carries its own tile - same as the main window's chrome. -->
<Border IsHitTestVisible="False"
Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
<Grid Margin="{DynamicResource TitleBarPadding}">
<!-- A file picker caption names the operation. It is window chrome, not a
branding surface; the caller's Title belongs here and nowhere else. -->
<TextBlock Text="{Binding Title, RelativeSource={RelativeSource AncestorType={x:Type Window}}}"
FontFamily="{DynamicResource ChromeFontFamily}"
FontSize="{DynamicResource MenuFontSize}" FontWeight="Bold"
Foreground="{DynamicResource ChromeTextBrush}"
VerticalAlignment="Center" IsHitTestVisible="False"/>
<Button x:Name="CaptionCloseButton" Content="&#xE8BB;" Click="Cancel_Click"
HorizontalAlignment="Right" VerticalAlignment="Top"
Width="{DynamicResource DialogCloseWidth}" Height="{DynamicResource DialogCloseHeight}"
Margin="{DynamicResource DialogCaptionButtonsMargin}"
FontSize="10" FontFamily="Segoe MDL2 Assets"
Foreground="{DynamicResource CaptionCloseBrush}"
Background="Transparent" BorderThickness="0" Cursor="Hand"/>
</Grid>
</Grid>
</Border>
<!-- Path row: up one level + current path + view modes -->
<Grid Grid.Row="2" Margin="16,8,16,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button x:Name="UpButton" Grid.Column="0" Content="&#xE74A;" Click="Up_Click"
ToolTip="{DynamicResource Str_TT_Up}" Style="{StaticResource PickerViewBtn}"
Margin="0,0,6,0"/>
<!-- Explicit style: an unstyled TextBox falls back to WPF's white fill and blue
focus border. The chevron overlays the box's right edge (the box pads 28 to
clear it) and drops the recent-locations list, like Explorer's address bar. -->
<Grid Grid.Column="1">
<TextBox x:Name="PathBox" Height="28" Style="{StaticResource DarkTextBox}"
FontFamily="Consolas" FontSize="12" Padding="8,0,28,0"
VerticalContentAlignment="Center" KeyDown="PathBox_KeyDown"/>
<Button x:Name="RecentsBtn" Content="{DynamicResource ComboChevGlyph}" Click="RecentsBtn_Click"
ToolTip="{DynamicResource Str_TT_RecentLocations}"
Style="{StaticResource PickerComboArrowBtn}"
HorizontalAlignment="Right" Margin="0,0,1,0"/>
<!-- Recent locations. Same raised-surface pattern as everything else:
shadow on an item-free sibling, content border on top. -->
<Popup x:Name="RecentsPopup" PlacementTarget="{Binding ElementName=PathBox}"
Placement="Bottom" StaysOpen="False" AllowsTransparency="True"
VerticalOffset="2" HorizontalOffset="-8">
<Grid Margin="8">
<Border Background="{DynamicResource FileDialogPaneBrush}" CornerRadius="{DynamicResource ControlCornerRadius}" IsHitTestVisible="False">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="14" ShadowDepth="2" Direction="270" Opacity="{DynamicResource FlyoutShadowOpacity}"/>
</Border.Effect>
</Border>
<Border Background="{DynamicResource FileDialogPaneBrush}" BorderBrush="{DynamicResource MenuBorderBrush}"
BorderThickness="1" CornerRadius="{DynamicResource ControlCornerRadius}">
<Grid>
<Border IsHitTestVisible="False" CornerRadius="{DynamicResource ControlCornerRadius}"
Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
<ListBox x:Name="RecentsList" Background="Transparent" BorderThickness="0" Padding="2,4"
Width="{Binding ActualWidth, ElementName=PathBox}" MaxHeight="260"
ItemContainerStyle="{StaticResource PickerRow}"
SelectionChanged="RecentsList_SelectionChanged"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontFamily="Consolas" FontSize="12"
TextTrimming="CharacterEllipsis"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
<Control Panel.ZIndex="20" Style="{StaticResource PaneBevelOverlay}"/>
</Grid>
</Popup>
</Grid>
<StackPanel Grid.Column="2" Orientation="Horizontal" Margin="8,0,0,0" VerticalAlignment="Center">
<!-- E7B3 (off) / E890 (on) - KillerShell's build-proven pair (ViewOptions.cs). -->
<Button x:Name="ShowHiddenBtn" Style="{StaticResource PickerViewBtn}" Content="&#xE7B3;" ToolTip="{DynamicResource Str_TT_ShowHidden}" Click="ShowHidden_Click" Margin="0,0,6,0"/>
<Button x:Name="ViewListBtn" Style="{StaticResource PickerViewBtn}" Content="&#xE8FD;" ToolTip="{DynamicResource Str_TT_ViewList}" Click="ViewList_Click"/>
<Button x:Name="ViewIconsBtn" Style="{StaticResource PickerViewBtn}" Content="&#xE80A;" ToolTip="{DynamicResource Str_TT_ViewIcons}" Click="ViewIcons_Click" Margin="2,0"/>
<Button x:Name="ViewDetailsBtn" Style="{StaticResource PickerViewBtn}" Content="&#xE700;" ToolTip="{DynamicResource Str_TT_ViewDetails}" Click="ViewDetails_Click"/>
</StackPanel>
</Grid>
<!-- Main: quick places on the left, folder contents on the right -->
<Grid Grid.Row="3" Margin="16,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="170"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition x:Name="ImagePreviewGapColumn" Width="0"/>
<ColumnDefinition x:Name="ImagePreviewColumn" Width="0"/>
</Grid.ColumnDefinitions>
<!-- A lifted card matching the file list (2026-08-15, revising the 2026-07-30 flat
call - the pane carried a fill anyway, so square shadowless edges read as a
bug, not flatness). Shadow on a SEPARATE border so text keeps ClearType.
KillerShell's own arrangement, not Explorer's: the tree fills the panel and
the pinned places sit BELOW it, the same slot its favorites drawer occupies,
so the two apps read identically. Draggable divider between. -->
<Border Grid.Column="0" IsHitTestVisible="False"
Background="{DynamicResource FileDialogPaneBrush}"
CornerRadius="{DynamicResource ControlCornerRadius}">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="14" ShadowDepth="2" Direction="270" Opacity="{DynamicResource FlyoutShadowOpacity}"/>
</Border.Effect>
</Border>
<!-- Film grain on the places card - the card's fill covers the dialog-wide tile. -->
<Border Grid.Column="0" IsHitTestVisible="False"
CornerRadius="{DynamicResource ControlCornerRadius}"
Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
<Grid Grid.Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="0"/>
<RowDefinition Height="0"/>
<RowDefinition x:Name="PlacesRow" Height="*" MinHeight="56"/>
</Grid.RowDefinitions>
<Control Grid.RowSpan="3" Panel.ZIndex="20" Style="{StaticResource PaneBevelOverlay}"/>
<ListBox x:Name="PlacesList" Grid.Row="2" Background="Transparent" BorderThickness="0" Padding="2,4"
ItemContainerStyle="{StaticResource PickerPlaceRow}" SelectionChanged="Places_SelectionChanged"
ContextMenuOpening="Places_ContextMenuOpening"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="{DynamicResource Str_Menu_UnpinPlace}" Click="UnpinPlace_Click">
<MenuItem.Icon><TextBlock Text="&#xE77A;" FontFamily="Segoe MDL2 Assets" FontSize="12"/></MenuItem.Icon>
</MenuItem>
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Style="{StaticResource PickerRowContent}">
<Image Source="{Binding Icon}" Width="16" Height="16" VerticalAlignment="Center"
Margin="0,0,8,0" SnapsToDevicePixels="True"
RenderOptions.BitmapScalingMode="NearestNeighbor"/>
<TextBlock Text="{Binding Label}" FontFamily="Consolas" FontSize="12" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- KillerShell's divider verbatim (MainWindow.xaml, terms/filters split):
invisible grab band, 1px CardBorderBrush line, accent on hover and
while dragging. -->
<GridSplitter Grid.Row="1" Height="0" Visibility="Collapsed" HorizontalAlignment="Stretch" Background="Transparent"
ResizeBehavior="PreviousAndNext" ResizeDirection="Rows"
Cursor="SizeNS" ShowsPreview="False" Focusable="False">
<GridSplitter.Template>
<ControlTemplate TargetType="GridSplitter">
<Border Background="Transparent">
<Border x:Name="line" Height="1" VerticalAlignment="Center"
Background="{DynamicResource CardBorderBrush}" Margin="12,0"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="line" Property="Background" Value="{DynamicResource PrimaryBrush}"/></Trigger>
<Trigger Property="IsDragging" Value="True"><Setter TargetName="line" Property="Background" Value="{DynamicResource PrimaryBrush}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</GridSplitter.Template>
</GridSplitter>
<!-- Left margin 0, not 4: the 16px expander gutter already provides the
inset, and the extra margin pushed the root icons visibly right of the
places icons below - dead space that cost horizontal room.
(2026-07-30) -->
<TreeView x:Name="FolderTreeCtl" Grid.Row="0" Style="{StaticResource FolderTreeView}" Visibility="Collapsed"
Margin="0,3,2,4"
PreviewMouseWheel="FolderTree_PreviewMouseWheel"
TreeViewItem.Expanded="FolderTree_Expanded"
ContextMenuOpening="FolderTree_ContextMenuOpening"
SelectedItemChanged="FolderTree_SelectedItemChanged">
<TreeView.ContextMenu>
<ContextMenu>
<MenuItem Header="{DynamicResource Str_Menu_PinPlace}" Click="TreePin_Click">
<MenuItem.Icon><TextBlock Text="&#xE718;" FontFamily="Segoe MDL2 Assets" FontSize="12"/></MenuItem.Icon>
</MenuItem>
</ContextMenu>
</TreeView.ContextMenu>
<TreeView.ItemContainerStyle>
<!-- Inherits the themed template and adds the two-way state bindings,
so RevealInTree can drive the tree from code. -->
<Style TargetType="TreeViewItem" BasedOn="{StaticResource FolderTreeItem}">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}"/>
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}"/>
</Style>
</TreeView.ItemContainerStyle>
<TreeView.ItemTemplate>
<HierarchicalDataTemplate DataType="{x:Type kui:FolderNode}"
ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Margin="0,1">
<Image Source="{Binding Icon}" Width="16" Height="16"
VerticalAlignment="Center" Margin="0,0,6,0" SnapsToDevicePixels="True"
RenderOptions.BitmapScalingMode="NearestNeighbor"/>
<TextBlock Text="{Binding Name}" Style="{StaticResource TreeName}"
ToolTip="{Binding Path}"/>
</StackPanel>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
<!-- Edge fades, KillerShell's tree pattern verbatim (MainWindow.xaml): the
overlay IS the surface - BackgroundBrush plus the same grain, exactly as
KillerShell's, now that the pane is flat on the card - under an opacity
mask, so rows dissolve into an exact match instead of a flat band.
Hit-test-transparent; each edge only shows while there is something PAST
it, ramped in code (SyncTreeEdgeFades). Right inset clears the
scrollbar; the bottom margin is driven from code when a horizontal bar
appears (SyncTreeFade). -->
<Border x:Name="TreeFadeTop" Grid.Row="0" Height="18" Opacity="0" Visibility="Collapsed"
VerticalAlignment="Top" Margin="0,3,14,0" IsHitTestVisible="False">
<Border.OpacityMask>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#FF000000" Offset="0"/>
<GradientStop Color="#00000000" Offset="1"/>
</LinearGradientBrush>
</Border.OpacityMask>
<Grid>
<Border Background="{DynamicResource BackgroundBrush}"/>
<Border Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
</Grid>
</Border>
<Border x:Name="TreeFadeBottom" Grid.Row="0" Height="22" Opacity="0" Visibility="Collapsed"
VerticalAlignment="Bottom" Margin="0,0,14,4" IsHitTestVisible="False">
<Border.OpacityMask>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#00000000" Offset="0"/>
<GradientStop Color="#FF000000" Offset="1"/>
</LinearGradientBrush>
</Border.OpacityMask>
<Grid>
<Border Background="{DynamicResource BackgroundBrush}"/>
<Border Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
</Grid>
</Border>
<!-- Same edge fades for the places list (2026-07-30): rows dissolve
at both ends unless the list is flush there. Same surface, same ramp
(SyncPlacesEdgeFades); no scrollbar lift needed - horizontal scrolling
is disabled on this list. -->
<Border x:Name="PlacesFadeTop" Grid.Row="2" Height="18" Opacity="0"
VerticalAlignment="Top" Margin="0,0,12,0" IsHitTestVisible="False">
<Border.OpacityMask>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#FF000000" Offset="0"/>
<GradientStop Color="#00000000" Offset="1"/>
</LinearGradientBrush>
</Border.OpacityMask>
<Grid>
<Border Background="{DynamicResource BackgroundBrush}"/>
<Border Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
</Grid>
</Border>
<Border x:Name="PlacesFadeBottom" Grid.Row="2" Height="22" Opacity="0"
VerticalAlignment="Bottom" Margin="0,0,12,0" IsHitTestVisible="False">
<Border.OpacityMask>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#00000000" Offset="0"/>
<GradientStop Color="#FF000000" Offset="1"/>
</LinearGradientBrush>
</Border.OpacityMask>
<Grid>
<Border Background="{DynamicResource BackgroundBrush}"/>
<Border Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
</Grid>
</Border>
</Grid>
<Grid Grid.Column="2">
<Border Background="{DynamicResource FileDialogPaneBrush}" CornerRadius="{DynamicResource ControlCornerRadius}" IsHitTestVisible="False">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="14" ShadowDepth="2" Direction="270" Opacity="{DynamicResource FlyoutShadowOpacity}"/>
</Border.Effect>
</Border>
<Border Background="{DynamicResource FileDialogPaneBrush}" CornerRadius="{DynamicResource ControlCornerRadius}">
<Grid>
<Border IsHitTestVisible="False" CornerRadius="{DynamicResource ControlCornerRadius}"
Background="{DynamicResource GrainTileBrush}" Opacity="{DynamicResource GrainOpacity}"/>
<DockPanel>
<Grid DockPanel.Dock="Top" x:Name="DetailsHeader" Visibility="Collapsed" Margin="12,4,26,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="150"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Style="{StaticResource PickerColBtn}" Click="SortName_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{DynamicResource Str_Col_Name}"/>
<TextBlock x:Name="NameArrow" FontFamily="Segoe MDL2 Assets" FontSize="8" Margin="4,1,0,0"/>
</StackPanel>
</Button>
<Button Grid.Column="1" Style="{StaticResource PickerColBtn}" HorizontalContentAlignment="Right" Click="SortSize_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{DynamicResource Str_Col_Size}"/>
<TextBlock x:Name="SizeArrow" FontFamily="Segoe MDL2 Assets" FontSize="8" Margin="4,1,0,0"/>
</StackPanel>
</Button>
<Button Grid.Column="2" Style="{StaticResource PickerColBtn}" Margin="10,0,0,0" Click="SortModified_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{DynamicResource Str_Col_Modified}"/>
<TextBlock x:Name="ModArrow" FontFamily="Segoe MDL2 Assets" FontSize="8" Margin="4,1,0,0"/>
</StackPanel>
</Button>
</Grid>
<Grid>
<ListBox x:Name="FileList" Background="Transparent" BorderThickness="0" Padding="2,4"
ItemContainerStyle="{StaticResource PickerRow}"
ItemTemplate="{StaticResource RowTemplate}"
ItemsPanel="{StaticResource PanelStack}"
SelectionChanged="Files_SelectionChanged" MouseDoubleClick="Files_DoubleClick"
ContextMenuOpening="Files_ContextMenuOpening"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.CanContentScroll="False"
PreviewMouseWheel="FileList_PreviewMouseWheel">
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="{DynamicResource Str_Menu_PinPlace}" Click="FilePin_Click">
<MenuItem.Icon><TextBlock Text="&#xE718;" FontFamily="Segoe MDL2 Assets" FontSize="12"/></MenuItem.Icon>
</MenuItem>
</ContextMenu>
</ListBox.ContextMenu>
</ListBox>
<TextBlock x:Name="EmptyHint" Text="{DynamicResource Str_Dlg_NoMatchingFiles}" Visibility="Collapsed"
Foreground="{DynamicResource DimTextBrush}" FontFamily="Consolas" FontSize="11"
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap"
TextAlignment="Center" Margin="20"/>
</Grid>
</DockPanel>
</Grid>
</Border>
<Control Panel.ZIndex="20" Style="{StaticResource PaneBevelOverlay}"/>
</Grid>
<!-- Image-only pickers opt into this pane. Keeping it in the shared dialog makes
Insert Image, image signatures, image stamps, and image-to-PDF import behave
identically without changing ordinary Open/Save dialogs. -->
<Grid x:Name="ImagePreviewHost" Grid.Column="4" Visibility="Collapsed">
<Border Background="{DynamicResource FileDialogPaneBrush}"
CornerRadius="{DynamicResource ControlCornerRadius}"
IsHitTestVisible="False">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="14" ShadowDepth="2" Direction="270"
Opacity="{DynamicResource FlyoutShadowOpacity}"/>
</Border.Effect>
</Border>
<Border Background="{DynamicResource FileDialogPaneBrush}"
CornerRadius="{DynamicResource ControlCornerRadius}">
<Grid>
<Border IsHitTestVisible="False"
CornerRadius="{DynamicResource ControlCornerRadius}"
Background="{DynamicResource GrainTileBrush}"
Opacity="{DynamicResource GrainOpacity}"/>
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Text="{DynamicResource Str_Dlg_Preview}"
FontFamily="Consolas" FontSize="11"
Foreground="{DynamicResource MutedTextBrush}"/>
<Grid Grid.Row="1" Margin="0,10,0,0">
<TextBlock x:Name="ImagePreviewPlaceholder" Text="&#xEB9F;"
FontFamily="Segoe MDL2 Assets" FontSize="42"
Foreground="{DynamicResource DimTextBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Image x:Name="ImagePreview" Stretch="Uniform"
SnapsToDevicePixels="True"
RenderOptions.BitmapScalingMode="HighQuality"/>
</Grid>
</Grid>
</Grid>
</Border>
<Control Panel.ZIndex="20" Style="{StaticResource PaneBevelOverlay}"/>
</Grid>
</Grid>
<!-- File name + filter. Labels are DimTextBrush on the dark card: 3.15:1, the tier
these brushes are calibrated for. -->
<Grid Grid.Row="4" Margin="16,12,16,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="{DynamicResource Str_Dlg_FileName}" VerticalAlignment="Center"
Margin="0,0,10,0" Foreground="{DynamicResource MutedTextBrush}" FontFamily="Consolas" FontSize="11"/>
<!-- Left padding 5, not 8: a TextBox's content host carries its own ~2px inset
(WPF's TextBoxView), so at 8 the name started visibly right of the filter
combo's text directly below it (combo padding is 7 with no inherent inset).
5 + 2 lines the two up. (2026-07-31) -->
<TextBox x:Name="FileNameBox" Grid.Row="0" Grid.Column="1" Height="28"
Style="{StaticResource DarkTextBox}"
FontFamily="Consolas" FontSize="12" Padding="5,0,8,0"
VerticalContentAlignment="Center" KeyDown="FileNameBox_KeyDown"/>
<TextBlock x:Name="FilterLabel" Grid.Row="1" Grid.Column="0" Text="{DynamicResource Str_Dlg_FileType}" VerticalAlignment="Center"
Margin="0,8,10,0" Foreground="{DynamicResource MutedTextBrush}" FontFamily="Consolas" FontSize="11"/>
<ComboBox x:Name="FilterCombo" Grid.Row="1" Grid.Column="1" Height="28" Margin="0,8,0,0"
Style="{StaticResource DarkComboBox}"
SelectionChanged="Filter_SelectionChanged"/>
</Grid>
<!-- Footer: selection details on the left, actions on the right -->
<Grid Grid.Row="5" Margin="16,12,16,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" VerticalAlignment="Center" Margin="0,0,12,0">
<TextBlock x:Name="SelName" FontFamily="Consolas" FontSize="12" Foreground="{DynamicResource TextBrush}"
TextTrimming="CharacterEllipsis"/>
<TextBlock x:Name="SelMeta" FontFamily="Consolas" FontSize="10" Foreground="{DynamicResource MutedTextBrush}"
TextTrimming="CharacterEllipsis" Margin="0,2,0,0"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="{DynamicResource Str_Btn_Cancel}" Style="{StaticResource SurfaceButton}" MinWidth="90" Margin="0,0,8,0" Click="Cancel_Click"/>
<Button x:Name="AcceptButton" Style="{StaticResource OutlineButton}" MinWidth="90" Click="OK_Click"/>
</StackPanel>
</Grid>
</Grid>
</Border>
<!-- The same directional 98SE window frame used by DialogChrome. Modern themes set these
thicknesses to zero, so their existing card treatment is unchanged. -->
<Grid Margin="{DynamicResource DialogHaloMargin}" IsHitTestVisible="False">
<Border Margin="{DynamicResource WindowFrameMargin}"
BorderBrush="{DynamicResource WindowFrameBrush}"
BorderThickness="{DynamicResource DialogWindowFrameThickness}"/>
<Border Margin="{DynamicResource FrameInnerMargin}"
BorderBrush="{DynamicResource FrameInnerLightBrush}"
BorderThickness="{DynamicResource FrameInnerLightThickness}"/>
<Border Margin="{DynamicResource FrameInnerMargin}"
BorderBrush="{DynamicResource FrameInnerDarkBrush}"
BorderThickness="{DynamicResource FrameInnerDarkThickness}"/>
<Border BorderBrush="{DynamicResource FrameOuterLightBrush}"
BorderThickness="{DynamicResource FrameOuterLightThickness}"/>
<Border BorderBrush="{DynamicResource FrameOuterDarkBrush}"
BorderThickness="{DynamicResource FrameOuterDarkThickness}"/>
</Grid>
</Grid>
</Window>
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
namespace KillerPDF
{
/// <summary>
/// Where every flyout opens: the bottom corner of the content pane beside the rail.
/// (From KillerUI/Shell/FlyoutPlacement.cs - the family flyout standard.)
///
/// That rail-adjacent corner is the answer because of what bounds it, and all three matter:
/// - it is INSIDE the window, so a flyout never hangs over the desktop;
/// - it is ABOVE the footer, so the status bar is never covered;
/// - it is clear of the icon rail, so the rail buttons are never covered.
/// The content pane is the one element bounded by all three at once, so flyouts are positioned
/// against IT - not against the button, and not by any built-in placement mode.
///
/// WHY NOT PlacementMode.Right / Top / etc: a Popup is its own top-level window, and WPF's
/// built-in modes only ever avoid the SCREEN edge. They do not know the app window exists, let
/// alone the footer or the rail. "Right of the button" opened flyouts over the desktop when the
/// rail sat near the window's right edge; "Top" opened them over the status bar. Hours went
/// into re-tuning offsets before it was clear no built-in mode can express the requirement.
/// The requirement: do not obscure the icons, do not obscure the status bar, and put the
/// flyout against the rail-adjacent corner of the content pane. (2026-07-30)
///
/// WIRING (once, before the flyouts open):
/// FlyoutPlacement.UsePane(pane, railOnRight); // the element the document content sits on
/// then, each time a flyout opens:
/// FlyoutPlacement.Attach(themeMenu, themeButton);
/// themeMenu.IsOpen = true;
///
/// The flyout's own card carries a 6px margin for its drop shadow (FlyoutCard in
/// MainWindow.xaml), so pinning flush to the corner leaves the VISIBLE card sitting neatly just
/// inside it. Do not add an inset here.
/// </summary>
internal static class FlyoutPlacement
{
/// <summary>The content pane. Set once; every flyout positions against it.</summary>
private static FrameworkElement? _pane;
private static bool _alignRight;
internal static void UsePane(FrameworkElement pane, bool alignRight)
{
_pane = pane;
_alignRight = alignRight;
}
internal static void Attach(Popup popup, UIElement _)
{
popup.PlacementTarget = _pane;
popup.Placement = PlacementMode.Custom;
popup.CustomPopupPlacementCallback =
(popupSize, targetSize, __) => PaneCorner(popupSize, targetSize, _alignRight);
}
internal static void Attach(ContextMenu menu, UIElement _)
{
menu.PlacementTarget = _pane;
menu.Placement = PlacementMode.Custom;
// The shared ContextMenu style compensates ordinary pointer-anchored menus for its
// enlarged shadow halo. Rail flyouts use exact pane-corner coordinates instead, so
// clear those global offsets and account for the halo in BottomLeftOfPane.
menu.HorizontalOffset = 0;
menu.VerticalOffset = 0;
menu.CustomPopupPlacementCallback =
(popupSize, targetSize, __) => PaneCorner(popupSize, targetSize, _alignRight);
}
/// <summary>
/// Coordinates are relative to the pane's top-left. The horizontal coordinate mirrors
/// between pane edges, while y puts the flyout's bottom above the footer.
/// </summary>
internal static CustomPopupPlacement[] PaneCorner(
Size popupSize, Size targetSize, bool alignRight)
{
// ContextMenu's template now reserves 22px left, 18px top, and 26px bottom for its
// shadow. Position the VISIBLE card at the same 6px pane inset used before that halo
// grew. On the right, mirror the same inset against the pane's right edge.
double x = alignRight ? targetSize.Width - popupSize.Width + 16 : -16;
double y = targetSize.Height - popupSize.Height + 20;
// A flyout taller than the pane would otherwise start above it and run over the
// toolbar; pin it to the pane's top instead and let it use the height it has.
if (y < 0) y = 0;
return new[] { new CustomPopupPlacement(new Point(x, y), PopupPrimaryAxis.None) };
}
}
}
+207
View File
@@ -0,0 +1,207 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Media;
namespace KillerPDF.Controls
{
// ═══════════════════════════════════════════════════════════
// FOLDER TREE - the file dialog's left pane, below places
// ═══════════════════════════════════════════════════════════
// Ported from KillerShell's FolderTree.cs (the family reference), minus what a modal file
// dialog does not need: no demo mode, no expansion persistence (the dialog reveals the
// current folder on open instead), no shell context menu suite.
//
// One node per folder, children loaded only when a node is actually expanded. A tree that
// eagerly walked the disk would hang on the first drive with a deep tree on it. The lazy
// load is the standard placeholder trick: every node that might have children gets a single
// dummy child so WPF draws an expander arrow, and the real children replace it on first
// expand. "Might have children" is deliberately optimistic - proving a folder empty costs
// the very enumeration being deferred.
public sealed class FolderNode : INotifyPropertyChanged
{
private static readonly FolderNode Placeholder = new("", "", false);
/// <summary>Set by FileDialog from its persisted toggle, BEFORE the tree loads. Gates
/// attribute-Hidden/System folders AND leading-dot names, same as the file list.</summary>
internal static bool ShowHidden;
public string Path { get; }
public string Name { get; }
// Drives get their own treatment: always expandable, never disappear mid-session, and
// their label is "Local Disk (C:)" rather than a bare folder name.
public bool IsDrive { get; }
public ObservableCollection<FolderNode> Children { get; } = [];
public FolderNode(string path, string name, bool mayHaveChildren)
{
Path = path;
Name = name;
if (mayHaveChildren) Children.Add(Placeholder);
}
public FolderNode(DriveInfo d)
{
Path = d.RootDirectory.FullName;
IsDrive = true;
Name = DriveLabel(d);
Children.Add(Placeholder);
}
/// <summary>"Local Disk (C:)" style label, or the bare letter when the volume cannot be read.</summary>
internal static string DriveLabel(DriveInfo d)
{
string letter = d.Name.TrimEnd('\\');
try
{
// VolumeLabel throws on a drive that is not ready (empty optical, disconnected
// share), which is exactly when we still want to show the letter.
if (d.IsReady && !string.IsNullOrWhiteSpace(d.VolumeLabel))
return d.VolumeLabel + " (" + letter + ")";
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
return letter;
}
public bool IsLoaded { get; private set; }
// A drive's REAL icon (USB, network, optical) via the real-path query; plain folders get
// the shared generic folder icon so the tree never touches the disk per row.
public ImageSource? Icon
=> IsDrive ? Services.ShellIcons.Place(Path) : Services.ShellIcons.Small(Path, true);
private bool _isExpanded;
public bool IsExpanded
{
get => _isExpanded;
set { if (_isExpanded != value) { _isExpanded = value; Raise(); } }
}
private bool _isSelected;
public bool IsSelected
{
get => _isSelected;
set { if (_isSelected != value) { _isSelected = value; Raise(); } }
}
/// <summary>
/// Replaces the placeholder with the real subfolders. Enumeration happens off the UI
/// thread - a slow or disconnected network drive would otherwise freeze the dialog for
/// as long as the SMB timeout takes.
/// </summary>
public async Task LoadChildrenAsync()
{
if (IsLoaded) return;
IsLoaded = true;
string path = Path;
List<FolderNode> kids = await Task.Run(() => EnumerateChildren(path)).ConfigureAwait(true);
Children.Clear();
foreach (var k in kids) Children.Add(k);
}
/// <summary>
/// Re-enumerates this node's children in place, keeping whatever the user had open.
/// Used when the show-hidden filter changes. Reconciled IN PLACE rather than cleared
/// and refilled: Clear() removes the container holding the tree's selection, and WPF
/// answers a lost selection by selecting the PARENT node - which would navigate the
/// dialog up one folder on a toggle that has nothing to do with where you are.
/// </summary>
internal async Task RefreshAsync()
{
if (!IsLoaded) return;
string path = Path;
var fresh = await Task.Run(() => EnumerateChildren(path)).ConfigureAwait(true);
var byName = new Dictionary<string, FolderNode>(StringComparer.OrdinalIgnoreCase);
foreach (var n in fresh) byName[n.Name] = n;
for (int i = Children.Count - 1; i >= 0; i--)
if (!byName.ContainsKey(Children[i].Name)) Children.RemoveAt(i);
var have = new Dictionary<string, FolderNode>(StringComparer.OrdinalIgnoreCase);
foreach (var c in Children) have[c.Name] = c;
for (int i = 0; i < fresh.Count; i++)
{
if (have.TryGetValue(fresh[i].Name, out var existing))
{
// Anything the user has opened stays the SAME node object, so its own subtree
// and IsExpanded survive; only genuinely new entries get fresh nodes.
int at = Children.IndexOf(existing);
if (at != i) Children.Move(at, i);
await existing.RefreshAsync(); // its children are stale for the same reason
}
else Children.Insert(i, fresh[i]);
}
}
private static List<FolderNode> EnumerateChildren(string path)
{
var list = new List<FolderNode>();
try
{
foreach (var d in new DirectoryInfo(path).EnumerateDirectories())
{
// Same gate the file list applies: attribute Hidden/System AND leading-dot
// names, so the two panes never disagree about what exists. System is grouped
// with hidden rather than given its own switch - Explorer's separate option
// guards a handful of roots nobody browses to on purpose.
if (!ShowHidden)
{
var a = d.Attributes;
if ((a & FileAttributes.Hidden) != 0 || (a & FileAttributes.System) != 0) continue;
if (d.Name.StartsWith(".", StringComparison.Ordinal)) continue;
}
list.Add(new FolderNode(d.FullName, d.Name, mayHaveChildren: true));
}
}
catch (UnauthorizedAccessException) { /* show what we can see */ }
catch (IOException) { }
list.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.CurrentCultureIgnoreCase));
return list;
}
public event PropertyChangedEventHandler? PropertyChanged;
private void Raise([CallerMemberName] string? p = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(p));
}
// True when a TreeViewItem is the last child of its parent - which is the one thing the
// folder tree's connecting lines need to know. Every node draws a vertical line down its own
// left edge; for the LAST child that line has to stop at the elbow instead of running past
// the bottom of the node into empty space. There is no "IsLastItem" property in WPF and no
// way to ask in pure XAML, hence this. Bound with the item itself as the source, so it
// re-evaluates when the container is recycled onto a different node.
public sealed class LastChildConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not DependencyObject d) return false;
var parent = ItemsControl.ItemsControlFromItemContainer(d);
if (parent == null) return false;
int index = parent.ItemContainerGenerator.IndexFromContainer(d);
return index >= 0 && index == parent.Items.Count - 1;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
}
+514
View File
@@ -0,0 +1,514 @@
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 KillerPDF.Services;
namespace KillerPDF
{
// ============================================================
// Themed dialog - replaces MessageBox for dark-UI consistency
// ============================================================
internal static class KillerDialog
{
// Pulls the current theme brush at call time so dialogs respect light/dark/HC themes.
private static SolidColorBrush R(string key)
=> (SolidColorBrush)Application.Current.Resources[key];
private static string L(string key, string fallback)
=> Application.Current.TryFindResource(key) as string ?? fallback;
// Carries the checkbox state of the last Show() call back to ShowWithCheckbox. Dialogs are
// modal and UI-thread only, so a shared field is safe and avoids a duplicate dialog body.
private static bool _lastCheckboxChecked;
#pragma warning disable IDE0060 // image intentionally kept for API parity with MessageBox; not yet rendered
public static MessageBoxResult Show(
Window? owner,
string message,
string title = "KillerPDF",
MessageBoxButton buttons = MessageBoxButton.OK,
MessageBoxImage image = MessageBoxImage.None,
bool fadeClose = true,
string? checkboxText = null,
MessageBoxResult? defaultResult = null)
#pragma warning restore IDE0060
{
var result = MessageBoxResult.OK;
bool boxChecked = false;
var win = new Window
{
Title = title,
Width = 380,
SizeToContent = SizeToContent.Height
};
DialogChrome.Configure(win, owner, fade: fadeClose);
var outerBorder = new Border
{
Background = R("MenuBackgroundBrush"),
BorderBrush = UiKit.Brush("DialogFrameBrush"),
BorderThickness = Application.Current.TryFindResource("DialogFrameThickness") is Thickness dft ? dft : new Thickness(1),
Padding = Application.Current.TryFindResource("DialogFramePadding") is Thickness dfp ? dfp : new Thickness(0),
CornerRadius = UiKit.RadWindow,
Margin = Application.Current.TryFindResource("DialogHaloMargin") is Thickness hm ? hm : new Thickness(10),
Effect = UiKit.ShadowDialog()
};
var root = new StackPanel();
// Title bar
var titleBar = new Border
{
// Transparent so the dialog-wide film grain shows through the title bar too (it sits
// over the same BgModal surface, so it still reads as one continuous surface).
Background = Application.Current.TryFindResource("UseDialogCaption") is true ? UiKit.Brush("TitleBarBrush") : Brushes.Transparent,
Padding = new Thickness(16, 10, 16, 10),
CornerRadius = new CornerRadius(5, 5, 0, 0)
};
titleBar.MouseLeftButtonDown += (_, e) => { if (e.ButtonState == MouseButtonState.Pressed) win.DragMove(); };
// When the title is just "KillerPDF", render it as the main window's wordmark - "Killer"
// in the primary text color and "PDF" in the green logo accent, bold, with a soft shadow.
if (title == "KillerPDF")
{
var wm = new StackPanel { Orientation = Orientation.Horizontal };
var wmTb = new TextBlock { VerticalAlignment = VerticalAlignment.Center };
wmTb.Inlines.Add(new System.Windows.Documents.Run("Killer") { FontFamily = UiKit.WordmarkFont, FontWeight = FontWeights.Normal, FontSize = 15, Foreground = R("TextBrush") });
wmTb.Inlines.Add(new System.Windows.Documents.Run("PDF") { FontFamily = UiKit.WordmarkFontPdf, FontWeight = FontWeights.Bold, FontSize = 19.5, Foreground = R("AccentLogo") });
wm.Children.Add(wmTb);
// No DropShadowEffect on the text - it rasterizes and blurs the wordmark. Kept crisp.
titleBar.Child = wm;
}
else
{
titleBar.Child = new TextBlock
{
Text = title,
Foreground = R("PrimaryBrush"),
FontWeight = FontWeights.Bold, // blue title -> bold
FontSize = 14,
FontFamily = UiKit.MonoFont
};
}
if (Application.Current.TryFindResource("UseDialogCaption") is true)
titleBar = DialogChrome.BuildTitleBar(win, owner, title, () => { result = MessageBoxResult.Cancel; win.Close(); });
titleBar.Height = Application.Current.TryFindResource("DialogTitleBarHeight") is double titleHeight ? titleHeight : double.NaN;
root.Children.Add(titleBar);
// Message
var msgBorder = new Border
{
Padding = new Thickness(20, 16, 20, 8),
Child = new TextBlock
{
Text = message,
Foreground = R("TextBrush"),
FontSize = 13,
TextWrapping = TextWrapping.Wrap
}
};
root.Children.Add(msgBorder);
// Optional checkbox (e.g. "Remember my choice"). Extra top padding sets it apart from the message.
if (checkboxText is not null)
{
var chk = UiKit.CheckBox(checkboxText);
chk.Margin = new Thickness(20, 10, 20, 4);
chk.Checked += (_, _2) => boxChecked = true;
chk.Unchecked += (_, _2) => boxChecked = false;
root.Children.Add(chk);
}
// Buttons
var btnPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right
};
// Build a minimal ControlTemplate so Background binds correctly and
// WPF's default blue hover chrome can't override our colors.
Button MakeBtn(string label, MessageBoxResult res, bool accent = false)
{
// Enter triggers the primary action. Normally that's the accent button; a caller can
// override which button is the default (e.g. the quit prompt makes the safe "No" the
// default), and the accent highlight follows so the Enter target is obvious.
bool isDefault = defaultResult is MessageBoxResult dr ? res == dr : accent;
// Shared themed button (UiKit.Make) so this dialog matches the print dialog et al.
var btn = UiKit.Make(label, isDefault);
btn.Margin = new Thickness(8, 0, 0, 0);
btn.IsDefault = isDefault;
btn.IsCancel = res == MessageBoxResult.Cancel; // Esc triggers Cancel where there is one
btn.Click += (_, _2) => { result = res; win.Close(); };
return btn;
}
switch (buttons)
{
case MessageBoxButton.OK:
btnPanel.Children.Add(MakeBtn(L("Str_Btn_OK", "OK"), MessageBoxResult.OK, accent: true));
break;
case MessageBoxButton.OKCancel:
btnPanel.Children.Add(MakeBtn(L("Str_Btn_OK", "OK"), MessageBoxResult.OK, accent: true));
btnPanel.Children.Add(MakeBtn(L("Str_Btn_Cancel", "Cancel"), MessageBoxResult.Cancel));
break;
case MessageBoxButton.YesNo:
btnPanel.Children.Add(MakeBtn(L("Str_Btn_Yes", "Yes"), MessageBoxResult.Yes, accent: true));
btnPanel.Children.Add(MakeBtn(L("Str_Btn_No", "No"), MessageBoxResult.No));
break;
case MessageBoxButton.YesNoCancel:
btnPanel.Children.Add(MakeBtn(L("Str_Btn_Yes", "Yes"), MessageBoxResult.Yes, accent: true));
btnPanel.Children.Add(MakeBtn(L("Str_Btn_No", "No"), MessageBoxResult.No));
btnPanel.Children.Add(MakeBtn(L("Str_Btn_Cancel", "Cancel"), MessageBoxResult.Cancel));
break;
}
root.Children.Add(new Border
{
Padding = new Thickness(16, 8, 16, 16),
Child = btnPanel
});
// Paint the same film-grain texture the app's panels use, behind the content, so the
// dialog reads as part of the same surface family instead of a flat box.
var contentGrid = new Grid();
var grain = (owner as MainWindow)?.GrainTexture;
if (grain is not null)
{
double grainOpacity = Application.Current.Resources["GrainOpacity"] is double go ? go : 0.05;
contentGrid.Children.Add(new Border
{
CornerRadius = new CornerRadius(6),
IsHitTestVisible = false,
Opacity = grainOpacity,
Background = new System.Windows.Media.ImageBrush(grain)
{
TileMode = System.Windows.Media.TileMode.Tile,
ViewportUnits = System.Windows.Media.BrushMappingMode.Absolute,
Viewport = new Rect(0, 0, 256, 256),
Stretch = System.Windows.Media.Stretch.None
}
});
}
contentGrid.Children.Add(root);
win.Content = DialogChrome.WrapContent(owner, contentGrid);
win.ShowDialog();
_lastCheckboxChecked = boxChecked;
return result;
}
/// <summary>
/// Like <see cref="Show"/> but with a custom set of buttons. Returns the index of the clicked
/// button, or -1 if the dialog was closed without a choice. The button at <paramref name="accentIndex"/>
/// is rendered as the primary (accent) action.
/// </summary>
public static int ShowChoices(
Window? owner,
string message,
string[] labels,
int accentIndex = 0,
string title = "KillerPDF")
{
int result = -1;
var win = new Window { Title = title, MinWidth = 380, MaxWidth = 760, SizeToContent = SizeToContent.WidthAndHeight };
DialogChrome.Configure(win, owner, fade: true);
var outerBorder = new Border
{
Background = R("MenuBackgroundBrush"),
BorderBrush = UiKit.Brush("DialogFrameBrush"),
BorderThickness = Application.Current.TryFindResource("DialogFrameThickness") is Thickness dft ? dft : new Thickness(1),
Padding = Application.Current.TryFindResource("DialogFramePadding") is Thickness dfp ? dfp : new Thickness(0),
CornerRadius = UiKit.RadWindow,
Margin = Application.Current.TryFindResource("DialogHaloMargin") is Thickness hm ? hm : new Thickness(10),
Effect = UiKit.ShadowDialog()
};
var root = new StackPanel();
var titleBar = new Border { Background = Application.Current.TryFindResource("UseDialogCaption") is true ? UiKit.Brush("TitleBarBrush") : Brushes.Transparent, Padding = new Thickness(16, 10, 16, 10) };
titleBar.MouseLeftButtonDown += (_, e) => { if (e.ButtonState == MouseButtonState.Pressed) win.DragMove(); };
if (title == "KillerPDF")
{
var wmTb = new TextBlock { VerticalAlignment = VerticalAlignment.Center };
wmTb.Inlines.Add(new System.Windows.Documents.Run("Killer") { FontFamily = UiKit.WordmarkFont, FontWeight = FontWeights.Normal, FontSize = 15, Foreground = R("TextBrush") });
wmTb.Inlines.Add(new System.Windows.Documents.Run("PDF") { FontFamily = UiKit.WordmarkFontPdf, FontWeight = FontWeights.Bold, FontSize = 19.5, Foreground = R("AccentLogo") });
titleBar.Child = wmTb;
}
else
{
titleBar.Child = new TextBlock { Text = title, Foreground = R("PrimaryBrush"), FontWeight = FontWeights.Bold, FontSize = 14, FontFamily = UiKit.MonoFont };
}
if (Application.Current.TryFindResource("UseDialogCaption") is true)
titleBar = DialogChrome.BuildTitleBar(win, owner, title, () => win.Close());
titleBar.Height = Application.Current.TryFindResource("DialogTitleBarHeight") is double titleHeight ? titleHeight : double.NaN;
root.Children.Add(titleBar);
root.Children.Add(new Border
{
Padding = new Thickness(20, 16, 20, 8),
Child = new TextBlock { Text = message, Foreground = R("TextBrush"), FontSize = 13, TextWrapping = TextWrapping.Wrap, MaxWidth = 560 }
});
var btnPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
for (int i = 0; i < labels.Length; i++)
{
int idx = i;
var btn = UiKit.Make(labels[i], accent: i == accentIndex);
btn.Padding = new Thickness(22, 8, 22, 8);
btn.MinWidth = 96;
btn.Margin = new Thickness(8, 0, 0, 0);
btn.Click += (_, _2) => { result = idx; win.Close(); };
btnPanel.Children.Add(btn);
}
root.Children.Add(new Border { Padding = new Thickness(16, 8, 16, 16), Child = btnPanel });
var contentGrid = new Grid();
var grain = (owner as MainWindow)?.GrainTexture;
if (grain is not null)
{
double grainOpacity = Application.Current.Resources["GrainOpacity"] is double go ? go : 0.05;
contentGrid.Children.Add(new Border
{
CornerRadius = new CornerRadius(6),
IsHitTestVisible = false,
Opacity = grainOpacity,
Background = new ImageBrush(grain)
{
TileMode = TileMode.Tile,
ViewportUnits = BrushMappingMode.Absolute,
Viewport = new Rect(0, 0, 256, 256),
Stretch = Stretch.None
}
});
}
contentGrid.Children.Add(root);
win.Content = DialogChrome.WrapContent(owner, contentGrid);
win.ShowDialog();
return result;
}
/// <summary>
/// Like <see cref="Show"/> but with a "don't warn again" style checkbox between the message and the
/// buttons. Returns the button result and the checkbox state.
/// </summary>
// Same dialog as Show(), plus a checkbox (e.g. "Remember my choice"). Delegates to the single
// Show() implementation so there is one KillerDialog box, not a duplicate.
public static (MessageBoxResult result, bool isChecked) ShowWithCheckbox(
Window? owner,
string message,
string checkboxText,
string title = "KillerPDF",
MessageBoxButton buttons = MessageBoxButton.OKCancel,
MessageBoxResult? defaultResult = null)
{
var result = Show(owner, message, title, buttons, checkboxText: checkboxText, defaultResult: defaultResult);
return (result, _lastCheckboxChecked);
}
/// <summary>
/// KillerFind-style quit prompt (family standard): a short question with TWO opt-out
/// checkboxes stacked between the message and the buttons - "Close my open tabs"
/// (unchecked = session reopens next launch) and "Remember my choice" - plus
/// Cancel / Quit buttons where Quit is the accent + Enter default and Esc cancels.
/// Returns (confirmed, closeTabsChecked, rememberChecked).
///
/// A thin wrapper over <see cref="ShowTwoCheckPrompt"/> since the install prompt needed the
/// same two-checkbox shape. Passing check2Initial:false keeps this behavior identical to
/// what it was when the body lived here.
/// </summary>
public static (bool confirmed, bool closeTabs, bool remember) ShowQuitPrompt(
Window? owner,
string message,
string closeTabsText,
bool closeTabsInitial,
string rememberText,
string quitLabel,
string cancelLabel)
=> ShowTwoCheckPrompt(owner, message, closeTabsText, closeTabsInitial,
rememberText, false, quitLabel, cancelLabel);
/// <summary>
/// The family two-checkbox confirm: a short question, two checkboxes stacked between the
/// message and the buttons, and Cancel / confirm buttons where confirm is the accent +
/// Enter default and Esc cancels. Used by the quit prompt and by the install prompt
/// (desktop shortcut + install for all users).
/// </summary>
public static (bool confirmed, bool check1, bool check2) ShowTwoCheckPrompt(
Window? owner,
string message,
string check1Text,
bool check1Initial,
string check2Text,
bool check2Initial,
string confirmLabel,
string cancelLabel)
{
bool confirmed = false;
bool closeTabs = check1Initial;
bool remember = check2Initial;
var win = new Window { Title = "KillerPDF", Width = 380, SizeToContent = SizeToContent.Height };
// fade:false - the app's own fade-out follows immediately on confirm; two fades
// back-to-back read as lag (same reasoning as the unsaved-changes prompt).
DialogChrome.Configure(win, owner, fade: false);
var outerBorder = new Border
{
Background = R("MenuBackgroundBrush"),
BorderBrush = UiKit.Brush("DialogFrameBrush"),
BorderThickness = Application.Current.TryFindResource("DialogFrameThickness") is Thickness dft ? dft : new Thickness(1),
Padding = Application.Current.TryFindResource("DialogFramePadding") is Thickness dfp ? dfp : new Thickness(0),
CornerRadius = UiKit.RadWindow,
Margin = Application.Current.TryFindResource("DialogHaloMargin") is Thickness hm ? hm : new Thickness(10),
Effect = UiKit.ShadowDialog()
};
var root = new StackPanel();
// Title bar: the wordmark, exactly like Show()'s "KillerPDF" branch.
var titleBar = new Border
{
Background = Application.Current.TryFindResource("UseDialogCaption") is true ? UiKit.Brush("TitleBarBrush") : Brushes.Transparent,
Padding = new Thickness(16, 10, 16, 10),
CornerRadius = new CornerRadius(5, 5, 0, 0)
};
titleBar.MouseLeftButtonDown += (_, e) => { if (e.ButtonState == MouseButtonState.Pressed) win.DragMove(); };
var wm = new StackPanel { Orientation = Orientation.Horizontal };
var wmTb = new TextBlock { VerticalAlignment = VerticalAlignment.Center };
wmTb.Inlines.Add(new System.Windows.Documents.Run("Killer") { FontFamily = UiKit.WordmarkFont, FontWeight = FontWeights.Normal, FontSize = 15, Foreground = R("TextBrush") });
wmTb.Inlines.Add(new System.Windows.Documents.Run("PDF") { FontFamily = UiKit.WordmarkFontPdf, FontWeight = FontWeights.Bold, FontSize = 18, Foreground = R("AccentLogo") });
wm.Children.Add(wmTb);
titleBar.Child = wm;
if (Application.Current.TryFindResource("UseDialogCaption") is true)
titleBar = DialogChrome.BuildTitleBar(win, owner, "KillerPDF", () => win.Close());
titleBar.Height = Application.Current.TryFindResource("DialogTitleBarHeight") is double titleHeight ? titleHeight : double.NaN;
root.Children.Add(titleBar);
root.Children.Add(new Border
{
Padding = new Thickness(20, 16, 20, 8),
Child = new TextBlock
{
Text = message,
Foreground = R("TextBrush"),
FontSize = 13,
TextWrapping = TextWrapping.Wrap
}
});
var chk1 = UiKit.CheckBox(check1Text);
chk1.Margin = new Thickness(20, 10, 20, 0);
chk1.IsChecked = check1Initial;
chk1.Checked += (_, _2) => closeTabs = true;
chk1.Unchecked += (_, _2) => closeTabs = false;
root.Children.Add(chk1);
var chk2 = UiKit.CheckBox(check2Text);
chk2.Margin = new Thickness(20, 8, 20, 4);
chk2.IsChecked = check2Initial;
chk2.Checked += (_, _2) => remember = true;
chk2.Unchecked += (_, _2) => remember = false;
root.Children.Add(chk2);
var btnPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right
};
var cancelBtn = UiKit.Make(cancelLabel, false);
cancelBtn.Margin = new Thickness(8, 0, 0, 0);
cancelBtn.IsCancel = true;
cancelBtn.Click += (_, _2) => win.Close();
var quitBtn = UiKit.Make(confirmLabel, true);
quitBtn.Margin = new Thickness(8, 0, 0, 0);
quitBtn.IsDefault = true;
quitBtn.Click += (_, _2) => { confirmed = true; win.Close(); };
btnPanel.Children.Add(cancelBtn);
btnPanel.Children.Add(quitBtn);
root.Children.Add(new Border
{
Padding = new Thickness(16, 12, 16, 16),
Child = btnPanel
});
// Film grain across the whole card, same as Show().
var contentGrid = new Grid();
var grain = (owner as MainWindow)?.GrainTexture;
if (grain is not null)
{
double grainOpacity = Application.Current.Resources["GrainOpacity"] is double go ? go : 0.05;
contentGrid.Children.Add(new Border
{
CornerRadius = new CornerRadius(6),
IsHitTestVisible = false,
Opacity = grainOpacity,
Background = new System.Windows.Media.ImageBrush(grain)
{
TileMode = System.Windows.Media.TileMode.Tile,
ViewportUnits = System.Windows.Media.BrushMappingMode.Absolute,
Viewport = new Rect(0, 0, 256, 256),
Stretch = System.Windows.Media.Stretch.None
}
});
}
contentGrid.Children.Add(root);
win.Content = DialogChrome.WrapContent(owner, contentGrid);
win.ShowDialog();
return (confirmed, closeTabs, remember);
}
/// <summary>
/// Themed "Password Required" prompt: the family dialog chrome (wordmark title bar, grain,
/// red close, Esc to cancel) around a themed PasswordBox. Returns the entered password, or
/// null if the user canceled / closed the dialog.
/// </summary>
public static string? PromptPassword(Window? owner, string filename)
{
string? result = null;
var win = new Window { Width = 380, SizeToContent = SizeToContent.Height };
DialogChrome.Configure(win, owner, fade: true);
void CloseCancel() { result = null; win.Close(); }
var body = new StackPanel();
// Message: "<file>" is password protected.
var msg = new TextBlock { Foreground = R("TextBrush"), FontSize = 13, TextWrapping = TextWrapping.Wrap };
msg.Inlines.Add(new System.Windows.Documents.Run($"“{System.IO.Path.GetFileName(filename)}” ") { FontWeight = FontWeights.SemiBold });
msg.Inlines.Add(new System.Windows.Documents.Run("is password protected."));
body.Children.Add(new Border { Padding = new Thickness(20, 4, 20, 10), Child = msg });
var pw = UiKit.PasswordField();
body.Children.Add(new Border { Padding = new Thickness(20, 0, 20, 4), Child = pw });
var openBtn = UiKit.Make("Open", accent: true);
openBtn.IsDefault = true;
openBtn.Click += (_, _2) => { result = pw.Password; win.Close(); };
var cancelBtn = UiKit.Make("Cancel", accent: false);
cancelBtn.IsCancel = true;
cancelBtn.Click += (_, _2) => CloseCancel();
body.Children.Add(new Border { Padding = new Thickness(16, 12, 16, 16), Child = UiKit.ButtonRow(openBtn, cancelBtn) });
// Enter anywhere in the field submits.
pw.KeyDown += (_, e) => { if (e.Key == Key.Enter) { result = pw.Password; win.Close(); } };
win.Content = DialogChrome.Frame(win, owner, "KillerPDF", CloseCancel, body);
win.Loaded += (_, _2) => pw.Focus();
win.ShowDialog();
return result;
}
}
}
+55
View File
@@ -0,0 +1,55 @@
using System;
namespace KillerPDF.Controls
{
// Row and place models for FileDialog.
public sealed class PickerPlace(string label, string path, bool pinned = false)
{
public string Label { get; } = label;
public string Path { get; } = path;
/// <summary>True for a user-pinned (removable) entry; drives are dynamic and never pinned.</summary>
public bool Pinned { get; } = pinned;
/// <summary>Real shell icon, resolved by PATH - a drive shows its true icon (USB,
/// network, optical) and a special folder its own. Cached in ShellIcons.</summary>
public System.Windows.Media.ImageSource? Icon => Services.ShellIcons.Place(Path);
}
// One row in the folder pane: a subfolder or a (dimmed, non-pickable) file.
public sealed class PickerEntry(string name, string fullPath, bool isFolder, long sizeBytes, DateTime modified)
{
private static readonly string GlyphFolder = ((char)0xE8B7).ToString();
private static readonly string GlyphFile = ((char)0xE8A5).ToString();
public string Name { get; } = name;
public string FullPath { get; } = fullPath;
public bool IsFolder { get; } = isFolder;
public long SizeBytes { get; } = sizeBytes;
public DateTime Modified { get; } = modified;
public string Glyph => IsFolder ? GlyphFolder : GlyphFile;
/// <summary>Shell icon, 16px, for the list and details rows. Cached by extension, so
/// binding it per row is cheap.</summary>
public System.Windows.Media.ImageSource? Icon
=> Services.ShellIcons.Small(FullPath, IsFolder);
/// <summary>Shell icon, 32px, for the icon grid.</summary>
public System.Windows.Media.ImageSource? IconLarge
=> Services.ShellIcons.Large(FullPath, IsFolder);
public string SizeLabel => IsFolder ? string.Empty : FormatSize(SizeBytes);
public string ModifiedLabel => Modified == DateTime.MinValue ? string.Empty : Modified.ToString("yyyy-MM-dd HH:mm");
private static string FormatSize(long b)
{
if (b < 1024) return b + " B";
double kb = b / 1024.0;
if (kb < 1024) return kb.ToString("0") + " KB";
double mb = kb / 1024.0;
if (mb < 1024) return mb.ToString("0.0") + " MB";
return (mb / 1024.0).ToString("0.00") + " GB";
}
}
}
+804
View File
@@ -0,0 +1,804 @@
<!-- ============================================================
File picker styles, ported verbatim from Killendar Controls.xaml (the family
reference). Emitted in the source file order, which already satisfies every
StaticResource forward reference.
The key list is derived from BOTH the picker XAML and its code-behind: ApplyView()
resolves PanelListCols / PanelIconGrid / IconTemplate / DetailsTemplate through
FindResource(), so a scan of XAML references alone misses them and the dialog dies
at runtime the first time the view mode is applied.
Merged by Controls/FileDialog.xaml, NOT App.xaml: LocaleManager owns application
merged-dictionary slot [2] and removes it outright for English.
============================================================ -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:KillerPDF.Controls"
xmlns:kui="clr-namespace:KillerPDF.Controls">
<Style x:Key="OutlineButton" TargetType="Button">
<!-- Transparent at rest, NOT RowSelectedBrush. Filled with RowSelectedBrush while its text
was OutlineBtnBrush, both resolve to the accent on several palettes - so the confirm
button was a solid block of color with its caption invisible inside it. -->
<Setter Property="Background" Value="{DynamicResource OutlineFaceBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource OutlineTextBrush}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="14,5"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="BorderBrush" Value="{DynamicResource OutlineRestBrush}"/>
<Setter Property="Cursor" Value="Hand"/>
<!-- WPF's dotted focus rectangle draws over the caption; the template shows focus itself. -->
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<!-- Bevels are SIBLINGS of the border so they land on the button's outer edge,
not inside its 1px edge and 14,5 padding. Zero-thickness off 98SE. -->
<Grid>
<Border x:Name="border" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{DynamicResource ControlCornerRadius}" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border x:Name="bevelLight" IsHitTestVisible="False" BorderBrush="{DynamicResource BevelLightBrush}" BorderThickness="{DynamicResource ButtonBevelLightThickness}"/>
<Border x:Name="bevelDark" IsHitTestVisible="False" BorderBrush="{DynamicResource BevelDarkBrush}" BorderThickness="{DynamicResource ButtonBevelDarkThickness}"/>
</Grid>
<ControlTemplate.Triggers>
<!-- Order matters: IsPressed must come after IsMouseOver to win. -->
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background" Value="{DynamicResource OutlineHoverBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource OutlineHoverTextBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border" Property="Background" Value="{DynamicResource OutlinePressedBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource OutlineTextBrush}"/>
<!-- Bevel inverts on press: sunken, the classic behavior. -->
<Setter TargetName="bevelLight" Property="BorderBrush" Value="{DynamicResource BevelDarkBrush}"/>
<Setter TargetName="bevelDark" Property="BorderBrush" Value="{DynamicResource BevelLightBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="SurfaceButton" TargetType="Button">
<Setter Property="Background" Value="{DynamicResource PaneBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="12,6"/>
<Setter Property="BorderThickness" Value="1"/>
<!-- ButtonEdgeBrush: a beveled theme makes it transparent so the bevel is the edge and
the flat outline does not draw a second line beside it. -->
<Setter Property="BorderBrush" Value="{DynamicResource ButtonEdgeBrush}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid>
<Border x:Name="border" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{DynamicResource ControlCornerRadius}" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border x:Name="bevelLight" IsHitTestVisible="False" BorderBrush="{DynamicResource BevelLightBrush}" BorderThickness="{DynamicResource ButtonBevelLightThickness}"/>
<Border x:Name="bevelDark" IsHitTestVisible="False" BorderBrush="{DynamicResource BevelDarkBrush}" BorderThickness="{DynamicResource ButtonBevelDarkThickness}"/>
</Grid>
<ControlTemplate.Triggers>
<!-- Order matters: IsPressed after IsMouseOver so it wins. -->
<!-- A real fill change, not an opacity fade. Opacity 0.75 on a PaneBrush
button over the dark panel is a ~9-step move - invisible in practice,
so Cancel read as dead - no hover at all. (2026-07-30) -->
<!-- Fill only, no accent border: the accent outline is the CONFIRM
button's identity (OutlineButton), and a neutral button borrowing it
on hover reads as a second confirm. (2026-07-30) -->
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background" Value="{DynamicResource RowHoverBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border" Property="Background" Value="{DynamicResource SurfaceBrush}"/>
<Setter TargetName="bevelLight" Property="BorderBrush" Value="{DynamicResource BevelDarkBrush}"/>
<Setter TargetName="bevelDark" Property="BorderBrush" Value="{DynamicResource BevelLightBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
<Setter Property="Cursor" Value="Arrow"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="DarkTextBox" TargetType="TextBox">
<!-- SurfaceBrush (#333333 on Dark), NOT BackgroundBrush. The appointment sidebar paints
nothing of its own and sits straight on the window's BackgroundBrush (#1c1c1c), so a
BackgroundBrush field was the exact same color as the panel behind it - the only thing
separating a text box from empty space was its 1px border. KillerNotes has always used
SurfaceBrush here; this is that. (2026-07-30)
It reads correctly on the other surfaces too: the dialog cards are PaneBrush (#3a3a3a),
against which a SurfaceBrush field is a step darker and reads as recessed. -->
<Setter Property="Background" Value="{DynamicResource TextFieldBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource InputBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="CaretBrush" Value="{DynamicResource TextBrush}"/>
<Setter Property="SelectionBrush" Value="{DynamicResource PrimaryBrush}"/>
<Setter Property="SelectionOpacity" Value="0.3"/>
<!-- Replaces WPF's built-in Cut/Copy/Paste menu, which is built from the framework theme
and ignores this app's ContextMenu/MenuItem styles. DynamicResource, not Static: the
menu is defined further down this file. -->
<Setter Property="ContextMenu" Value="{DynamicResource TextInputContextMenu}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="border" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{DynamicResource ControlCornerRadius}">
<!-- VerticalAlignment must follow VerticalContentAlignment. Without it the
content host fills the box top-down, so in a fixed-height row the text
line is taller than the space left by Padding and scrolls out of sight,
leaving what looks like an empty box. -->
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="BorderBrush" Value="{DynamicResource InputHoverBrush}"/>
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="border" Property="BorderBrush" Value="{DynamicResource PrimaryBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<ContextMenu x:Key="TextInputContextMenu" x:Shared="False">
<MenuItem Command="ApplicationCommands.Cut" Header="{DynamicResource Str_Ctx_Cut}" InputGestureText="Ctrl+X">
<MenuItem.Icon><TextBlock Text="&#xE8C6;" FontFamily="Segoe MDL2 Assets" FontSize="12"/></MenuItem.Icon>
</MenuItem>
<MenuItem Command="ApplicationCommands.Copy" Header="{DynamicResource Str_Ctx_Copy}" InputGestureText="Ctrl+C">
<MenuItem.Icon><TextBlock Text="&#xE8C8;" FontFamily="Segoe MDL2 Assets" FontSize="12"/></MenuItem.Icon>
</MenuItem>
<MenuItem Command="ApplicationCommands.Paste" Header="{DynamicResource Str_Ctx_Paste}" InputGestureText="Ctrl+V">
<MenuItem.Icon><TextBlock Text="&#xE77F;" FontFamily="Segoe MDL2 Assets" FontSize="12"/></MenuItem.Icon>
</MenuItem>
<Separator/>
<MenuItem Command="ApplicationCommands.SelectAll" Header="{DynamicResource Str_Ctx_SelectAll}" InputGestureText="Ctrl+A">
<MenuItem.Icon><TextBlock Text="&#xE8B3;" FontFamily="Segoe MDL2 Assets" FontSize="12"/></MenuItem.Icon>
</MenuItem>
</ContextMenu>
<!-- The places / bookmarks rail. Same template as PickerRow but tight: the rail is a
navigation list beside a folder tree, so its rows should match the tree's line height and
let you see more at once. PickerRow's 8,5 padding plus 2,1 margin made each place ~28px
tall for a 16px icon - over half the row was air. Kept separate from PickerRow so the
file list, where rows carry three columns of detail, is unaffected. -->
<Style x:Key="PickerPlaceRow" TargetType="ListBoxItem">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="bg" Background="Transparent" CornerRadius="{DynamicResource SmallCornerRadius}" Padding="6,1" Margin="2,0">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bg" Property="Background" Value="{DynamicResource RowHoverBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="bg" Property="Background" Value="{DynamicResource SelectionBg}"/>
<Setter Property="Foreground" Value="{DynamicResource SelectionFg}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="PickerRow" TargetType="ListBoxItem">
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="bg" Background="Transparent" CornerRadius="{DynamicResource ControlCornerRadius}" Padding="8,5" Margin="2,1">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bg" Property="Background" Value="{DynamicResource RowHoverBrush}"/>
</Trigger>
<!-- SelectionBg/SelectionFg, the family pair - never the raw accent.
Same treatment as a selected menu item or tab. (2026-07-30) -->
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="bg" Property="Background" Value="{DynamicResource SelectionBg}"/>
<Setter Property="Foreground" Value="{DynamicResource SelectionFg}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="PickerRowContent" TargetType="Panel">
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=ListBoxItem}}" Value="True">
<Setter Property="Effect" Value="{DynamicResource TextStroke}"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="PickerName" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Consolas"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsFolder}" Value="False">
<Setter Property="Foreground" Value="{DynamicResource MutedTextBrush}"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=ListBoxItem}}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource SelectionFg}"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="PickerMeta" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Consolas"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Foreground" Value="{DynamicResource DimTextBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=ListBoxItem}}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource SelectionFg}"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="PickerViewBtn" TargetType="Button">
<Setter Property="Width" Value="26"/>
<Setter Property="Height" Value="22"/>
<Setter Property="FontFamily" Value="Segoe MDL2 Assets"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Foreground" Value="{DynamicResource DimTextBrush}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<!-- Bevels as siblings so the raised edge is the button's own edge. This covers
the view-mode buttons AND the up-a-folder button, which share this style. -->
<Grid>
<Border x:Name="bg" Background="Transparent" CornerRadius="{DynamicResource SmallCornerRadius}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border x:Name="bevelLight" IsHitTestVisible="False" BorderBrush="{DynamicResource BevelLightBrush}" BorderThickness="{DynamicResource ButtonBevelLightThickness}"/>
<Border x:Name="bevelDark" IsHitTestVisible="False" BorderBrush="{DynamicResource BevelDarkBrush}" BorderThickness="{DynamicResource ButtonBevelDarkThickness}"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bg" Property="Background" Value="{DynamicResource RowHoverBrush}"/>
</Trigger>
<!-- SelectionBg/SelectionFg, a matched pair. It was RowSelectedBrush with a
PrimaryBrush glyph - on the palettes where those are both the accent,
the active view button was an accent square with an accent icon on it,
so the selected view was the one you could not see. The bevel also
inverts, so the active button reads as pressed in. -->
<Trigger Property="Tag" Value="on">
<Setter TargetName="bg" Property="Background" Value="{DynamicResource SelectionBg}"/>
<Setter Property="Foreground" Value="{DynamicResource SelectionFg}"/>
<Setter TargetName="bevelLight" Property="BorderBrush" Value="{DynamicResource BevelDarkBrush}"/>
<Setter TargetName="bevelDark" Property="BorderBrush" Value="{DynamicResource BevelLightBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- The address-bar dropdown is the same classic arrow face used by ComboBox fields.
Keeping it here means every Open, Save and image picker receives one implementation. -->
<Style x:Key="PickerComboArrowBtn" TargetType="Button">
<Setter Property="Width" Value="{DynamicResource ComboButtonSize}"/>
<Setter Property="Height" Value="{DynamicResource ComboButtonHeight}"/>
<Setter Property="FontFamily" Value="{DynamicResource ComboChevFont}"/>
<Setter Property="FontSize" Value="8"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid>
<Border x:Name="face" Background="{DynamicResource ComboButtonBrush}"/>
<Border x:Name="bevelLight" IsHitTestVisible="False"
BorderBrush="{DynamicResource BevelLightBrush}"
BorderThickness="{DynamicResource ButtonBevelLightThickness}"/>
<Border x:Name="bevelDark" IsHitTestVisible="False"
BorderBrush="{DynamicResource BevelDarkBrush}"
BorderThickness="{DynamicResource ButtonBevelDarkThickness}"/>
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="face" Property="Background" Value="{DynamicResource ComboButtonHoverBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="bevelLight" Property="BorderBrush" Value="{DynamicResource BevelDarkBrush}"/>
<Setter TargetName="bevelDark" Property="BorderBrush" Value="{DynamicResource BevelLightBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="PickerColBtn" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource DimTextBrush}"/>
<Setter Property="FontFamily" Value="Consolas"/>
<Setter Property="FontSize" Value="10"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="Transparent" Padding="0,3">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="{DynamicResource PrimaryBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<DataTemplate x:Key="RowTemplate">
<StackPanel Orientation="Horizontal" Style="{StaticResource PickerRowContent}" Width="210">
<Image Source="{Binding Icon}" Width="16" Height="16" VerticalAlignment="Center"
Margin="0,0,8,0" SnapsToDevicePixels="True"
RenderOptions.BitmapScalingMode="NearestNeighbor"/>
<TextBlock Text="{Binding Name}" Style="{StaticResource PickerName}"/>
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="IconTemplate">
<StackPanel Style="{StaticResource PickerRowContent}" Width="84">
<Image Source="{Binding IconLarge}" Width="32" Height="32"
HorizontalAlignment="Center" Margin="0,2,0,4" SnapsToDevicePixels="True"/>
<TextBlock Text="{Binding Name}" Style="{StaticResource PickerName}"
TextWrapping="Wrap" TextAlignment="Center" MaxHeight="30"
HorizontalAlignment="Center"/>
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="DetailsTemplate">
<Grid Style="{StaticResource PickerRowContent}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="150"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<Image Source="{Binding Icon}" Width="16" Height="16" VerticalAlignment="Center"
Margin="0,0,8,0" SnapsToDevicePixels="True"
RenderOptions.BitmapScalingMode="NearestNeighbor"/>
<TextBlock Text="{Binding Name}" Style="{StaticResource PickerName}"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding SizeLabel}" Style="{StaticResource PickerMeta}" TextAlignment="Right" Margin="0,0,10,0"/>
<TextBlock Grid.Column="2" Text="{Binding ModifiedLabel}" Style="{StaticResource PickerMeta}" Margin="10,0,0,0"/>
</Grid>
</DataTemplate>
<ItemsPanelTemplate x:Key="PanelStack"><VirtualizingStackPanel/></ItemsPanelTemplate>
<ItemsPanelTemplate x:Key="PanelListCols">
<WrapPanel Orientation="Vertical" ItemHeight="26"/>
</ItemsPanelTemplate>
<ItemsPanelTemplate x:Key="PanelIconGrid">
<WrapPanel Orientation="Horizontal" ItemWidth="96" ItemHeight="76"/>
</ItemsPanelTemplate>
<kui:LastChildConverter x:Key="IsLastChild"/>
<Style x:Key="TreeExpander" TargetType="ToggleButton">
<Setter Property="Focusable" Value="False"/>
<Setter Property="Width" Value="16"/>
<Setter Property="Height" Value="16"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border Background="Transparent" Width="16" Height="16">
<Canvas Width="16" Height="16">
<Path x:Name="arrowFill" Data="M 7,3.5 L 13,7.5 L 7,11.5"
Fill="{DynamicResource BackgroundBrush}" SnapsToDevicePixels="False"/>
<Path x:Name="arrow" Data="M 8.5,3.5 L 13,7.5 L 8.5,11.5"
StrokeThickness="1.4" StrokeStartLineCap="Round" StrokeEndLineCap="Round"
Stroke="{DynamicResource DimTextBrush}" SnapsToDevicePixels="False"/>
</Canvas>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<!-- Points down, vertex landing on x=8 - the line's center. Arm length
and angle match the collapsed chevron, worked out by hand: a
RenderTransform cannot be reached by TargetName (MC4111). -->
<Setter TargetName="arrow" Property="Data" Value="M 4,5.25 L 8,9.75 L 12,5.25"/>
<Setter TargetName="arrowFill" Property="Data" Value="M 4,5.25 L 8,9.75 L 12,5.25"/>
<Setter TargetName="arrow" Property="Stroke" Value="{DynamicResource PrimaryBrush}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="arrow" Property="Stroke" Value="{DynamicResource PrimaryBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="TreeName" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Consolas"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=TreeViewItem}}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource SelectionFg}"/>
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="FolderTreeItem" TargetType="TreeViewItem">
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="Padding" Value="2,1"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TreeViewItem">
<Grid>
<Grid.ColumnDefinitions>
<!-- 16px gutter carries the expander AND the connecting lines, so
the lines land dead center on the triangle at every depth. -->
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Connecting lines. The vertical runs the full height of the node and
its children; the horizontal is the stub out to this node's icon.
For the last child the vertical is cut to an elbow (see trigger). -->
<Rectangle x:Name="VerLine" Grid.RowSpan="2" Width="1"
HorizontalAlignment="Center" VerticalAlignment="Stretch"
Fill="{DynamicResource TreeLineBrush}" SnapsToDevicePixels="True"/>
<!-- Only drawn for a node with no expander - where there IS one, the
chevron is the connector. -->
<Rectangle x:Name="HorLine" Height="1" Margin="8,0,0,0"
VerticalAlignment="Center" HorizontalAlignment="Stretch"
Visibility="Collapsed"
Fill="{DynamicResource TreeLineBrush}" SnapsToDevicePixels="True"/>
<ToggleButton x:Name="Expander" Style="{StaticResource TreeExpander}"
ClickMode="Press" VerticalAlignment="Center"
IsChecked="{Binding IsExpanded, RelativeSource={RelativeSource TemplatedParent}}"/>
<Border x:Name="Bd" Grid.Column="1" CornerRadius="{DynamicResource SmallCornerRadius}" Margin="2,0,0,0"
Background="Transparent" Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<ContentPresenter x:Name="PART_Header" ContentSource="Header"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"/>
</Border>
<ItemsPresenter x:Name="ItemsHost" Grid.Row="1" Grid.Column="1"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="False">
<Setter TargetName="ItemsHost" Property="Visibility" Value="Collapsed"/>
</Trigger>
<!-- No children means no chevron, so the elbow stub takes over as the
connector. The vertical stays either way: a leaf still belongs to
the run of siblings drawn down the gutter. -->
<Trigger Property="HasItems" Value="False">
<Setter TargetName="Expander" Property="Visibility" Value="Hidden"/>
<Setter TargetName="HorLine" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsMouseOver" SourceName="Bd" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource RowHoverBrush}"/>
</Trigger>
<!-- SelectionBg, the family pair - not KillerShell's RowSelectedBrush,
because in THIS dialog the tree node and the places row mean the same
thing and must light up the same way. -->
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SelectionBg}"/>
</Trigger>
<!-- Last sibling: cut the vertical to an elbow. Bound to the container
itself so a recycled container re-evaluates (FolderTree.cs). -->
<DataTrigger Value="True"
Binding="{Binding RelativeSource={RelativeSource Self}, Converter={StaticResource IsLastChild}}">
<Setter TargetName="VerLine" Property="VerticalAlignment" Value="Top"/>
<Setter TargetName="VerLine" Property="Height" Value="11"/>
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="BareScrollViewer" TargetType="ScrollViewer">
<Setter Property="Focusable" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<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}"
CanContentScroll="{TemplateBinding CanContentScroll}"
CanHorizontallyScroll="False"
CanVerticallyScroll="False"/>
<!-- The PART_ names are what ScrollViewer wires its own scrolling to, so
Value binds one-way and the control drives it from there. -->
<ScrollBar x:Name="PART_VerticalScrollBar" Grid.Row="0" Grid.Column="1"
Orientation="Vertical" Cursor="Arrow"
Minimum="0" Maximum="{TemplateBinding ScrollableHeight}"
ViewportSize="{TemplateBinding ViewportHeight}"
Value="{Binding VerticalOffset, Mode=OneWay, RelativeSource={RelativeSource TemplatedParent}}"
Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}"/>
<ScrollBar x:Name="PART_HorizontalScrollBar" Grid.Row="1" Grid.Column="0"
Orientation="Horizontal" Cursor="Arrow"
Minimum="0" Maximum="{TemplateBinding ScrollableWidth}"
ViewportSize="{TemplateBinding ViewportWidth}"
Value="{Binding HorizontalOffset, Mode=OneWay, RelativeSource={RelativeSource TemplatedParent}}"
Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}"/>
<!-- Row 1 / Column 1 deliberately left empty: that is the corner. -->
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="FolderTreeView" TargetType="TreeView">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="ItemContainerStyle" Value="{StaticResource FolderTreeItem}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TreeView">
<Border Background="{TemplateBinding Background}" BorderThickness="0">
<ScrollViewer Style="{StaticResource BareScrollViewer}"
Padding="{TemplateBinding Padding}"
CanContentScroll="False"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<ItemsPresenter/>
</ScrollViewer>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ============================================================ -->
<!-- Shared ComboBox chrome used by both the main-window zoom field and every file picker. -->
<!-- ============================================================ -->
<!-- Toggle button used inside the ComboBox template -->
<Style x:Key="DarkComboToggle" TargetType="ToggleButton">
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="ClickMode" Value="Press"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border Background="{TemplateBinding Background}" BorderThickness="0"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ComboBoxItem inside the dark popup -->
<Style x:Key="DarkComboItem" TargetType="ComboBoxItem">
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="Padding" Value="{DynamicResource MenuItemPadding}"/>
<Setter Property="FontFamily" Value="{DynamicResource MenuFontFamily}"/>
<Setter Property="FontSize" Value="{DynamicResource MenuFontSize}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="ItemBd"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="ItemBd" Property="Background" Value="{DynamicResource RowHoverBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="ItemBd" Property="Background" Value="{DynamicResource SelectionBg}"/>
<Setter Property="Foreground" Value="{DynamicResource SelectionFg}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- The ComboBox itself -->
<Style x:Key="DarkComboBox" TargetType="ComboBox">
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="Background" Value="{DynamicResource ComboFieldBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource MenuBorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="FontFamily" Value="{DynamicResource MenuFontFamily}"/>
<Setter Property="FontSize" Value="{DynamicResource MenuFontSize}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="ItemContainerStyle" Value="{StaticResource DarkComboItem}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<!-- Clickable area / border -->
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{DynamicResource SmallCornerRadius}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto" MinWidth="{DynamicResource ComboButtonMinWidth}"/>
</Grid.ColumnDefinitions>
<!-- Selected item text (non-editable mode) -->
<ContentPresenter x:Name="ContentSite"
Grid.Column="0"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
Margin="7,0,0,0"
VerticalAlignment="Center"
HorizontalAlignment="Left"
IsHitTestVisible="False"/>
<!-- Editable text box (shown when IsEditable=True) -->
<TextBox x:Name="PART_EditableTextBox"
Grid.Column="0"
Margin="5,0,0,0"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource TextBrush}"
FontFamily="{DynamicResource MenuFontFamily}"
FontSize="{DynamicResource MenuFontSize}"
VerticalAlignment="Center"
IsReadOnly="{TemplateBinding IsReadOnly}"
Visibility="Hidden"
Focusable="True"
SelectionBrush="{DynamicResource RowSelectedBrush}"
SelectionTextBrush="{DynamicResource PrimaryBrush}"
CaretBrush="{DynamicResource PrimaryBrush}"/>
<!-- The arrow face stretches to the field's INNER height. A fixed
26px face clipped inside the 22px toolbar zoom box, cutting its
bevel in half while looking correct in taller dialog fields. -->
<Grid Grid.Column="1" Width="{DynamicResource ComboButtonSize}"
HorizontalAlignment="Center" VerticalAlignment="Stretch"
IsHitTestVisible="False">
<Border x:Name="ComboChevFace" Background="{DynamicResource ComboButtonBrush}"/>
<Border x:Name="ComboChevLight" BorderBrush="{DynamicResource BevelLightBrush}"
BorderThickness="{DynamicResource ButtonBevelLightThickness}"/>
<Border x:Name="ComboChevDark" BorderBrush="{DynamicResource BevelDarkBrush}"
BorderThickness="{DynamicResource ButtonBevelDarkThickness}"/>
<TextBlock Text="{DynamicResource ComboChevGlyph}"
FontFamily="{DynamicResource ComboChevFont}" FontSize="8"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Grid>
<!-- Invisible toggle over the whole control -->
<ToggleButton Grid.Column="0" Grid.ColumnSpan="2"
Style="{StaticResource DarkComboToggle}"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay,
RelativeSource={RelativeSource TemplatedParent}}"/>
</Grid>
</Border>
<!-- A classic edit/combo field is a two-stage SUNKEN frame: gray/white
outside, black/control-face inside. Modern themes make these shared
pane-bevel resources transparent and zero-width. -->
<Border IsHitTestVisible="False" BorderBrush="{DynamicResource PaneBevelDarkBrush}"
BorderThickness="{DynamicResource PaneBevelLightThickness}"/>
<Border IsHitTestVisible="False" BorderBrush="{DynamicResource PaneBevelLightBrush}"
BorderThickness="{DynamicResource PaneBevelDarkThickness}"/>
<Border IsHitTestVisible="False" Margin="{DynamicResource PaneBevelInnerMargin}"
BorderBrush="{DynamicResource PaneBevelDark2Brush}"
BorderThickness="{DynamicResource PaneBevel2LightThickness}"/>
<Border IsHitTestVisible="False" Margin="{DynamicResource PaneBevelInnerMargin}"
BorderBrush="{DynamicResource PaneBevelLight2Brush}"
BorderThickness="{DynamicResource PaneBevel2DarkThickness}"/>
<!-- Dropdown popup -->
<Popup x:Name="PART_Popup"
AllowsTransparency="True"
IsOpen="{TemplateBinding IsDropDownOpen}"
Focusable="False"
PopupAnimation="Fade"
Placement="Bottom">
<Border Background="{DynamicResource ComboPopupBrush}"
BorderBrush="{DynamicResource CardBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource SmallCornerRadius}"
Padding="2"
Margin="0,2,6,6"
MinWidth="{Binding ActualWidth,
RelativeSource={RelativeSource TemplatedParent}}"
MaxHeight="{TemplateBinding MaxDropDownHeight}">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="12" ShadowDepth="2" Direction="270"
Opacity="{DynamicResource FlyoutShadowOpacity}"/>
</Border.Effect>
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<ItemsPresenter/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsEditable" Value="True">
<Setter TargetName="ContentSite" Property="Visibility" Value="Hidden"/>
<Setter TargetName="PART_EditableTextBox" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ComboFieldHoverBrush}"/>
<Setter TargetName="ComboChevFace" Property="Background" Value="{DynamicResource ComboButtonHoverBrush}"/>
</Trigger>
<Trigger Property="IsDropDownOpen" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ComboFieldHoverBrush}"/>
<Setter TargetName="ComboChevFace" Property="Background" Value="{DynamicResource ComboButtonHoverBrush}"/>
<Setter TargetName="ComboChevLight" Property="BorderBrush" Value="{DynamicResource BevelDarkBrush}"/>
<Setter TargetName="ComboChevDark" Property="BorderBrush" Value="{DynamicResource BevelLightBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
File diff suppressed because it is too large Load Diff
+302
View File
@@ -0,0 +1,302 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography.X509Certificates;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
using KillerPDF.Services.Signing;
using Microsoft.Win32;
namespace KillerPDF
{
/// <summary>
/// Themed modal dialog that cryptographically signs the open PDF with a certificate (a .pfx/.p12
/// file, or one from the Windows store) and writes a NEW signed copy. This is the real digital
/// signature - distinct from the drawn "Signature" stamp tool, which only places a picture.
/// Chrome and colors mirror PrintPreviewWindow so every KillerPDF dialog looks identical.
/// </summary>
internal sealed class SignDocumentDialog : Window
{
private readonly string _sourcePdf;
private RadioButton _fileRadio = null!;
private RadioButton _storeRadio = null!;
private TextBox _pfxBox = null!;
private PasswordBox _pwBox = null!;
private Button _browsePfx = null!;
private ComboBox _storeCombo = null!;
private TextBox _reasonBox = null!;
private TextBox _locationBox = null!;
private TextBox _contactBox = null!;
private TextBox _outputBox = null!;
private readonly List<X509Certificate2> _storeCerts = [];
// Segoe MDL2 Assets close glyph, matching the main window + print dialog chrome.
private const string CloseGlyph = "";
private static SolidColorBrush R(string key) => (SolidColorBrush)Application.Current.Resources[key];
// Localized string from the active locale dictionary (falls back to the key if missing).
private static string L(string key) => Application.Current.TryFindResource(key) as string ?? key;
public SignDocumentDialog(Window? owner, string sourcePdf)
{
_sourcePdf = sourcePdf;
Title = "KillerPDF - " + L("Str_Sign_Name");
Width = 470;
SizeToContent = SizeToContent.Height;
UseLayoutRounding = true;
DialogChrome.Configure(this, owner);
BuildUi();
}
private void BuildUi()
{
var body = new StackPanel { Margin = new Thickness(20, 6, 20, 18) };
body.Children.Add(new TextBlock
{
Text = string.Format(L("Str_Sign_Desc"), Path.GetFileName(_sourcePdf)),
Foreground = R("MutedTextBrush"), FontSize = 11, TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 0, 0, 14)
});
// --- Certificate source --------------------------------------------------------------
body.Children.Add(Label(L("Str_Sign_Certificate")));
_fileRadio = Radio(L("Str_Sign_FromFile"), true);
_storeRadio = Radio(L("Str_Sign_FromStore"), false);
_fileRadio.Checked += (_, _) => SyncSource();
_storeRadio.Checked += (_, _) => SyncSource();
body.Children.Add(_fileRadio);
var fileRow = new Grid { Margin = new Thickness(20, 2, 0, 4) };
fileRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
fileRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
_pfxBox = Field("");
_pfxBox.Margin = new Thickness(0, 0, 6, 0);
Grid.SetColumn(_pfxBox, 0);
_browsePfx = MakeButton(L("Str_Sign_Browse"), false);
_browsePfx.Click += (_, _) => BrowsePfx();
Grid.SetColumn(_browsePfx, 1);
fileRow.Children.Add(_pfxBox);
fileRow.Children.Add(_browsePfx);
body.Children.Add(fileRow);
body.Children.Add(new TextBlock { Text = L("Str_Sign_Password"), Foreground = R("MutedTextBrush"), FontSize = 11, Margin = new Thickness(20, 4, 0, 2) });
_pwBox = new PasswordBox
{
Margin = new Thickness(20, 0, 0, 10),
Background = R("BgCanvas"), Foreground = R("TextBrush"),
BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1),
CaretBrush = R("TextBrush"), Template = MakePasswordTemplate()
};
body.Children.Add(_pwBox);
body.Children.Add(_storeRadio);
_storeCombo = new ComboBox { Margin = new Thickness(20, 2, 0, 10), Height = 26 };
ApplyComboStyle(_storeCombo);
try
{
foreach (var c in WindowsCertificateStore.ListSigningCertificates())
{
_storeCerts.Add(c);
_storeCombo.Items.Add(new StoreCertificateProvider(c).DisplayName);
}
}
catch { /* store unavailable - leave empty */ }
if (_storeCombo.Items.Count > 0) _storeCombo.SelectedIndex = 0;
body.Children.Add(_storeCombo);
// --- Metadata ------------------------------------------------------------------------
body.Children.Add(Label(L("Str_Sign_Reason")));
_reasonBox = Field(""); body.Children.Add(_reasonBox);
body.Children.Add(Label(L("Str_Sign_Location")));
_locationBox = Field(""); body.Children.Add(_locationBox);
body.Children.Add(Label(L("Str_Sign_Contact")));
_contactBox = Field(""); body.Children.Add(_contactBox);
// --- Output --------------------------------------------------------------------------
body.Children.Add(Label(L("Str_Sign_SaveAs")));
var outRow = new Grid();
outRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
outRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
_outputBox = Field(DefaultOutputPath());
_outputBox.Margin = new Thickness(0, 0, 6, 0);
Grid.SetColumn(_outputBox, 0);
var browseOut = MakeButton(L("Str_Sign_Browse"), false);
browseOut.Click += (_, _) => BrowseOutput();
Grid.SetColumn(browseOut, 1);
outRow.Children.Add(_outputBox);
outRow.Children.Add(browseOut);
body.Children.Add(outRow);
// --- Buttons -------------------------------------------------------------------------
var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 16, 0, 0) };
var sign = MakeButton(L("Str_Sign_Sign"), true);
sign.Click += (_, _) => DoSign();
sign.IsDefault = true; // Enter
var cancel = MakeButton(L("Str_Sign_Cancel"), false);
cancel.Margin = new Thickness(8, 0, 0, 0);
cancel.Click += (_, _) => { DialogResult = false; Close(); };
cancel.IsCancel = true; // Esc
btnRow.Children.Add(sign);
btnRow.Children.Add(cancel);
body.Children.Add(btnRow);
SyncSource();
Content = DialogChrome.Frame(this, Owner, "KillerPDF - " + L("Str_Sign_TitleSuffix"),
() => { DialogResult = false; Close(); }, body);
}
private string DefaultOutputPath()
{
string dir = Path.GetDirectoryName(_sourcePdf) ?? "";
string name = Path.GetFileNameWithoutExtension(_sourcePdf);
return Path.Combine(dir, name + "-signed.pdf");
}
// Enable only the inputs for the selected certificate source.
private void SyncSource()
{
bool file = _fileRadio.IsChecked == true;
_pfxBox.IsEnabled = _browsePfx.IsEnabled = _pwBox.IsEnabled = file;
_storeCombo.IsEnabled = !file;
}
private void BrowsePfx()
{
var dlg = new KillerPDF.Controls.FileDialog(KillerPDF.Controls.FileDialogMode.Open)
{ Filter = L("Str_Filter_Cert") + "|*.pfx;*.p12|" + L("Str_Filter_AllFiles") + "|*.*", Title = L("Str_Sign_ChooseCert") };
if (dlg.ShowDialog(this) == true) _pfxBox.Text = dlg.FileName;
}
private void BrowseOutput()
{
var dlg = new KillerPDF.Controls.FileDialog(KillerPDF.Controls.FileDialogMode.Save)
{ Filter = L("Str_Filter_Pdf") + "|*.pdf", Title = L("Str_Sign_SaveAs"), FileName = Path.GetFileName(_outputBox.Text) };
if (dlg.ShowDialog(this) == true) _outputBox.Text = dlg.FileName;
}
private void DoSign()
{
ICertificateProvider provider;
if (_fileRadio.IsChecked == true)
{
string pfx = _pfxBox.Text?.Trim() ?? "";
if (!File.Exists(pfx)) { Warn(L("Str_Sign_NeedCertFile")); return; }
provider = new PfxFileCertificateProvider(pfx, _pwBox.Password);
}
else
{
int i = _storeCombo.SelectedIndex;
if (i < 0 || i >= _storeCerts.Count) { Warn(L("Str_Sign_NoStoreCert")); return; }
provider = new StoreCertificateProvider(_storeCerts[i]);
}
string output = _outputBox.Text?.Trim() ?? "";
if (string.IsNullOrEmpty(output)) { Warn(L("Str_Sign_NeedOutput")); return; }
X509Certificate2 cert;
try { cert = provider.GetCertificate(); }
catch (System.Security.Cryptography.CryptographicException)
{
// The raw Win32 text ("The specified network password is not correct.") is misleading -
// nothing networked is involved. Almost always a wrong password or a non-.pfx file.
Warn(L("Str_Sign_BadCert"));
return;
}
catch (Exception ex) { Warn(L("Str_Sign_CertLoadFailed") + "\n\n" + ex.Message); return; }
try
{
new PdfSigner().Sign(_sourcePdf, output, cert,
new PdfSigner.SignInfo(_reasonBox.Text ?? "", _locationBox.Text ?? "", _contactBox.Text ?? ""));
}
catch (Exception ex)
{
Warn(L("Str_Sign_Failed") + "\n\n" + ex.GetType().Name + ": " + ex.Message);
return;
}
KillerDialog.Show(this, L("Str_Dlg_SignedSavedTo") + "\n" + output, L("Str_Sign_Name"), MessageBoxButton.OK, MessageBoxImage.Information);
DialogResult = true;
Close();
}
private void Warn(string msg) => KillerDialog.Show(this, msg, L("Str_Sign_Name"), MessageBoxButton.OK, MessageBoxImage.Warning);
// ---- themed control helpers (mirroring PrintPreviewWindow) -------------------------------
private Style? FindOwnerStyle(string key) => Owner?.TryFindResource(key) as Style;
private static TextBlock Label(string text) => new()
{ Text = text, Foreground = R("TextBrush"), FontSize = 12, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 6, 0, 2) };
private RadioButton Radio(string text, bool isChecked)
{
var r = new RadioButton { Content = text, IsChecked = isChecked, GroupName = "CertSource", FontSize = 12, Margin = new Thickness(0, 4, 0, 2) };
if (FindOwnerStyle("ThemeRadio") is Style s) r.Style = s; else r.Foreground = R("TextBrush");
return r;
}
private void ApplyComboStyle(ComboBox combo)
{
if (FindOwnerStyle("DarkComboBox") is Style s) combo.Style = s;
else { combo.Foreground = R("TextBrush"); combo.BorderBrush = R("CardBorderBrush"); }
combo.Background = R("BgCanvas");
}
private TextBox Field(string text)
{
var tb = new TextBox
{
Text = text, Margin = new Thickness(0, 0, 0, 4),
Background = R("BgCanvas"), Foreground = R("TextBrush"),
BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1),
Padding = new Thickness(6, 4, 6, 4), CaretBrush = R("TextBrush"),
SelectionBrush = R("RowSelectedBrush"), SelectionTextBrush = R("TextBrush"),
Template = MakeTextBoxTemplate()
};
return tb;
}
private static ControlTemplate MakeTextBoxTemplate()
{
var b = new FrameworkElementFactory(typeof(Border));
b.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetBinding(Border.BorderBrushProperty, new Binding("BorderBrush") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetBinding(Border.BorderThicknessProperty, new Binding("BorderThickness") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetValue(Border.CornerRadiusProperty, new CornerRadius(3));
var sv = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" };
b.AppendChild(sv);
var ct = new ControlTemplate(typeof(TextBox)) { VisualTree = b };
var disabled = new Trigger { Property = UIElement.IsEnabledProperty, Value = false };
disabled.Setters.Add(new Setter(UIElement.OpacityProperty, 0.4));
ct.Triggers.Add(disabled);
return ct;
}
private static ControlTemplate MakePasswordTemplate()
{
var b = new FrameworkElementFactory(typeof(Border));
b.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetBinding(Border.BorderBrushProperty, new Binding("BorderBrush") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetBinding(Border.BorderThicknessProperty, new Binding("BorderThickness") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetValue(Border.CornerRadiusProperty, new CornerRadius(3));
b.SetValue(Border.PaddingProperty, new Thickness(6, 4, 6, 4));
var sv = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" };
b.AppendChild(sv);
var ct = new ControlTemplate(typeof(PasswordBox)) { VisualTree = b };
var disabled = new Trigger { Property = UIElement.IsEnabledProperty, Value = false };
disabled.Setters.Add(new Setter(UIElement.OpacityProperty, 0.4));
ct.Triggers.Add(disabled);
return ct;
}
private static Button MakeButton(string label, bool accent) => UiKit.Make(label, accent);
}
}
+831
View File
@@ -0,0 +1,831 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Microsoft.Win32;
namespace KillerPDF
{
/// <summary>
/// Combined "Stamp" tool, modeled on the Transform window: a live page preview on the left and an
/// options sidebar on the right with two independent, toggleable sections - Page Numbers and
/// Watermark (text or image). Apply hands a StampSpec back to the caller, which places the stamps on
/// the editable stamp layer. Re-opening (double-click a stamp) seeds the window from the saved spec.
/// </summary>
internal sealed class StampWindow : Window
{
public bool Applied { get; private set; }
public StampSpec Result { get; private set; }
private BitmapSource _pageSrc;
private double _pageWpt, _pageHpt;
private readonly int _pageCount;
private int _pageIndex;
private readonly StampSpec _spec;
private readonly bool _hadExisting; // dialog opened on a doc that already has stamps (#145)
// Renders an arbitrary page for the preview stepper: returns that page's bitmap + size in points.
private readonly Func<int, (BitmapSource? src, double wpt, double hpt)>? _pageProvider;
private TextBlock _pageNavLabel = null!;
private Button _prevArrow = null!, _nextArrow = null!;
private System.Windows.Threading.DispatcherTimer? _navRenderTimer;
private readonly Image _preview = new()
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(24),
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 14, ShadowDepth = 3, Direction = 270, Opacity = 0.4 }
};
private readonly Canvas _overlay = new() { IsHitTestVisible = false, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
private FrameworkElement _previewArea = null!;
private Button _applyBtn = null!;
private readonly System.Windows.Threading.DispatcherTimer _previewTimer;
// Page-number controls
private CheckBox _numEnable = null!;
private CheckBox _numMirror = null!;
private TextBox _numStart = null!, _numFormat = null!, _numSize = null!, _numRange = null!;
private ComboBox _numPos = null!;
private Border _numSwatch = null!;
private Color _numColor;
private StackPanel _numBody = null!;
// Watermark controls
private CheckBox _wmEnable = null!;
private RadioButton _wmTextRadio = null!, _wmImageRadio = null!;
private TextBox _wmText = null!, _wmSize = null!, _wmRange = null!;
private ComboBox _wmPos = null!, _wmFont = null!;
private Slider _wmAngle = null!, _wmOpacity = null!, _wmScale = null!;
private Border _wmSwatch = null!;
private Color _wmColor;
private string? _wmImagePath;
private BitmapImage? _wmImageSrc;
private TextBlock _wmImageLabel = null!;
private StackPanel _wmBody = null!, _wmTextPanel = null!, _wmImagePanel = null!;
private readonly Style? _darkSlider, _darkCombo;
private static SolidColorBrush R(string key) => (SolidColorBrush)Application.Current.Resources[key];
private static string S(string key) => Application.Current.TryFindResource(key) as string ?? key;
// (resource key, horizontal 0/1/2, vertical 0 top / 1 middle / 2 bottom)
private static readonly (string key, int h, int v)[] Positions =
[
("Str_Pos_BottomCenter", 1, 2), ("Str_Pos_BottomRight", 2, 2), ("Str_Pos_BottomLeft", 0, 2),
("Str_Pos_TopCenter", 1, 0), ("Str_Pos_TopRight", 2, 0), ("Str_Pos_TopLeft", 0, 0),
("Str_Pos_Center", 1, 1), ("Str_Pos_Custom", -1, -1)
];
public StampWindow(Window owner, BitmapSource pageSrc, double pageWpt, double pageHpt,
int pageCount, int pageIndex, StampSpec? existing,
Func<int, (BitmapSource? src, double wpt, double hpt)>? pageProvider = null)
{
_pageSrc = pageSrc;
_pageWpt = pageWpt;
_pageHpt = pageHpt;
_pageCount = pageCount;
_pageIndex = pageIndex;
_pageProvider = pageProvider;
_hadExisting = existing is not null; // #145: clearing existing stamps must stay applyable
_spec = existing?.Clone() ?? new StampSpec { NumbersEnabled = true };
Result = _spec;
Title = "KillerPDF - " + S("Str_Stamp_Suffix");
Width = 980;
Height = 720;
MinWidth = 680;
MinHeight = 480;
DialogChrome.Configure(this, owner, resizable: true);
_darkSlider = owner.TryFindResource("DarkSlider") as Style;
_darkCombo = owner.TryFindResource("DarkComboBox") as Style;
// Borrow the main window's themed scrollbar so the sidebar scroller isn't the OS-white default.
if (owner.TryFindResource(typeof(System.Windows.Controls.Primitives.ScrollBar)) is Style sbStyle)
Resources[typeof(System.Windows.Controls.Primitives.ScrollBar)] = sbStyle;
_numColor = _spec.NumColor;
_wmColor = _spec.WmColor;
_wmImagePath = _spec.WmImagePath;
_previewTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(40) };
_previewTimer.Tick += (_, _2) => { _previewTimer.Stop(); RenderPreview(); };
BuildUi(owner);
LoadWatermarkImage();
UpdateEnabledStates();
RenderPreview();
}
private void Schedule() { _previewTimer.Stop(); _previewTimer.Start(); }
private void BuildUi(Window owner)
{
var root = new DockPanel();
// ---- Right sidebar ----
// Small right padding so the always-on scrollbar tucks near the window edge; the footer and
// scrolled content get their own right inset so nothing sits under the bar.
var sidebar = new Border { Width = 300, Background = Brushes.Transparent, Padding = new Thickness(16, 8, 4, 14) };
DockPanel.SetDock(sidebar, Dock.Right);
var side = new DockPanel();
// Docked footer: Reset all link above a right-aligned Cancel / Apply row. Right inset keeps the
// buttons off the reserved scrollbar gutter.
var bottom = new StackPanel { Margin = new Thickness(0, 10, 12, 0) };
var resetLink = UiKit.LinkLabel(S("Str_Tf_ResetAll"), ResetAll);
resetLink.Margin = new Thickness(0, 0, 0, 8);
bottom.Children.Add(resetLink);
var actionRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
var cancelBtn = UiKit.Make(S("Str_Tf_Cancel"), false);
cancelBtn.Click += (_, _2) => { Applied = false; Close(); };
cancelBtn.IsCancel = true; // Esc
cancelBtn.Margin = new Thickness(0, 0, 8, 0);
actionRow.Children.Add(cancelBtn);
_applyBtn = UiKit.Make(S("Str_Tf_Apply"), true);
_applyBtn.Click += (_, _2) => CommitAndClose();
_applyBtn.IsDefault = true; // Enter
actionRow.Children.Add(_applyBtn);
bottom.Children.Add(actionRow);
DockPanel.SetDock(bottom, Dock.Bottom);
side.Children.Add(bottom);
// Keep fields and section headers clear of the always-reserved scrollbar gutter.
var stack = new StackPanel { Margin = new Thickness(0, 0, 8, 0) };
stack.Children.Add(BuildWatermarkSection());
stack.Children.Add(Divider());
stack.Children.Add(BuildNumbersSection());
// Scrollbar is ALWAYS reserved (Visible, not Auto) so the content never shifts left when it
// appears. This is the rule for these sidebar windows.
var scroller = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Visible, Content = stack };
side.Children.Add(scroller);
sidebar.Child = side;
root.Children.Add(sidebar);
// ---- Left preview ----
var previewWrap = new Border
{
BorderThickness = new Thickness(1),
CornerRadius = UiKit.RadControl,
Margin = new Thickness(8, 4, 8, 12),
ClipToBounds = true
};
previewWrap.SetResourceReference(Border.BackgroundProperty, "BgCanvas");
previewWrap.SetResourceReference(Border.BorderBrushProperty, "PaneBorderBrush");
// The page image (row 0) and the stepper (row 1) live in separate rows so the stepper sits
// BELOW the page instead of overlapping it - same row layout as the print preview.
var previewLayout = new Grid();
previewLayout.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
previewLayout.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var imageHost = new Grid();
AddGrain(imageHost, owner, 0.05, cornerRadius: 0);
RenderOptions.SetBitmapScalingMode(_preview, BitmapScalingMode.HighQuality);
_preview.Source = _pageSrc;
imageHost.Children.Add(_preview);
imageHost.Children.Add(_overlay);
Grid.SetRow(imageHost, 0);
previewLayout.Children.Add(imageHost);
previewWrap.Child = previewLayout;
_previewArea = imageHost; // size the page against the image row only, never the stepper row
// Page stepper: the wheel over the preview (or the arrows) walks pages so you can preview the
// stamp on any page - the per-page number and range checks update as you go. Only when the caller
// supplies a page provider and there's more than one page.
if (_pageProvider != null && _pageCount > 1)
{
var nav = BuildPageNav();
Grid.SetRow(nav, 1);
previewLayout.Children.Add(nav);
previewWrap.PreviewMouseWheel += (_, e) =>
{
int notches = Math.Max(1, Math.Abs(e.Delta) / 120);
StepPage(e.Delta < 0 ? notches : -notches);
e.Handled = true;
};
}
_previewArea.SizeChanged += (_, _2) => { SizePreviewImage(); Schedule(); };
// Family shadow under the content pane, like the main window (flat on 98SE).
root.Children.Add(UiKit.PaneWithShadow(previewWrap));
Content = DialogChrome.Frame(this, Owner, "KillerPDF - " + S("Str_Stamp_Suffix"), () => { Applied = false; Close(); }, root);
// Esc-to-close is wired by DialogChrome.Frame; Enter commits.
KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitAndClose(); };
}
private static void AddGrain(Grid host, Window owner, double fallback, double cornerRadius)
{
var grain = (owner as MainWindow)?.GrainTexture;
if (grain == null) return;
double op = Application.Current.Resources["GrainOpacity"] is double go ? go : fallback;
host.Children.Add(new Border
{
CornerRadius = new CornerRadius(cornerRadius), IsHitTestVisible = false, Opacity = op,
Background = new ImageBrush(grain) { TileMode = TileMode.Tile, ViewportUnits = BrushMappingMode.Absolute, Viewport = new Rect(0, 0, 256, 256), Stretch = Stretch.None }
});
}
// ---------- Page Numbers section ----------
private FrameworkElement BuildNumbersSection()
{
var wrap = new StackPanel();
_numBody = new StackPanel { Margin = new Thickness(0, 4, 0, 0) };
_numEnable = SectionToggle(S("Str_Stamp_SecNumbers"), _spec.NumbersEnabled);
_numEnable.Checked += (_, _2) => { UpdateEnabledStates(); Schedule(); };
_numEnable.Unchecked += (_, _2) => { UpdateEnabledStates(); Schedule(); };
wrap.Children.Add(SectionHeaderRow(_numEnable, _numBody));
_numBody.Children.Add(UiKit.GroupLabel(S("Str_Stamp_StartAt")));
_numStart = UiKit.Field();
_numStart.Text = _spec.StartNumber.ToString();
_numStart.Margin = new Thickness(0, 0, 0, 8);
_numStart.TextChanged += (_, _2) => Schedule();
_numBody.Children.Add(_numStart);
_numBody.Children.Add(UiKit.GroupLabel(S("Str_Stamp_Format")));
_numFormat = UiKit.Field();
_numFormat.Text = _spec.Format;
_numFormat.TextChanged += (_, _2) => Schedule();
_numBody.Children.Add(_numFormat);
_numBody.Children.Add(new TextBlock { Text = S("Str_Stamp_Hint"), Foreground = R("MutedTextBrush"), FontSize = 11, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 2, 0, 0) });
_numBody.Children.Add(new TextBlock { Text = S("Str_Stamp_Hint2"), Foreground = R("MutedTextBrush"), FontSize = 11, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 8) });
_numBody.Children.Add(SliderBoxRow(S("Str_Stamp_FontSize"), 6, 96, _spec.NumFontPt, out _, out _numSize));
_numBody.Children.Add(ColorRow(S("Str_Stamp_Color"), _numColor, out _numSwatch, c => { _numColor = c; Schedule(); }));
_numBody.Children.Add(UiKit.GroupLabel(S("Str_Stamp_Pages")));
_numRange = UiKit.Field();
_numRange.Text = _spec.NumRange;
_numRange.ToolTip = S("Str_Crop_RangeTip");
_numRange.Margin = new Thickness(0, 0, 0, 8);
_numRange.TextChanged += (_, _2) => Schedule();
_numBody.Children.Add(_numRange);
// Position is the last page-number option (it's the least-changed setting).
_numBody.Children.Add(UiKit.GroupLabel(S("Str_Stamp_Position")));
_numPos = MakePosCombo(_spec.NumPosH, _spec.NumPosV);
_numPos.SelectionChanged += (_, _2) => { UpdateMirrorEnabled(); Schedule(); };
_numBody.Children.Add(_numPos);
_numMirror = UiKit.CheckBox(S("Str_Stamp_Mirror"));
_numMirror.IsChecked = _spec.NumMirror;
_numMirror.Margin = new Thickness(0, 6, 0, 0);
_numMirror.Checked += (_, _2) => Schedule();
_numMirror.Unchecked += (_, _2) => Schedule();
_numBody.Children.Add(_numMirror);
UpdateMirrorEnabled();
wrap.Children.Add(_numBody);
return wrap;
}
// ---------- Watermark section ----------
private FrameworkElement BuildWatermarkSection()
{
var wrap = new StackPanel();
_wmBody = new StackPanel { Margin = new Thickness(0, 4, 0, 0) };
_wmEnable = SectionToggle(S("Str_Stamp_SecWatermark"), _spec.WmEnabled);
_wmEnable.Checked += (_, _2) => { UpdateEnabledStates(); Schedule(); };
_wmEnable.Unchecked += (_, _2) => { UpdateEnabledStates(); Schedule(); };
wrap.Children.Add(SectionHeaderRow(_wmEnable, _wmBody));
// Type: text vs image (clean UiKit radios line up with the section content directly).
var typeRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 4, 0, 6) };
_wmTextRadio = MakeRadio(S("Str_Stamp_WmText"), !_spec.WmIsImage);
_wmTextRadio.Margin = new Thickness(0, 0, 14, 0);
_wmImageRadio = MakeRadio(S("Str_Stamp_WmImage"), _spec.WmIsImage);
_wmTextRadio.Checked += (_, _2) => { UpdateEnabledStates(); Schedule(); };
_wmImageRadio.Checked += (_, _2) => { UpdateEnabledStates(); Schedule(); };
typeRow.Children.Add(_wmTextRadio);
typeRow.Children.Add(_wmImageRadio);
_wmBody.Children.Add(typeRow);
// Text sub-panel
_wmTextPanel = new StackPanel();
_wmTextPanel.Children.Add(UiKit.GroupLabel(S("Str_Stamp_WmTextLabel")));
_wmText = UiKit.Field();
_wmText.Text = _spec.WmText;
_wmText.Margin = new Thickness(0, 0, 0, 8);
_wmText.TextChanged += (_, _2) => Schedule();
_wmTextPanel.Children.Add(_wmText);
var wmFontRow = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 0, 8) };
wmFontRow.Children.Add(new TextBlock { Text = S("Str_Bar_Font"), Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 11, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) });
_wmFont = new ComboBox { Width = 188, Height = 26, MaxDropDownHeight = 320, VerticalAlignment = VerticalAlignment.Center };
if (_darkCombo != null) _wmFont.Style = _darkCombo; else { _wmFont.Background = R("BgCanvas"); _wmFont.Foreground = R("TextBrush"); }
foreach (var fn in MainWindow.SystemFontNames) _wmFont.Items.Add(fn);
_wmFont.SelectedItem = _spec.WmFont;
_wmFont.SelectionChanged += (_, _2) => Schedule();
wmFontRow.Children.Add(_wmFont);
_wmTextPanel.Children.Add(wmFontRow);
_wmTextPanel.Children.Add(SliderBoxRow(S("Str_Stamp_FontSize"), 12, 200, _spec.WmFontPt, out _, out _wmSize));
_wmTextPanel.Children.Add(ColorRow(S("Str_Stamp_Color"), _wmColor, out _wmSwatch, c => { _wmColor = c; Schedule(); }));
_wmBody.Children.Add(_wmTextPanel);
// Image sub-panel: filename fills the left, the Choose button sits right-aligned across from it.
_wmImagePanel = new StackPanel();
var imgRow = new DockPanel { Margin = new Thickness(0, 0, 0, 8) };
var chooseBtn = UiKit.Make(S("Str_Stamp_ChooseImage"), false);
chooseBtn.Click += (_, _2) => ChooseImage();
DockPanel.SetDock(chooseBtn, Dock.Right);
imgRow.Children.Add(chooseBtn);
_wmImageLabel = new TextBlock { Text = System.IO.Path.GetFileName(_wmImagePath ?? ""), Foreground = R("MutedTextBrush"), FontSize = 11, TextWrapping = TextWrapping.Wrap, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) };
imgRow.Children.Add(_wmImageLabel);
_wmImagePanel.Children.Add(imgRow);
_wmImagePanel.Children.Add(SliderBoxRow(S("Str_Stamp_Scale"), 10, 200, _spec.WmScale * 100, out _wmScale, out _));
_wmBody.Children.Add(_wmImagePanel);
// Shared watermark controls
_wmBody.Children.Add(SliderBoxRow(S("Str_Stamp_Angle"), -90, 90, _spec.WmAngle, out _wmAngle, out _));
_wmBody.Children.Add(SliderBoxRow(S("Str_Stamp_Opacity"), 5, 100, _spec.WmOpacity * 100, out _wmOpacity, out _));
_wmBody.Children.Add(UiKit.GroupLabel(S("Str_Stamp_Position")));
_wmPos = MakePosCombo(_spec.WmPosH, _spec.WmPosV);
_wmPos.SelectionChanged += (_, _2) => Schedule();
_wmBody.Children.Add(_wmPos);
_wmBody.Children.Add(UiKit.GroupLabel(S("Str_Stamp_Pages")));
_wmRange = UiKit.Field();
_wmRange.Text = _spec.WmRange;
_wmRange.ToolTip = S("Str_Crop_RangeTip");
_wmRange.TextChanged += (_, _2) => Schedule();
_wmBody.Children.Add(_wmRange);
wrap.Children.Add(_wmBody);
return wrap;
}
// ---------- shared builders ----------
private CheckBox SectionToggle(string text, bool on)
{
var cb = UiKit.CheckBox(text);
cb.IsChecked = on;
cb.FontSize = 13;
cb.FontWeight = FontWeights.SemiBold;
cb.VerticalAlignment = VerticalAlignment.Center;
return cb;
}
// Collapsible section header. The enable checkbox itself expands (checked) or collapses (unchecked)
// the body; the chevron is just a non-clickable indicator of that state.
private FrameworkElement SectionHeaderRow(CheckBox enable, StackPanel body)
{
var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 8, 0, 0) };
var chevron = new TextBlock
{
FontSize = 12, Foreground = R("MutedTextBrush"),
Width = 14, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0)
};
void Sync()
{
bool on = enable.IsChecked == true;
body.Visibility = on ? Visibility.Visible : Visibility.Collapsed;
chevron.Text = on ? "▾" : "▸"; // down when expanded, right when collapsed
}
enable.Checked += (_, _2) => Sync();
enable.Unchecked += (_, _2) => Sync();
Sync();
row.Children.Add(chevron);
row.Children.Add(enable);
return row;
}
// A slider paired with a small numeric input box (two-way synced), e.g. font size.
private FrameworkElement SliderBoxRow(string label, double min, double max, double value, out Slider slider, out TextBox box)
{
var panel = new StackPanel { Margin = new Thickness(0, 2, 0, 8) };
panel.Children.Add(UiKit.GroupLabel(label));
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var s = new Slider { Minimum = min, Maximum = max, Value = Math.Max(min, Math.Min(max, value)), SmallChange = 1, LargeChange = 4, VerticalAlignment = VerticalAlignment.Center };
if (_darkSlider != null) s.Style = _darkSlider;
var b = UiKit.Field(46);
b.Text = ((int)Math.Round(value)).ToString();
b.Margin = new Thickness(8, 0, 0, 0);
bool guard = false;
s.ValueChanged += (_, _2) => { if (guard) return; guard = true; b.Text = ((int)Math.Round(s.Value)).ToString(); guard = false; Schedule(); };
b.TextChanged += (_, _2) => { if (guard) return; if (double.TryParse(b.Text, out double d)) { guard = true; s.Value = Math.Max(min, Math.Min(max, d)); guard = false; Schedule(); } };
Grid.SetColumn(s, 0); Grid.SetColumn(b, 1);
grid.Children.Add(s); grid.Children.Add(b);
panel.Children.Add(grid);
slider = s; box = b;
return panel;
}
private ComboBox MakePosCombo(int h, int v)
{
var combo = new ComboBox { Margin = new Thickness(0, 0, 0, 8), Height = 26 };
if (_darkCombo != null) combo.Style = _darkCombo; else { combo.Background = R("BgCanvas"); combo.Foreground = R("TextBrush"); }
int sel = 0;
for (int i = 0; i < Positions.Length; i++)
{
combo.Items.Add(S(Positions[i].key));
if (Positions[i].h == h && Positions[i].v == v) sel = i;
}
combo.SelectedIndex = sel;
return combo;
}
private RadioButton MakeRadio(string text, bool isChecked)
{
var rb = UiKit.Radio(text);
rb.IsChecked = isChecked;
return rb;
}
private FrameworkElement ColorRow(string label, Color initial, out Border swatch, Action<Color> onPick)
{
var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 8), VerticalAlignment = VerticalAlignment.Center };
row.Children.Add(new TextBlock { Text = label, Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 11, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) });
var sw = new Border
{
Width = 44, Height = 22, CornerRadius = UiKit.RadControl,
BorderBrush = R("CardBorderBrush"), BorderThickness = new Thickness(1),
Background = new SolidColorBrush(initial), SnapsToDevicePixels = true
};
// The swatch is a real Button (chrome-free template) so the click is rock-solid - a plain
// Border's MouseLeftButtonUp was unreliable here, which is why the color never updated.
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
var btn = new Button
{
Content = sw, Cursor = Cursors.Hand, Focusable = false,
Background = Brushes.Transparent, BorderThickness = new Thickness(0), Padding = new Thickness(0),
Template = new ControlTemplate(typeof(Button)) { VisualTree = cp }
};
btn.Click += (_, _2) =>
{
var current = sw.Background is SolidColorBrush b ? b.Color : initial;
var dlg = new ColorPickerDialog(this, current);
dlg.ShowDialog();
// Apply SelectedColor unconditionally rather than gating on DialogResult: opening the picker
// as a nested dialog from this modal window + the fade-close makes ShowDialog return false even
// on OK. On Cancel, SelectedColor is still the original color, so this is harmless.
sw.Background = new SolidColorBrush(dlg.SelectedColor);
onPick(dlg.SelectedColor);
};
swatch = sw;
row.Children.Add(btn);
return row;
}
private FrameworkElement Divider() => new Border { Height = 1, Background = R("CardBorderBrush"), Opacity = 0.6, Margin = new Thickness(0, 12, 0, 12) };
private void UpdateEnabledStates()
{
if (_wmTextPanel != null) _wmTextPanel.Visibility = _wmImageRadio.IsChecked == true ? Visibility.Collapsed : Visibility.Visible;
if (_wmImagePanel != null) _wmImagePanel.Visibility = _wmImageRadio.IsChecked == true ? Visibility.Visible : Visibility.Collapsed;
// Nothing to apply unless at least one section is enabled - EXCEPT when the document
// already has stamps: applying with both sections off is how they are removed (#145).
if (_applyBtn != null) _applyBtn.IsEnabled = _hadExisting
|| _numEnable?.IsChecked == true || _wmEnable?.IsChecked == true;
}
// Mirroring only makes sense for a left/right position, so gray it out on a centered one.
private void UpdateMirrorEnabled()
{
if (_numMirror == null || _numPos == null) return;
_numMirror.IsEnabled = Positions[Math.Max(0, _numPos.SelectedIndex)].h != 1;
}
private void ChooseImage()
{
var ofd = new KillerPDF.Controls.FileDialog(KillerPDF.Controls.FileDialogMode.Open)
{ Filter = "Images|*.png;*.jpg;*.jpeg;*.bmp;*.gif|All files|*.*", ShowImagePreview = true };
if (ofd.ShowDialog(this) == true)
{
_wmImagePath = ofd.FileName;
_wmImageLabel.Text = System.IO.Path.GetFileName(_wmImagePath);
LoadWatermarkImage();
Schedule();
}
}
private void LoadWatermarkImage()
{
_wmImageSrc = null;
if (string.IsNullOrEmpty(_wmImagePath) || !System.IO.File.Exists(_wmImagePath)) return;
try
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.UriSource = new Uri(_wmImagePath!);
bmp.EndInit();
bmp.Freeze();
_wmImageSrc = bmp;
}
catch { _wmImageSrc = null; }
}
// ---------- preview ----------
// ---------- Preview page stepper ----------
private FrameworkElement BuildPageNav()
{
_prevArrow = MakeNavArrow("", () => GoToPage(_pageIndex - 1)); // ChevronLeft
_nextArrow = MakeNavArrow("", () => GoToPage(_pageIndex + 1)); // ChevronRight
_pageNavLabel = new TextBlock
{
FontFamily = UiKit.UiFont, FontSize = 12,
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(12, 0, 12, 0)
};
_pageNavLabel.SetResourceReference(TextBlock.ForegroundProperty, "TextBrush");
var row = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 6, 0, 8)
};
row.Children.Add(_prevArrow);
row.Children.Add(_pageNavLabel);
row.Children.Add(_nextArrow);
UpdatePageNav();
return row;
}
// Same chrome as the print preview stepper (UiKit.Make), so the two windows share one button style.
private Button MakeNavArrow(string glyph, Action onClick)
{
var b = UiKit.Make(glyph, false);
b.FontFamily = UiKit.IconFont;
b.FontSize = 12;
b.Click += (_, _2) => onClick();
return b;
}
// Button clicks step one page and render immediately.
private void GoToPage(int idx)
{
if (_pageProvider == null) return;
idx = Math.Max(0, Math.Min(_pageCount - 1, idx));
if (idx == _pageIndex) return;
_pageIndex = idx;
UpdatePageNav();
RenderCurrentPage();
}
// Wheel stepping advances the page number (and arrow states) instantly and defers the heavy page
// render until the wheel settles, so a fast flick scrolls quickly instead of blocking on each
// page's rasterization.
private void StepPage(int delta)
{
if (_pageProvider == null) return;
int idx = Math.Max(0, Math.Min(_pageCount - 1, _pageIndex + delta));
if (idx == _pageIndex) return;
_pageIndex = idx;
UpdatePageNav();
_navRenderTimer ??= MakeNavRenderTimer();
_navRenderTimer.Stop();
_navRenderTimer.Start();
}
private System.Windows.Threading.DispatcherTimer MakeNavRenderTimer()
{
var t = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(90) };
t.Tick += (_, _2) => { t.Stop(); RenderCurrentPage(); };
return t;
}
private void RenderCurrentPage()
{
if (_pageProvider == null) return;
var (src, wpt, hpt) = _pageProvider(_pageIndex);
if (src == null) return;
_pageSrc = src; _pageWpt = wpt; _pageHpt = hpt;
_preview.Source = src;
RenderPreview();
}
private void UpdatePageNav()
{
if (_pageNavLabel == null) return;
_pageNavLabel.Text = string.Format(S("Str_PageOf"), _pageIndex + 1, _pageCount);
_prevArrow.IsEnabled = _pageIndex > 0;
_nextArrow.IsEnabled = _pageIndex < _pageCount - 1;
}
private void SizePreviewImage()
{
if (_previewArea == null) return;
double availW = Math.Max(1, _previewArea.ActualWidth - 48);
double availH = Math.Max(1, _previewArea.ActualHeight - 48);
double ar = _pageHpt > 0 ? _pageWpt / _pageHpt : (_pageSrc.PixelWidth / (double)_pageSrc.PixelHeight);
double w = availW, h = w / ar;
if (h > availH) { h = availH; w = h * ar; }
_preview.Width = w;
_preview.Height = h;
_overlay.Width = w;
_overlay.Height = h;
}
private static HashSet<int> ParseRange(string range, int pageCount)
{
var set = new HashSet<int>();
if (string.IsNullOrWhiteSpace(range))
{
for (int i = 0; i < pageCount; i++) set.Add(i);
return set;
}
foreach (var part in range.Split(','))
{
var p = part.Trim();
if (p.Length == 0) continue;
int dash = p.IndexOf('-');
if (dash > 0)
{
if (int.TryParse(p[..dash].Trim(), out int a) && int.TryParse(p[(dash + 1)..].Trim(), out int b))
{
// Clamp the ends rather than testing each i: an unclamped "1-2147483647" wrapped
// i++ to int.MinValue at the top and never terminated. Same fix as ParseRange.
int lo = Math.Max(1, Math.Min(a, b)), hi = Math.Min(pageCount, Math.Max(a, b));
for (int i = lo; i <= hi; i++) set.Add(i - 1);
}
}
else if (int.TryParse(p, out int single) && single >= 1 && single <= pageCount) set.Add(single - 1);
}
return set;
}
private void RenderPreview()
{
_overlay.Children.Clear();
_overlay.IsHitTestVisible = false; // re-enabled by MakeDraggable only when a custom stamp is shown
SizePreviewImage();
double pw = _preview.Width, ph = _preview.Height;
if (double.IsNaN(pw) || pw <= 0 || double.IsNaN(ph) || ph <= 0) return;
double pxPerPt = _pageHpt > 0 ? ph / _pageHpt : 1; // preview pixels per PDF point
double mx = pw * 0.05, my = ph * 0.04;
// Watermark sits under the page-number text (drawn first).
if (_wmEnable.IsChecked == true && ParseRange(_wmRange.Text, _pageCount).Contains(_pageIndex))
{
if (_wmImageRadio.IsChecked == true && _wmImageSrc != null)
{
double scale = _wmScale.Value / 100.0;
double iw = Math.Min(pw, _wmImageSrc.PixelWidth * pxPerPt * 0.5) * scale;
double ih = iw * _wmImageSrc.PixelHeight / Math.Max(1, _wmImageSrc.PixelWidth);
var img = new Image { Source = _wmImageSrc, Width = iw, Height = ih, Opacity = _wmOpacity.Value / 100.0, Stretch = Stretch.Fill };
PlaceRotated(img, iw, ih, _wmPos.SelectedIndex, pw, ph, mx, my, _wmAngle.Value);
}
else if (_wmImageRadio.IsChecked != true && _wmText.Text.Length > 0)
{
double fpx = ReadDouble(_wmSize, 64) * pxPerPt;
var tb = new TextBlock { Text = _wmText.Text, FontFamily = new FontFamily(_wmFont.SelectedItem as string ?? "Segoe UI"), FontWeight = FontWeights.Bold, FontSize = Math.Max(6, fpx), Foreground = new SolidColorBrush(_wmColor), Opacity = _wmOpacity.Value / 100.0 };
var sz = Measure(tb);
PlaceRotated(tb, sz.Width, sz.Height, _wmPos.SelectedIndex, pw, ph, mx, my, _wmAngle.Value);
}
}
// Page number for the current page.
if (_numEnable.IsChecked == true && ParseRange(_numRange.Text, _pageCount).Contains(_pageIndex))
{
double fpx = ReadDouble(_numSize, 12) * pxPerPt;
string text = (_numFormat.Text.Length == 0 ? "{n}" : _numFormat.Text)
.Replace("{n}", (ReadInt(_numStart, 1) + _pageIndex).ToString())
.Replace("{N}", _pageCount.ToString());
if (text.Length > 0)
{
var tb = new TextBlock { Text = text, FontFamily = UiKit.UiFont, FontSize = Math.Max(5, fpx), Foreground = new SolidColorBrush(_numColor) };
var sz = Measure(tb);
int h = Positions[Math.Max(0, _numPos.SelectedIndex)].h, v = Positions[Math.Max(0, _numPos.SelectedIndex)].v;
double x, y;
if (h < 0) // custom: drag the number anywhere on the page
{
bool mirroredHere = _numMirror.IsChecked == true && (_pageIndex % 2 == 1);
double cx = mirroredHere ? 1 - _spec.NumCustomX : _spec.NumCustomX;
x = cx * pw - sz.Width / 2;
y = _spec.NumCustomY * ph - sz.Height / 2;
MakeDraggable(tb, sz.Width, sz.Height, pw, ph, (fx, fy) =>
{
_spec.NumCustomX = mirroredHere ? 1 - fx : fx;
_spec.NumCustomY = fy;
});
}
else
{
if (_numMirror.IsChecked == true && h != 1 && (_pageIndex % 2 == 1)) h = 2 - h;
x = h == 0 ? mx : h == 2 ? pw - sz.Width - mx : (pw - sz.Width) / 2;
y = v == 0 ? my : v == 1 ? (ph - sz.Height) / 2 : ph - sz.Height - my;
}
Canvas.SetLeft(tb, x);
Canvas.SetTop(tb, y);
_overlay.Children.Add(tb);
}
}
}
private void PlaceRotated(FrameworkElement el, double w, double h, int posIndex, double pw, double ph, double mx, double my, double angle)
{
int hpos = Positions[Math.Max(0, posIndex)].h, vpos = Positions[Math.Max(0, posIndex)].v;
el.RenderTransformOrigin = new Point(0.5, 0.5);
el.RenderTransform = new RotateTransform(-angle);
double x, y;
if (hpos < 0) // custom: drag the watermark anywhere on the page
{
x = _spec.WmCustomX * pw - w / 2;
y = _spec.WmCustomY * ph - h / 2;
MakeDraggable(el, w, h, pw, ph, (fx, fy) => { _spec.WmCustomX = fx; _spec.WmCustomY = fy; });
}
else
{
x = hpos == 0 ? mx : hpos == 2 ? pw - w - mx : (pw - w) / 2;
y = vpos == 0 ? my : vpos == 1 ? (ph - h) / 2 : ph - h - my;
}
Canvas.SetLeft(el, x);
Canvas.SetTop(el, y);
_overlay.Children.Add(el);
}
// Makes a stamp element draggable in the preview; reports the new center as fractions of the page.
private void MakeDraggable(FrameworkElement el, double elW, double elH, double pw, double ph, Action<double, double> onMove)
{
_overlay.IsHitTestVisible = true;
el.IsHitTestVisible = true;
el.Cursor = Cursors.SizeAll;
bool dragging = false;
el.MouseLeftButtonDown += (_, e) => { dragging = true; el.CaptureMouse(); e.Handled = true; };
el.MouseLeftButtonUp += (_, _2) => { dragging = false; el.ReleaseMouseCapture(); };
el.MouseMove += (_, e) =>
{
if (!dragging) return;
var p = e.GetPosition(_overlay);
double left = Math.Max(0, Math.Min(pw - elW, p.X - elW / 2));
double top = Math.Max(0, Math.Min(ph - elH, p.Y - elH / 2));
Canvas.SetLeft(el, left);
Canvas.SetTop(el, top);
double fx = pw > 0 ? Math.Max(0, Math.Min(1, (left + elW / 2) / pw)) : 0.5;
double fy = ph > 0 ? Math.Max(0, Math.Min(1, (top + elH / 2) / ph)) : 0.5;
onMove(fx, fy);
};
}
private static Size Measure(FrameworkElement el)
{
el.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
return el.DesiredSize;
}
private static double ReadDouble(TextBox tb, double fallback)
=> double.TryParse(tb.Text?.Trim(), NumberStyles.Any, CultureInfo.CurrentCulture, out double d) && d > 0 ? d : fallback;
private static int ReadInt(TextBox tb, int fallback)
=> int.TryParse(tb.Text?.Trim(), out int i) ? i : fallback;
private void ResetAll()
{
var d = new StampSpec { NumbersEnabled = _numEnable.IsChecked == true };
_numEnable.IsChecked = d.NumbersEnabled;
_numStart.Text = d.StartNumber.ToString();
_numFormat.Text = d.Format;
_numSize.Text = d.NumFontPt.ToString("0");
_numRange.Text = d.NumRange;
_numColor = d.NumColor; _numSwatch.Background = new SolidColorBrush(d.NumColor);
_wmText.Text = d.WmText;
_wmSize.Text = d.WmFontPt.ToString("0");
_wmFont.SelectedItem = d.WmFont;
_wmColor = d.WmColor; _wmSwatch.Background = new SolidColorBrush(d.WmColor);
_wmAngle.Value = d.WmAngle;
_wmOpacity.Value = d.WmOpacity * 100;
_wmScale.Value = d.WmScale * 100;
Schedule();
}
private void CommitAndClose()
{
_spec.NumbersEnabled = _numEnable.IsChecked == true;
_spec.StartNumber = ReadInt(_numStart, 1);
_spec.Format = _numFormat.Text.Length == 0 ? "{n}" : _numFormat.Text;
_spec.NumFontPt = ReadDouble(_numSize, 12);
_spec.NumColor = _numColor;
_spec.NumRange = _numRange.Text.Trim();
_spec.NumMirror = _numMirror.IsChecked == true;
(_spec.NumPosH, _spec.NumPosV) = (Positions[Math.Max(0, _numPos.SelectedIndex)].h, Positions[Math.Max(0, _numPos.SelectedIndex)].v);
_spec.WmEnabled = _wmEnable.IsChecked == true;
_spec.WmIsImage = _wmImageRadio.IsChecked == true;
_spec.WmText = _wmText.Text;
_spec.WmFontPt = ReadDouble(_wmSize, 64);
_spec.WmFont = _wmFont.SelectedItem as string ?? "Segoe UI";
_spec.WmColor = _wmColor;
_spec.WmOpacity = _wmOpacity.Value / 100.0;
_spec.WmAngle = _wmAngle.Value;
_spec.WmScale = _wmScale.Value / 100.0;
_spec.WmImagePath = _wmImagePath;
_spec.WmRange = _wmRange.Text.Trim();
(_spec.WmPosH, _spec.WmPosV) = (Positions[Math.Max(0, _wmPos.SelectedIndex)].h, Positions[Math.Max(0, _wmPos.SelectedIndex)].v);
Result = _spec;
Applied = true;
Close();
}
}
}
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
namespace KillerPDF
{
// Keeps the document scrollbar reactive (thumb sized to the visible proportion) while
// guaranteeing it never shrinks below a grabbable floor.
//
// WPF's Track sizes the thumb AND the repeat buttons from the raw proportional value
// (trackLen * viewport / (range + viewport)). Thumb.MinHeight does NOT feed that math - it
// only stretches the thumb's render, so on a long document the thumb overflows its tiny
// proportional slot and the increase RepeatButton paints over the overflow (the "8px"
// scrollbar). Enforcing the minimum here, by raising the ViewportSize the Track sees, makes
// the Track size the thumb and the buttons from the same floored value - no overflow, no
// overlap, still proportional whenever there is room.
//
// Bindings (in order): ViewportSize, Maximum, Minimum, ActualWidth, ActualHeight, Orientation.
// ConverterParameter: the floor in pixels (default 64).
public sealed class ThumbViewportFloorConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
double vp = AsDouble(values, 0);
// Anything unexpected -> hand back the real ViewportSize so behavior is unchanged.
if (double.IsNaN(vp) || vp <= 0) return vp;
double max = AsDouble(values, 1);
double min = AsDouble(values, 2);
double width = AsDouble(values, 3);
double height = AsDouble(values, 4);
bool vertical = values.Length <= 5 || values[5] is not Orientation o
|| o == Orientation.Vertical;
double trackLen = vertical ? height : width;
double range = max - min;
if (double.IsNaN(trackLen) || trackLen <= 0 || range <= 0) return vp;
double floor = 64;
if (parameter != null &&
double.TryParse(parameter.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var p) && p > 0)
floor = p;
// Never let the floor eat the whole track; leave room for the thumb to travel.
floor = Math.Min(floor, trackLen * 0.5);
if (trackLen <= floor) return vp;
// ViewportSize that yields a thumb exactly = floor; take the larger of it and the real VP.
double vpForFloor = floor * range / (trackLen - floor);
return Math.Max(vp, vpForFloor);
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
private static double AsDouble(object[] values, int i)
{
if (values == null || i >= values.Length) return double.NaN;
object v = values[i];
if (v == null || v == DependencyProperty.UnsetValue) return double.NaN;
try { return System.Convert.ToDouble(v, CultureInfo.InvariantCulture); }
catch { return double.NaN; }
}
}
}
+681
View File
@@ -0,0 +1,681 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using System.Windows.Threading;
using KillerPDF.Services;
namespace KillerPDF
{
/// <summary>
/// Modal "Transform" window. Renders the current page on its own canvas (so the main view's mode is
/// irrelevant) and lets the user rotate (quarter turns + fine deskew) and scale it, with the controls in
/// a right-hand sidebar (the mirror of Print Preview). Apply hands the chosen angle / scale / page-mode
/// back to the caller, which rasterizes at full resolution. Draggable corner handles are the next step.
/// </summary>
internal sealed class TransformWindow : Window
{
public bool Applied { get; private set; }
public double Angle { get; private set; } // total = quarter turns + fine
public double Scale { get; private set; } = 1.0;
public bool FixedPage { get; private set; } // true = keep page size (margins); false = resize page
public bool FlipH { get; private set; }
public bool FlipV { get; private set; }
// #174: source levels (black point, white point, midtone gamma). 0/255/1.0 = untouched.
public int LevelBlack { get; private set; }
public int LevelWhite { get; private set; } = 255;
public double LevelGamma { get; private set; } = 1.0;
public Point[] PerspectiveCorners { get; private set; } =
[new(0, 0), new(1, 0), new(1, 1), new(0, 1)];
private readonly BitmapSource _src;
private readonly Image _preview = new()
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(24),
Effect = new System.Windows.Media.Effects.DropShadowEffect
{ Color = Colors.Black, BlurRadius = 14, ShadowDepth = 3, Direction = 270, Opacity = 0.45 }
};
private readonly Border _previewArea = null!;
private readonly double _srcW;
private readonly double _srcH;
private readonly double _pageWpt;
private readonly double _pageHpt;
private readonly TextBlock _sizeReadout = null!;
private int _quarter; // 0..3 quarter turns clockwise
private double _fine; // fine deskew, degrees
private double _scale = 1.0;
private bool _fixedPage;
private readonly TextBlock _rotReadout = null!;
private readonly TextBlock _scaleReadout = null!;
private readonly Slider _rotSlider = null!;
private readonly Slider _scaleSlider = null!;
private Slider _lvlBlack = null!, _lvlWhite = null!, _lvlGamma = null!; // #174
private readonly RadioButton _resizeRadio = null!;
private bool _flipH;
private bool _flipV;
private readonly CheckBox _flipHCheck = null!;
private readonly CheckBox _flipVCheck = null!;
private readonly Canvas _lineCanvas = null!;
private readonly Line _alignLine = null!;
private readonly CheckBox _deskewCheck = null!;
private readonly TextBlock _lineCoords = null!;
private bool _drawingLine;
private Point _lineStart;
private Point _startPagePt;
private readonly DispatcherTimer _previewTimer = null!;
private readonly Canvas _perspectiveCanvas = null!;
private readonly Polygon _perspectiveOutline = null!;
private readonly Ellipse[] _perspectiveHandles = new Ellipse[4];
private readonly CheckBox _perspectiveCheck = null!;
private int _dragPerspective = -1;
private static SolidColorBrush R(string key) => (SolidColorBrush)Application.Current.Resources[key];
private static string S(string key) => Application.Current.TryFindResource(key) as string ?? key;
public TransformWindow(Window owner, BitmapSource src, double pageWpt, double pageHpt)
{
_src = src;
_srcW = src.PixelWidth;
_srcH = src.PixelHeight;
_pageWpt = pageWpt;
_pageHpt = pageHpt;
Title = "KillerPDF - " + S("Str_Tf_Suffix");
Width = 980;
Height = 720;
MinWidth = 640;
MinHeight = 460;
DialogChrome.Configure(this, owner, resizable: true);
var darkSlider = owner?.TryFindResource("DarkSlider") as Style;
var themeRadio = owner?.TryFindResource("ThemeRadio") as Style;
// Coalesce rapid slider changes: the heavy compose (especially scaling a page up, which makes a
// big bitmap) only runs ~25x/sec on the latest value, so dragging stays smooth instead of queuing
// a backlog of full re-renders.
_previewTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(40) };
_previewTimer.Tick += (_, _2) => { _previewTimer.Stop(); UpdatePreview(); };
var root = new DockPanel();
// ---- Right sidebar (transparent so it blends with the dark title bar, like Print Preview) ----
var sidebar = new Border { Width = 288, Background = Brushes.Transparent, Padding = new Thickness(16, 8, 16, 14) };
DockPanel.SetDock(sidebar, Dock.Right);
var side = new DockPanel();
// Bottom: a "Reset all" text link on its own line (translations like "Tout reinitialiser" are
// long), with Cancel / Apply right-aligned beneath it - so nothing crowds or clips.
var bottom = new StackPanel { Margin = new Thickness(0, 10, 0, 0) };
var resetAll = new TextBlock
{
Text = S("Str_Tf_ResetAll"), FontFamily = UiKit.UiFont, FontSize = 12,
Foreground = R("MutedTextBrush"), Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Left
};
resetAll.MouseEnter += (_, _2) => resetAll.Foreground = R("PrimaryBrush");
resetAll.MouseLeave += (_, _2) => resetAll.Foreground = R("MutedTextBrush");
resetAll.MouseLeftButtonUp += (_, _2) =>
{
_quarter = 0; _rotSlider.Value = 0; _scaleSlider.Value = 100;
_resizeRadio.IsChecked = true; _flipHCheck.IsChecked = false; _flipVCheck.IsChecked = false;
ResetPerspective();
ResetLevels(); // #174
};
bottom.Children.Add(resetAll);
var actionRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 8, 0, 0) };
var cancelBtn = UiKit.Make(S("Str_Tf_Cancel"), false);
cancelBtn.Margin = new Thickness(0, 0, 8, 0);
cancelBtn.Click += (_, _2) => { Applied = false; Close(); };
cancelBtn.IsCancel = true; // Esc
actionRow.Children.Add(cancelBtn);
var applyBtn = UiKit.Make(S("Str_Tf_Apply"), true);
applyBtn.Click += (_, _2) => CommitAndClose();
applyBtn.IsDefault = true; // Enter
actionRow.Children.Add(applyBtn);
bottom.Children.Add(actionRow);
DockPanel.SetDock(bottom, Dock.Bottom);
side.Children.Add(bottom);
var stack = new StackPanel();
int rotateStart = stack.Children.Count;
// Quarter-turn buttons.
var turnRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 6) };
var turnL = UiKit.Make("↺ 90°", false);
turnL.Margin = new Thickness(0, 0, 6, 0);
turnL.Click += (_, _2) => { _quarter = (_quarter + 3) % 4; UpdatePreview(); };
var turnR = UiKit.Make("90° ↻", false);
turnR.Click += (_, _2) => { _quarter = (_quarter + 1) % 4; UpdatePreview(); };
turnRow.Children.Add(turnL);
turnRow.Children.Add(turnR);
stack.Children.Add(turnRow);
_rotSlider = new Slider { Minimum = -45, Maximum = 45, Value = 0, TickFrequency = 1, SmallChange = 0.1, LargeChange = 1, Margin = new Thickness(0, 2, 0, 2) };
if (darkSlider != null) _rotSlider.Style = darkSlider;
_rotSlider.ValueChanged += (_, ev) => { _fine = Math.Round(ev.NewValue, 1); if (_rotReadout != null) _rotReadout.Text = $"{Total:0.0}°"; SchedulePreview(); };
stack.Children.Add(_rotSlider);
stack.Children.Add(ValueRow(S("Str_Tf_Angle"), "0.0°", out _rotReadout, out var rotReset));
rotReset.Click += (_, _2) => { _quarter = 0; _rotSlider.Value = 0; UpdatePreview(); };
WrapSection(stack, rotateStart, S("Str_Tf_Rotate"), expanded: true);
stack.Children.Add(Divider());
int scaleStart = stack.Children.Count;
_scaleSlider = new Slider { Minimum = 25, Maximum = 200, Value = 100, TickFrequency = 5, SmallChange = 1, LargeChange = 10, Margin = new Thickness(0, 2, 0, 2) };
if (darkSlider != null) _scaleSlider.Style = darkSlider;
_scaleSlider.ValueChanged += (_, ev) => { _scale = Math.Round(ev.NewValue) / 100.0; _scaleReadout.Text = $"{ev.NewValue:0}%"; SchedulePreview(); };
stack.Children.Add(_scaleSlider);
stack.Children.Add(ValueRow(S("Str_Tf_Size"), "100%", out _scaleReadout, out var scaleReset));
scaleReset.Click += (_, _2) => _scaleSlider.Value = 100;
stack.Children.Add(new TextBlock { Text = S("Str_Tf_WhenScaling"), Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 11, Margin = new Thickness(0, 10, 0, 4) });
_resizeRadio = MakeRadio(S("Str_Tf_ResizePage"), true, themeRadio);
var fixedRadio = MakeRadio(S("Str_Tf_KeepSize"), false, themeRadio);
_resizeRadio.Checked += (_, _2) => { _fixedPage = false; UpdatePreview(); };
fixedRadio.Checked += (_, _2) => { _fixedPage = true; UpdatePreview(); };
stack.Children.Add(_resizeRadio);
stack.Children.Add(fixedRadio);
// Live output dimensions, so scale changes (including above 100%, where the preview clamps to
// fit) are always legible as a number even when the page can't grow on screen.
_sizeReadout = new TextBlock { Foreground = R("MutedTextBrush"), FontFamily = UiKit.MonoFont, FontSize = 11, Margin = new Thickness(0, 8, 0, 0) };
stack.Children.Add(_sizeReadout);
WrapSection(stack, scaleStart, S("Str_Tf_Scale"), expanded: false);
stack.Children.Add(Divider());
int flipStart = stack.Children.Count;
_flipHCheck = MakeCheck(S("Str_Tf_FlipH"));
_flipHCheck.Checked += (_, _2) => { _flipH = true; UpdatePreview(); };
_flipHCheck.Unchecked += (_, _2) => { _flipH = false; UpdatePreview(); };
stack.Children.Add(_flipHCheck);
_flipVCheck = MakeCheck(S("Str_Tf_FlipV"));
_flipVCheck.Checked += (_, _2) => { _flipV = true; UpdatePreview(); };
_flipVCheck.Unchecked += (_, _2) => { _flipV = false; UpdatePreview(); };
stack.Children.Add(_flipVCheck);
WrapSection(stack, flipStart, S("Str_Tf_Flip"), expanded: false);
stack.Children.Add(Divider());
int skewStart = stack.Children.Count;
_deskewCheck = MakeCheck(S("Str_Tf_LevelLine"));
_deskewCheck.Checked += (_, _2) => { _lineCanvas.IsHitTestVisible = true; };
_deskewCheck.Unchecked += (_, _2) => { _lineCanvas.IsHitTestVisible = false; _alignLine.Visibility = Visibility.Collapsed; _lineCoords.Text = ""; };
stack.Children.Add(_deskewCheck);
stack.Children.Add(new TextBlock
{
Text = S("Str_Tf_SkewHint"),
Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 10,
TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 4, 0, 0)
});
// Live cursor coordinates (page points), so the user can place the line precisely on the small
// preview. Start point on press, end point as they drag.
_lineCoords = new TextBlock
{
Text = "", Foreground = R("MutedTextBrush"), FontFamily = UiKit.MonoFont,
FontSize = 11, LineHeight = 16, Margin = new Thickness(0, 6, 0, 0), Padding = new Thickness(3)
};
stack.Children.Add(_lineCoords);
WrapSection(stack, skewStart, S("Str_Tf_Skew"), expanded: false);
stack.Children.Add(Divider());
int perspectiveStart = stack.Children.Count;
_perspectiveCheck = MakeCheck(S("Str_Tf_CorrectPerspective"));
_perspectiveCheck.Checked += (_, _2) =>
{
_deskewCheck.IsChecked = false;
_perspectiveCanvas.Visibility = Visibility.Visible;
_perspectiveCanvas.IsHitTestVisible = true;
UpdatePerspectiveOverlay();
};
_perspectiveCheck.Unchecked += (_, _2) =>
{
_perspectiveCanvas.Visibility = Visibility.Collapsed;
_perspectiveCanvas.IsHitTestVisible = false;
};
stack.Children.Add(_perspectiveCheck);
stack.Children.Add(new TextBlock
{
Text = S("Str_Tf_PerspectiveHint"),
Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont, FontSize = 10,
TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 4, 0, 0)
});
var resetPerspective = UiKit.Make(S("Str_Tf_ResetCorners"), false);
resetPerspective.Margin = new Thickness(0, 7, 0, 0);
resetPerspective.HorizontalAlignment = HorizontalAlignment.Left;
resetPerspective.Click += (_, _2) => ResetPerspective();
stack.Children.Add(resetPerspective);
WrapSection(stack, perspectiveStart, S("Str_Tf_Perspective"), expanded: false);
// #174: LEVELS - FineReader-style source levels for rescuing pale scans. Black point,
// white point, and a midtone gamma; live in the preview, baked on Apply like every
// other correction here.
stack.Children.Add(Divider());
int levelsStart = stack.Children.Count;
stack.Children.Add(SliderLabel(S("Str_Tf_LevelsBlack")));
_lvlBlack = new Slider { Minimum = 0, Maximum = 200, Value = 0, TickFrequency = 5, SmallChange = 1, LargeChange = 10, Margin = new Thickness(0, 2, 0, 2) };
if (darkSlider != null) _lvlBlack.Style = darkSlider;
_lvlBlack.ValueChanged += (_, ev) => { LevelBlack = (int)Math.Round(ev.NewValue); SchedulePreview(); };
stack.Children.Add(_lvlBlack);
stack.Children.Add(SliderLabel(S("Str_Tf_LevelsWhite")));
_lvlWhite = new Slider { Minimum = 55, Maximum = 255, Value = 255, TickFrequency = 5, SmallChange = 1, LargeChange = 10, Margin = new Thickness(0, 2, 0, 2) };
if (darkSlider != null) _lvlWhite.Style = darkSlider;
_lvlWhite.ValueChanged += (_, ev) => { LevelWhite = (int)Math.Round(ev.NewValue); SchedulePreview(); };
stack.Children.Add(_lvlWhite);
stack.Children.Add(SliderLabel(S("Str_Tf_LevelsGamma")));
_lvlGamma = new Slider { Minimum = 0.2, Maximum = 2.5, Value = 1.0, TickFrequency = 0.05, SmallChange = 0.05, LargeChange = 0.2, Margin = new Thickness(0, 2, 0, 2) };
if (darkSlider != null) _lvlGamma.Style = darkSlider;
_lvlGamma.ValueChanged += (_, ev) => { LevelGamma = Math.Round(ev.NewValue, 2); SchedulePreview(); };
stack.Children.Add(_lvlGamma);
var levelsReset = UiKit.Make(S("Str_Tf_Reset"), false);
levelsReset.Margin = new Thickness(0, 7, 0, 0);
levelsReset.HorizontalAlignment = HorizontalAlignment.Left;
levelsReset.Click += (_, _2) => ResetLevels();
stack.Children.Add(levelsReset);
WrapSection(stack, levelsStart, S("Str_Tf_Levels"), expanded: false);
side.Children.Add(new ScrollViewer
{
Content = stack,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
});
sidebar.Child = side;
root.Children.Add(sidebar);
// ---- Preview area: a documentbg box (1px frame, margin, rounded) with grain in the margins and
// the page (sized to its true relative scale, with a drop shadow) centered on top. ----
var previewWrap = new Border
{
BorderThickness = new Thickness(1),
CornerRadius = UiKit.RadControl,
Margin = new Thickness(8, 4, 8, 12),
ClipToBounds = true
};
previewWrap.SetResourceReference(Border.BackgroundProperty, "BgCanvas");
previewWrap.SetResourceReference(Border.BorderBrushProperty, "PaneBorderBrush");
var previewGrid = new Grid();
var pgGrain = (owner as MainWindow)?.GrainTexture;
if (pgGrain != null)
{
double pop = Application.Current.Resources["GrainOpacity"] is double pgo ? pgo : 0.05;
previewGrid.Children.Add(new Border
{
IsHitTestVisible = false, Opacity = pop,
Background = new ImageBrush(pgGrain) { TileMode = TileMode.Tile, ViewportUnits = BrushMappingMode.Absolute, Viewport = new Rect(0, 0, 256, 256), Stretch = Stretch.None }
});
}
RenderOptions.SetBitmapScalingMode(_preview, BitmapScalingMode.HighQuality);
_preview.Source = _src;
previewGrid.Children.Add(_preview);
// Alignment-line overlay: when "Draw a level line" is on, the user drags a reference line across
// the page and the page rotates so that line becomes level. Hit-testing is off until enabled, so
// it never interferes with the rest of the preview.
_lineCanvas = new Canvas { Background = Brushes.Transparent, IsHitTestVisible = false, Cursor = Cursors.Cross };
_alignLine = new Line
{
Stroke = R("PrimaryBrush"), StrokeThickness = 2, StrokeDashArray = [4, 3],
Visibility = Visibility.Collapsed,
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.White, BlurRadius = 3, ShadowDepth = 0, Opacity = 0.8 }
};
_lineCanvas.Children.Add(_alignLine);
_lineCanvas.MouseLeftButtonDown += LineCanvas_Down;
_lineCanvas.MouseMove += LineCanvas_Move;
_lineCanvas.MouseLeftButtonUp += LineCanvas_Up;
previewGrid.Children.Add(_lineCanvas);
_perspectiveCanvas = new Canvas
{
Background = Brushes.Transparent,
Visibility = Visibility.Collapsed,
IsHitTestVisible = false,
Cursor = Cursors.Cross,
};
_perspectiveOutline = new Polygon
{
Stroke = R("PrimaryBrush"), StrokeThickness = 2, StrokeDashArray = [5, 3],
Fill = new SolidColorBrush(Color.FromArgb(24, 30, 165, 76)), IsHitTestVisible = false,
};
_perspectiveCanvas.Children.Add(_perspectiveOutline);
for (int i = 0; i < 4; i++)
{
var handle = new Ellipse
{
Width = 18, Height = 18, Fill = R("PrimaryBrush"), Stroke = Brushes.White,
StrokeThickness = 2, Cursor = Cursors.SizeAll, Tag = i,
};
handle.PreviewMouseLeftButtonDown += PerspectiveHandle_Down;
_perspectiveHandles[i] = handle;
_perspectiveCanvas.Children.Add(handle);
}
_perspectiveCanvas.AddHandler(Mouse.PreviewMouseMoveEvent,
new MouseEventHandler(PerspectiveCanvas_Move), true);
_perspectiveCanvas.AddHandler(Mouse.PreviewMouseUpEvent,
new MouseButtonEventHandler(PerspectiveCanvas_Up), true);
_perspectiveCanvas.LostMouseCapture += (_, _2) => _dragPerspective = -1;
previewGrid.Children.Add(_perspectiveCanvas);
previewWrap.Child = previewGrid;
_previewArea = previewWrap;
previewWrap.SizeChanged += (_, _2) => SizePreviewImage();
// Family shadow under the content pane, like the main window (flat on 98SE).
root.Children.Add(UiKit.PaneWithShadow(previewWrap));
Content = DialogChrome.Frame(this, Owner, "KillerPDF - " + S("Str_Tf_Suffix"), () => { Applied = false; Close(); }, root);
UpdatePreview(); // populate the output-size readout at the original dimensions
// Esc-to-close is wired by DialogChrome.Frame; Enter commits.
KeyDown += (_, e) => { if (e.Key == Key.Enter) CommitAndClose(); };
}
private double Total => _quarter * 90 + _fine;
private void CommitAndClose()
{
Applied = true;
Angle = Total;
Scale = _scale;
FixedPage = _fixedPage;
FlipH = _flipH;
FlipV = _flipV;
PerspectiveCorners = PerspectiveCorners.ToArray();
Close();
}
private void ResetPerspective()
{
PerspectiveCorners = [new(0, 0), new(1, 0), new(1, 1), new(0, 1)];
UpdatePerspectiveOverlay();
}
private Rect PreviewBoundsOnPerspectiveCanvas()
{
if (_preview.ActualWidth <= 0 || _preview.ActualHeight <= 0) return Rect.Empty;
Point origin = _preview.TranslatePoint(new Point(0, 0), _perspectiveCanvas);
return new Rect(origin.X, origin.Y, _preview.ActualWidth, _preview.ActualHeight);
}
private void UpdatePerspectiveOverlay()
{
if (_perspectiveCanvas == null || _perspectiveOutline == null) return;
Rect bounds = PreviewBoundsOnPerspectiveCanvas();
if (bounds.IsEmpty) return;
var points = new PointCollection();
for (int i = 0; i < 4; i++)
{
Point p = new(bounds.Left + PerspectiveCorners[i].X * bounds.Width,
bounds.Top + PerspectiveCorners[i].Y * bounds.Height);
points.Add(p);
Canvas.SetLeft(_perspectiveHandles[i], p.X - 9);
Canvas.SetTop(_perspectiveHandles[i], p.Y - 9);
}
_perspectiveOutline.Points = points;
}
private void PerspectiveHandle_Down(object sender, MouseButtonEventArgs e)
{
if (sender is not Ellipse { Tag: int index }) return;
_dragPerspective = index;
Mouse.Capture(_perspectiveCanvas, CaptureMode.SubTree);
e.Handled = true;
}
private void PerspectiveCanvas_Move(object sender, MouseEventArgs e)
{
if (_dragPerspective < 0 || e.LeftButton != MouseButtonState.Pressed) return;
Rect bounds = PreviewBoundsOnPerspectiveCanvas();
if (bounds.IsEmpty) return;
Point p = e.GetPosition(_perspectiveCanvas);
PerspectiveCorners[_dragPerspective] = new Point(
Math.Max(0, Math.Min(1, (p.X - bounds.Left) / bounds.Width)),
Math.Max(0, Math.Min(1, (p.Y - bounds.Top) / bounds.Height)));
UpdatePerspectiveOverlay();
}
private void PerspectiveCanvas_Up(object sender, MouseButtonEventArgs e)
{
_dragPerspective = -1;
if (ReferenceEquals(Mouse.Captured, _perspectiveCanvas)) Mouse.Capture(null);
e.Handled = true;
}
// ---- Alignment-line deskew: drag a line, release, and the page rotates to make that line level. ----
// Maps a point in the preview image to page coordinates in points (clamped to the page).
private Point PreviewToPagePts(Point pInPreview)
{
double w = _preview.ActualWidth, h = _preview.ActualHeight;
double fx = w > 0 ? Math.Max(0, Math.Min(1, pInPreview.X / w)) : 0;
double fy = h > 0 ? Math.Max(0, Math.Min(1, pInPreview.Y / h)) : 0;
return new Point(fx * _pageWpt, fy * _pageHpt);
}
private void ShowLineCoords(Point endPage)
=> _lineCoords.Text = $"Start {_startPagePt.X:0}, {_startPagePt.Y:0} pt\nEnd {endPage.X:0}, {endPage.Y:0} pt";
private void LineCanvas_Down(object sender, MouseButtonEventArgs e)
{
_drawingLine = true;
_lineStart = e.GetPosition(_lineCanvas);
_alignLine.X1 = _alignLine.X2 = _lineStart.X;
_alignLine.Y1 = _alignLine.Y2 = _lineStart.Y;
_alignLine.Visibility = Visibility.Visible;
_startPagePt = PreviewToPagePts(e.GetPosition(_preview));
ShowLineCoords(_startPagePt);
_lineCanvas.CaptureMouse();
}
private void LineCanvas_Move(object sender, MouseEventArgs e)
{
if (!_drawingLine) return;
var p = e.GetPosition(_lineCanvas);
_alignLine.X2 = p.X;
_alignLine.Y2 = p.Y;
ShowLineCoords(PreviewToPagePts(e.GetPosition(_preview)));
}
private void LineCanvas_Up(object sender, MouseButtonEventArgs e)
{
if (!_drawingLine) return;
_drawingLine = false;
_lineCanvas.ReleaseMouseCapture();
double dx = _alignLine.X2 - _alignLine.X1;
double dy = _alignLine.Y2 - _alignLine.Y1;
_alignLine.Visibility = Visibility.Collapsed;
if (dx * dx + dy * dy < 100) return; // ignore an accidental tap
// Screen angle of the line (clockwise positive, since Y is down). Normalize to an undirected
// (-90, 90], then snap to the nearest axis so a near-vertical drag deskews to vertical.
double a = Math.Atan2(dy, dx) * 180.0 / Math.PI;
a %= 180.0;
if (a > 90.0) a -= 180.0; else if (a < -90.0) a += 180.0;
if (a > 45.0) a -= 90.0; else if (a < -45.0) a += 90.0;
// Rotate by -a (on top of the current fine angle) to level the line; the slider drives _fine.
double newFine = Math.Max(-45.0, Math.Min(45.0, _fine - a));
_rotSlider.Value = Math.Round(newFine, 1);
}
// Throttles the heavy preview compose so slider dragging stays smooth (see the timer in the ctor).
private void SchedulePreview()
{
_previewTimer.Stop();
_previewTimer.Start();
}
// #174 helpers, shared with the full-resolution Apply in Rotate.cs.
internal static bool LevelsIdentity(int black, int white, double gamma)
=> black <= 0 && white >= 255 && Math.Abs(gamma - 1.0) < 0.01;
/// <summary>Levels pass: remaps [black..white] to [0..255] through a midtone gamma,
/// per RGB channel, alpha untouched. Identity settings return the source unchanged.</summary>
internal static BitmapSource ApplyLevels(BitmapSource src, int black, int white, double gamma)
{
if (LevelsIdentity(black, white, gamma)) return src;
var conv = new FormatConvertedBitmap(src, PixelFormats.Bgra32, null, 0);
int w = conv.PixelWidth, h = conv.PixelHeight, stride = w * 4;
var px = new byte[stride * h];
conv.CopyPixels(px, stride, 0);
var lut = new byte[256];
double lo = black, hi = Math.Max(black + 1, white), invG = 1.0 / Math.Max(0.05, gamma);
for (int i = 0; i < 256; i++)
{
double t = (i - lo) / (hi - lo);
t = t < 0 ? 0 : t > 1 ? 1 : t;
lut[i] = (byte)Math.Round(Math.Pow(t, invG) * 255);
}
for (int i = 0; i < px.Length; i += 4)
{
px[i] = lut[px[i]];
px[i + 1] = lut[px[i + 1]];
px[i + 2] = lut[px[i + 2]];
}
var bmp = BitmapSource.Create(w, h, conv.DpiX, conv.DpiY, PixelFormats.Bgra32, null, px, stride);
bmp.Freeze();
return bmp;
}
private void ResetLevels()
{
_lvlBlack.Value = 0; _lvlWhite.Value = 255; _lvlGamma.Value = 1.0;
}
private TextBlock SliderLabel(string text) => new()
{
Text = text, Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont,
FontSize = 10, Margin = new Thickness(0, 6, 0, 0),
};
private void UpdatePreview()
{
double total = Total;
if (_rotReadout != null) _rotReadout.Text = $"{total:0.0}°";
_preview.Source = (total == 0 && _scale == 1.0 && !_flipH && !_flipV)
? _src
: MainWindow.ComposeTransform(_src, total, _scale, _fixedPage, _flipH, _flipV);
// #174: levels ride on top of whatever geometry the preview shows.
if (_preview.Source is BitmapSource lvlSrc && !LevelsIdentity(LevelBlack, LevelWhite, LevelGamma))
_preview.Source = ApplyLevels(lvlSrc, LevelBlack, LevelWhite, LevelGamma);
if (_sizeReadout != null && _preview.Source is BitmapSource b && _srcW > 0 && _pageWpt > 0)
{
double outWin = b.PixelWidth * (_pageWpt / _srcW) / 72.0;
double outHin = b.PixelHeight * (_pageHpt / _srcH) / 72.0;
_sizeReadout.Text = string.Format(S("Str_Tf_Output"), outWin.ToString("0.0"), outHin.ToString("0.0"));
}
SizePreviewImage();
}
// Sizes the page to its TRUE relative scale within the preview box, so "Resize the whole page" makes
// the page visibly shrink (rather than refit to the same size), and rotation visibly grows it.
// Clamps so the page never overflows the box.
private void SizePreviewImage()
{
if (_previewArea is null || _preview.Source is not BitmapSource bmp || _srcW <= 0 || _srcH <= 0) return;
const double m = 36; // breathing room inside the box
double areaW = Math.Max(1, _previewArea.ActualWidth - m);
double areaH = Math.Max(1, _previewArea.ActualHeight - m);
double baseFit = Math.Min(areaW / _srcW, areaH / _srcH); // scale that fits the original page
double dispW = bmp.PixelWidth * baseFit;
double dispH = bmp.PixelHeight * baseFit;
double clamp = Math.Min(1.0, Math.Min(areaW / dispW, areaH / dispH)); // never overflow the box
_preview.Width = dispW * clamp;
_preview.Height = dispH * clamp;
Dispatcher.BeginInvoke(new Action(UpdatePerspectiveOverlay), DispatcherPriority.Loaded);
}
private TextBlock SectionHeader(string text) => new()
{
Text = text, Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont,
FontSize = 10, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 6, 0, 4)
};
private void WrapSection(StackPanel host, int start, string title, bool expanded)
{
var children = host.Children.Cast<UIElement>().Skip(start).ToList();
while (host.Children.Count > start) host.Children.RemoveAt(start);
var body = new StackPanel { Visibility = expanded ? Visibility.Visible : Visibility.Collapsed };
foreach (var child in children) body.Children.Add(child);
var chevron = new TextBlock
{
Text = expanded ? "▾" : "▸", Width = 16, FontSize = 12,
Foreground = R("MutedTextBrush"), VerticalAlignment = VerticalAlignment.Center,
};
var label = SectionHeader(title);
label.Margin = new Thickness(0);
var row = new StackPanel { Orientation = Orientation.Horizontal };
row.Children.Add(chevron);
row.Children.Add(label);
var header = new Border
{
Background = Brushes.Transparent, Cursor = Cursors.Hand,
Padding = new Thickness(0, 5, 0, 5), Child = row,
};
header.MouseLeftButtonUp += (_, _2) =>
{
bool open = body.Visibility != Visibility.Visible;
body.Visibility = open ? Visibility.Visible : Visibility.Collapsed;
chevron.Text = open ? "▾" : "▸";
};
host.Children.Add(header);
host.Children.Add(body);
}
private Border Divider()
{
var b = new Border { Height = 1, Margin = new Thickness(0, 14, 0, 12) };
b.SetResourceReference(Border.BackgroundProperty, "CardBorderBrush");
return b;
}
private DockPanel ValueRow(string label, string value, out TextBlock valueBlock, out Button reset)
{
var row = new DockPanel { Margin = new Thickness(0, 2, 0, 0) };
reset = UiKit.Make(S("Str_Tf_Reset"), false);
reset.Padding = new Thickness(8, 1, 8, 1);
reset.FontSize = 11;
DockPanel.SetDock(reset, Dock.Right);
row.Children.Add(reset);
valueBlock = new TextBlock
{
Text = value, Foreground = R("TextBrush"), FontFamily = UiKit.MonoFont,
FontSize = 12, VerticalAlignment = VerticalAlignment.Center,
TextAlignment = TextAlignment.Right, Margin = new Thickness(0, 0, 8, 0)
};
DockPanel.SetDock(valueBlock, Dock.Right);
row.Children.Add(valueBlock);
row.Children.Add(new TextBlock
{
Text = label, Foreground = R("MutedTextBrush"), FontFamily = UiKit.UiFont,
FontSize = 11, VerticalAlignment = VerticalAlignment.Center
});
return row;
}
private RadioButton MakeRadio(string text, bool isChecked, Style? style)
{
var rb = new RadioButton
{
Content = new TextBlock { Text = text, TextWrapping = TextWrapping.Wrap, VerticalAlignment = VerticalAlignment.Center },
IsChecked = isChecked, Foreground = R("TextBrush"),
FontFamily = UiKit.UiFont, FontSize = 12, Margin = new Thickness(0, 3, 0, 0)
};
if (style != null) rb.Style = style;
return rb;
}
private CheckBox MakeCheck(string text)
{
var cb = UiKit.CheckBox(text);
cb.Margin = new Thickness(0, 3, 0, 0);
return cb;
}
}
}
+579
View File
@@ -0,0 +1,579 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
namespace KillerPDF
{
// WPF has no built-in animation for GridLength, so grid columns (the sidebar) can only snap.
// This drives a pixel-unit GridLength between From and To so a column glides instead.
// From/To/Easing MUST be dependency properties: starting the clock freezes/clones the
// timeline, and the clone only carries DPs - as plain CLR properties they were lost and
// the "animation" held a constant until the completion snap.
internal sealed class GridLengthAnimation : AnimationTimeline
{
public static readonly DependencyProperty FromProperty =
DependencyProperty.Register(nameof(From), typeof(GridLength), typeof(GridLengthAnimation));
public static readonly DependencyProperty ToProperty =
DependencyProperty.Register(nameof(To), typeof(GridLength), typeof(GridLengthAnimation));
public static readonly DependencyProperty EasingProperty =
DependencyProperty.Register(nameof(Easing), typeof(IEasingFunction), typeof(GridLengthAnimation));
public GridLength From { get => (GridLength)GetValue(FromProperty); set => SetValue(FromProperty, value); }
public GridLength To { get => (GridLength)GetValue(ToProperty); set => SetValue(ToProperty, value); }
public IEasingFunction? Easing { get => (IEasingFunction?)GetValue(EasingProperty); set => SetValue(EasingProperty, value); }
public override Type TargetPropertyType => typeof(GridLength);
protected override Freezable CreateInstanceCore() => new GridLengthAnimation();
public override object GetCurrentValue(object defaultOriginValue, object defaultDestinationValue, AnimationClock animationClock)
{
double p = animationClock.CurrentProgress ?? 0.0;
if (Easing is { } ease) p = ease.Ease(p);
return new GridLength(From.Value + (To.Value - From.Value) * p);
}
}
// Design tokens (fonts, radii, shadows) and code-built controls (buttons, checkboxes, fields, labels)
// for dialogs and tools. Tokens resolve from App.xaml's resource dictionary.
internal static class UiKit
{
// Mouse-wheel over ANY slider (on hover) nudges its value - one global class handler covers every
// slider in the app and all dialogs. Handled so the wheel doesn't also scroll an enclosing panel
// while the cursor is on the slider. Registered once when UiKit is first touched (early in startup).
static UiKit()
{
EventManager.RegisterClassHandler(typeof(Slider), UIElement.PreviewMouseWheelEvent,
new MouseWheelEventHandler(SliderWheelAdjust));
}
private static void SliderWheelAdjust(object sender, MouseWheelEventArgs e)
{
if (sender is not Slider s || !s.IsEnabled) return;
double step = s.SmallChange > 0 ? s.SmallChange : (s.Maximum - s.Minimum) / 20.0;
if (step <= 0) return;
s.Value = Math.Max(s.Minimum, Math.Min(s.Maximum, s.Value + (e.Delta > 0 ? step : -step)));
e.Handled = true;
}
// ---- token + theme accessors -------------------------------------------------------------
public static FontFamily UiFont => Res("UiFont", _uiFallback);
public static FontFamily MonoFont => Res("MonoFont", _monoFallback);
public static FontFamily IconFont => Res("IconFont", _iconFallback);
public static FontFamily WordmarkFont => Res("WordmarkFont", _wordmarkFallback);
public static FontFamily WordmarkFontPdf => Res("WordmarkFontPdf", _wordmarkPdfFallback);
private static readonly FontFamily _uiFallback = new("Segoe UI, Microsoft JhengHei UI, Nirmala UI");
private static readonly FontFamily _monoFallback = new("Consolas");
private static readonly FontFamily _iconFallback = new("Segoe MDL2 Assets");
private static readonly FontFamily _wordmarkFallback = new("Typewriter - a602 (dead postman 2004), Consolas");
private static readonly FontFamily _wordmarkPdfFallback = new("Typewriter - a602 (dead postman 2004), Consolas");
public static CornerRadius RadControl => Rad("RadControl", 3);
public static CornerRadius RadCard => Rad("RadCard", 6);
public static CornerRadius RadWindow => Rad("RadWindow", 7);
// Fresh shadow instances (cheap) matching App.xaml's Shadow* resources, for code that builds Effects.
public static DropShadowEffect ShadowText() => Shadow(3, 1, 0.6);
public static DropShadowEffect ShadowIcon() => Shadow(4, 1, 0.9);
public static DropShadowEffect ShadowBar() => Shadow(6, 3, Opacity("BarShadowOpacity", 0.38));
public static DropShadowEffect ShadowDialog() => Shadow(18, 3, Opacity("FlyoutShadowOpacity", 0.6));
// Active-theme brush by key, with a safe fallback so the kit never throws before the theme loads.
public static Brush Brush(string key, Brush? fallback = null)
=> Application.Current?.TryFindResource(key) as Brush ?? fallback ?? Brushes.Gray;
// ---- inline flyout -----------------------------------------------------------------------
// The style for small helpers that float ON the document itself (the form font-size
// stepper; future on-page controls): a translucent dark pill that reads over any page
// content without shouting. Slightly see-through at rest so the page underneath stays
// visible; hovering solidifies it (animated). Deliberately theme-independent - pages are
// usually white whatever the app theme, so one consistent dark pill (with fixed light
// text inside) reads best everywhere.
public const double InlineFlyoutRestOpacity = 0.85;
public static Border InlineFlyout(FrameworkElement content)
{
var b = new Border
{
CornerRadius = new CornerRadius(12),
Padding = new Thickness(7, 1, 7, 1),
BorderThickness = new Thickness(1),
Background = new SolidColorBrush(Color.FromArgb(0xCC, 0x16, 0x16, 0x16)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF)),
Effect = Shadow(10, 2, 0.25),
Child = content,
SnapsToDevicePixels = true,
};
b.MouseEnter += (_, _) => b.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(1.0, new Duration(TimeSpan.FromMilliseconds(110)))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
b.MouseLeave += (_, _) => b.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(InlineFlyoutRestOpacity, new Duration(TimeSpan.FromMilliseconds(220)))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
return b;
}
private static T Res<T>(string key, T fallback) where T : class
=> Application.Current?.TryFindResource(key) as T ?? fallback;
private static CornerRadius Rad(string key, double fb)
=> Application.Current?.TryFindResource(key) is CornerRadius c ? c : new CornerRadius(fb);
private static DropShadowEffect Shadow(double blur, double depth, double opacity)
=> new() { Color = Colors.Black, BlurRadius = blur, ShadowDepth = depth, Direction = 270, Opacity = opacity };
private static double Opacity(string key, double fallback)
=> Application.Current?.TryFindResource(key) is double value ? value : fallback;
// The default quick-color palette, shared by the annotate bars and the color picker's swatch row
// (the "UserSwatches" setting seeds from this). One source so the two can't drift.
public static readonly Color[] DefaultSwatches =
[
Color.FromRgb(0xE0, 0x3C, 0x3C), Color.FromRgb(0xE8, 0x7A, 0x1E), Color.FromRgb(0xF2, 0xC0, 0x1E),
Color.FromRgb(0x2E, 0xA5, 0x4C), Color.FromRgb(0x2E, 0x86, 0xDE), Color.FromRgb(0x8E, 0x5B, 0xD6),
Color.FromRgb(0xE0, 0x4A, 0x9A), Colors.Black, Colors.White
];
// ---- control factories -------------------------------------------------------------------
// Themed checkbox: rounded box with an accent check mark when checked. Replaces the per-dialog
// StyleCheckBox/ThemedCheckTemplate copies so every checkbox in the app is identical.
public static CheckBox CheckBox(string label) => new()
{
Content = new TextBlock { Text = label, TextWrapping = TextWrapping.Wrap },
Foreground = Brush("TextBrush"),
FontFamily = UiFont,
FontSize = 12,
Cursor = Cursors.Hand,
VerticalContentAlignment = VerticalAlignment.Center,
Template = CheckTemplate()
};
private static ControlTemplate CheckTemplate()
{
// DockPanel, not a horizontal StackPanel. A horizontal StackPanel measures its children
// at infinite width, so the label could never wrap however it was configured. Docking the
// box to the left leaves the label a real width to wrap inside (#223).
var row = new FrameworkElementFactory(typeof(DockPanel)) { Name = "root" };
var boxHost = new FrameworkElementFactory(typeof(Grid));
boxHost.SetValue(FrameworkElement.WidthProperty, 16.0);
boxHost.SetValue(FrameworkElement.HeightProperty, 16.0);
boxHost.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center);
boxHost.SetValue(FrameworkElement.MarginProperty, new Thickness(0, 0, 8, 0));
boxHost.SetValue(DockPanel.DockProperty, Dock.Left);
var box = new FrameworkElementFactory(typeof(Border));
box.SetValue(Border.CornerRadiusProperty, RadControl);
box.SetValue(Border.BorderThicknessProperty, new Thickness(1));
box.SetValue(Border.BorderBrushProperty, Brush("CardBorderBrush"));
box.SetValue(Border.BackgroundProperty, Brush("RadioWellBrush"));
boxHost.AppendChild(box);
var sunkenDark = new FrameworkElementFactory(typeof(Border));
sunkenDark.SetValue(UIElement.IsHitTestVisibleProperty, false);
sunkenDark.SetResourceReference(Border.BorderBrushProperty, "BevelDarkBrush");
sunkenDark.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenDarkThickness");
boxHost.AppendChild(sunkenDark);
var sunkenLight = new FrameworkElementFactory(typeof(Border));
sunkenLight.SetValue(UIElement.IsHitTestVisibleProperty, false);
sunkenLight.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
sunkenLight.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenLightThickness");
boxHost.AppendChild(sunkenLight);
var check = new FrameworkElementFactory(typeof(TextBlock)) { Name = "chk" };
check.SetValue(TextBlock.TextProperty, ""); // Segoe MDL2 CheckMark
check.SetValue(TextBlock.FontFamilyProperty, IconFont);
check.SetValue(TextBlock.FontSizeProperty, 14.0);
check.SetValue(TextBlock.FontWeightProperty, FontWeights.Bold);
check.SetValue(TextBlock.ForegroundProperty, Brush("RadioAccent"));
check.SetValue(TextBlock.HorizontalAlignmentProperty, HorizontalAlignment.Center);
check.SetValue(TextBlock.VerticalAlignmentProperty, VerticalAlignment.Center);
check.SetValue(UIElement.VisibilityProperty, Visibility.Collapsed);
boxHost.AppendChild(check);
var content = new FrameworkElementFactory(typeof(ContentPresenter));
content.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
row.AppendChild(boxHost);
row.AppendChild(content);
var ct = new ControlTemplate(typeof(CheckBox)) { VisualTree = row };
var trig = new Trigger { Property = ToggleButton.IsCheckedProperty, Value = true };
trig.Setters.Add(new Setter(UIElement.VisibilityProperty, Visibility.Visible) { TargetName = "chk" });
ct.Triggers.Add(trig);
// Disabled state: dim the whole control (box + label) so it's obviously inactive.
var disabled = new Trigger { Property = UIElement.IsEnabledProperty, Value = false };
disabled.Setters.Add(new Setter(UIElement.OpacityProperty, 0.4) { TargetName = "root" });
ct.Triggers.Add(disabled);
return ct;
}
// Themed radio button with a clean horizontal layout (ring + accent dot + label), built from the
// theme brushes. Unlike the settings-panel ThemeRadio (a full-width vertical row), this lays out
// tightly for inline/horizontal use.
public static RadioButton Radio(string text) => new()
{
Content = text,
Foreground = Brush("TextBrush"),
FontFamily = UiFont,
FontSize = 12,
Cursor = Cursors.Hand,
VerticalContentAlignment = VerticalAlignment.Center,
Template = RadioTemplate()
};
private static ControlTemplate RadioTemplate()
{
var sp = new FrameworkElementFactory(typeof(StackPanel)) { Name = "root" };
sp.SetValue(StackPanel.OrientationProperty, Orientation.Horizontal);
sp.SetValue(Panel.BackgroundProperty, Brushes.Transparent);
var ring = new FrameworkElementFactory(typeof(Border)) { Name = "ring" };
ring.SetValue(Border.WidthProperty, 15.0);
ring.SetValue(Border.HeightProperty, 15.0);
ring.SetValue(Border.CornerRadiusProperty, new CornerRadius(7.5));
ring.SetValue(Border.BorderThicknessProperty, new Thickness(1.5));
ring.SetValue(Border.BorderBrushProperty, Brush("DimTextBrush"));
ring.SetValue(Border.BackgroundProperty, Brush("RadioWellBrush"));
ring.SetValue(Border.VerticalAlignmentProperty, VerticalAlignment.Center);
ring.SetValue(Border.MarginProperty, new Thickness(0, 1, 7, 0)); // +1 top settles it against the text optical center
var dot = new FrameworkElementFactory(typeof(Border)) { Name = "dot" };
dot.SetValue(Border.WidthProperty, 7.0);
dot.SetValue(Border.HeightProperty, 7.0);
dot.SetValue(Border.CornerRadiusProperty, new CornerRadius(3.5));
dot.SetValue(Border.BackgroundProperty, Brush("RadioAccent"));
dot.SetValue(Border.HorizontalAlignmentProperty, HorizontalAlignment.Center);
dot.SetValue(Border.VerticalAlignmentProperty, VerticalAlignment.Center);
dot.SetValue(UIElement.VisibilityProperty, Visibility.Collapsed);
ring.AppendChild(dot);
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
sp.AppendChild(ring);
sp.AppendChild(cp);
var ct = new ControlTemplate(typeof(RadioButton)) { VisualTree = sp };
var on = new Trigger { Property = ToggleButton.IsCheckedProperty, Value = true };
on.Setters.Add(new Setter(UIElement.VisibilityProperty, Visibility.Visible) { TargetName = "dot" });
on.Setters.Add(new Setter(Border.BorderBrushProperty, Brush("RadioAccent")) { TargetName = "ring" });
ct.Triggers.Add(on);
var off = new Trigger { Property = UIElement.IsEnabledProperty, Value = false };
off.Setters.Add(new Setter(UIElement.OpacityProperty, 0.4) { TargetName = "root" });
ct.Triggers.Add(off);
return ct;
}
// Themed single-line input, fully self-contained (templated from the theme brushes) so it renders
// correctly in ANY window without depending on a window-scoped XAML style. Kills the OS-default
// white box / blue focus + selection chrome.
public static TextBox Field(double width = double.NaN)
{
var tb = new TextBox
{
FontFamily = UiFont,
FontSize = 12,
Background = Brush("TextFieldBrush", Brush("BgCanvas")),
Foreground = Brush("TextBrush"),
BorderBrush = Brush("CardBorderBrush"),
BorderThickness = new Thickness(1),
Padding = new Thickness(6, 4, 6, 4),
CaretBrush = Brush("TextBrush"),
SelectionBrush = Brush("RowSelectedBrush"),
SelectionTextBrush = Brush("TextBrush"),
Template = FieldTemplate()
};
if (!double.IsNaN(width)) tb.Width = width;
return tb;
}
private static ControlTemplate FieldTemplate()
{
var root = new FrameworkElementFactory(typeof(Grid));
var b = new FrameworkElementFactory(typeof(Border));
b.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetBinding(Border.BorderBrushProperty, new Binding("BorderBrush") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetBinding(Border.BorderThicknessProperty, new Binding("BorderThickness") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetValue(Border.CornerRadiusProperty, RadControl);
var sv = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" };
sv.SetValue(Control.PaddingProperty, new Thickness(0));
b.AppendChild(sv);
root.AppendChild(b);
var dark = new FrameworkElementFactory(typeof(Border));
dark.SetValue(UIElement.IsHitTestVisibleProperty, false);
dark.SetValue(Border.CornerRadiusProperty, RadControl);
dark.SetResourceReference(Border.BorderBrushProperty, "BevelDarkBrush");
dark.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenDarkThickness");
root.AppendChild(dark);
var light = new FrameworkElementFactory(typeof(Border));
light.SetValue(UIElement.IsHitTestVisibleProperty, false);
light.SetValue(Border.CornerRadiusProperty, RadControl);
light.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
light.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenLightThickness");
root.AppendChild(light);
return new ControlTemplate(typeof(TextBox)) { VisualTree = root };
}
// Themed PasswordBox matching Field(): our border/fill, no OS white box or blue focus chrome.
public static PasswordBox PasswordField(double width = double.NaN)
{
var pb = new PasswordBox
{
FontFamily = UiFont,
FontSize = 12,
Background = Brush("TextFieldBrush", Brush("BgCanvas")),
Foreground = Brush("TextBrush"),
BorderBrush = Brush("CardBorderBrush"),
BorderThickness = new Thickness(1),
Padding = new Thickness(6, 5, 6, 5),
CaretBrush = Brush("TextBrush"),
Template = PasswordFieldTemplate()
};
if (!double.IsNaN(width)) pb.Width = width;
return pb;
}
private static ControlTemplate PasswordFieldTemplate()
{
var root = new FrameworkElementFactory(typeof(Grid));
var b = new FrameworkElementFactory(typeof(Border));
b.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetBinding(Border.BorderBrushProperty, new Binding("BorderBrush") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetBinding(Border.BorderThicknessProperty, new Binding("BorderThickness") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
b.SetValue(Border.CornerRadiusProperty, RadControl);
var sv = new FrameworkElementFactory(typeof(ScrollViewer)) { Name = "PART_ContentHost" };
sv.SetValue(Control.PaddingProperty, new Thickness(0));
b.AppendChild(sv);
root.AppendChild(b);
var dark = new FrameworkElementFactory(typeof(Border));
dark.SetValue(UIElement.IsHitTestVisibleProperty, false);
dark.SetValue(Border.CornerRadiusProperty, RadControl);
dark.SetResourceReference(Border.BorderBrushProperty, "BevelDarkBrush");
dark.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenDarkThickness");
root.AppendChild(dark);
var light = new FrameworkElementFactory(typeof(Border));
light.SetValue(UIElement.IsHitTestVisibleProperty, false);
light.SetValue(Border.CornerRadiusProperty, RadControl);
light.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
light.SetResourceReference(Border.BorderThicknessProperty, "CheckSunkenLightThickness");
root.AppendChild(light);
return new ControlTemplate(typeof(PasswordBox)) { VisualTree = root };
}
// Wraps a dialog's document/preview pane with the family drop shadow: a SEPARATE sibling
// border underneath (content must never render through a bitmap effect or it loses
// ClearType), carrying the per-theme PaneShadowEffect - which is null on 98SE, so the
// classic theme stays flat. Dialogs read as mini main windows this way.
public static Grid PaneWithShadow(Border pane)
{
// Code-built preview panes used to carry their own hard-coded radius, so 98SE could
// never square them. The theme only supplies this override when it needs one.
if (Application.Current?.TryFindResource("PaneCornerRadiusValue") is double radius)
pane.CornerRadius = new CornerRadius(radius);
var shadow = new Border
{
Margin = pane.Margin,
CornerRadius = pane.CornerRadius,
IsHitTestVisible = false,
};
shadow.SetResourceReference(Border.BackgroundProperty, "BgCanvas");
shadow.SetResourceReference(UIElement.EffectProperty, "PaneShadowEffect");
var host = new Grid();
host.Children.Add(shadow);
host.Children.Add(pane);
// The main document pane uses these same four bevel rings. They are transparent and
// zero-width on modern themes, while 98SE gets its square two-stage classic recess.
var bevels = new Grid { Margin = pane.Margin, IsHitTestVisible = false };
Border Ring(string brushKey, string thicknessKey, bool inner = false)
{
var ring = new Border { CornerRadius = pane.CornerRadius };
ring.SetResourceReference(Border.BorderBrushProperty, brushKey);
ring.SetResourceReference(Border.BorderThicknessProperty, thicknessKey);
if (inner)
ring.SetResourceReference(FrameworkElement.MarginProperty, "PaneBevelInnerMargin");
return ring;
}
bevels.Children.Add(Ring("PaneBevelDarkBrush", "PaneBevelLightThickness"));
bevels.Children.Add(Ring("PaneBevelLightBrush", "PaneBevelDarkThickness"));
bevels.Children.Add(Ring("PaneBevelDark2Brush", "PaneBevel2LightThickness", inner: true));
bevels.Children.Add(Ring("PaneBevelLight2Brush", "PaneBevel2DarkThickness", inner: true));
host.Children.Add(bevels);
return host;
}
// A dialog section heading (e.g. "ROTATE", "PAGE NUMBERS").
public static TextBlock SectionHeader(string text) => new()
{
Text = text,
FontFamily = MonoFont,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = Brush("TextBrush"),
Margin = new Thickness(0, 0, 0, 6),
Effect = ShadowText()
};
// A small secondary label sitting above/beside a field.
public static TextBlock GroupLabel(string text) => new()
{
Text = text,
FontFamily = UiFont,
FontSize = 11,
Foreground = Brush("MutedTextBrush"),
Margin = new Thickness(0, 0, 0, 2)
};
// Right-aligned row of dialog buttons with a consistent 8px gap. Pass buttons left-to-right.
public static StackPanel ButtonRow(params Button[] buttons)
{
var row = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
for (int i = 0; i < buttons.Length; i++)
{
if (i > 0) buttons[i].Margin = new Thickness(8, 0, 0, 0);
row.Children.Add(buttons[i]);
}
return row;
}
// A flat text "link" (e.g. "Reset all") with an accent hover, for low-emphasis dialog actions.
public static TextBlock LinkLabel(string text, Action onClick)
{
var link = new TextBlock
{
Text = text,
FontFamily = UiFont,
FontSize = 12,
Foreground = Brush("MutedTextBrush"),
Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center
};
link.MouseEnter += (_, _2) => link.Foreground = Brush("PrimaryBrush");
link.MouseLeave += (_, _2) => link.Foreground = Brush("MutedTextBrush");
link.MouseLeftButtonUp += (_, _2) => onClick();
return link;
}
// Dialog/popup buttons. accent==true is the primary (fills solid accent on hover); false is secondary.
public static Button Make(object content, bool accent)
{
if (KillerPDF.Services.ThemeManager.Current == KillerPDF.Services.Theme.SE98)
{
var button = Make(content, Brush("ChipFaceBrush"), Brush("ChipFaceBrush"),
Brush("TextBrush"), Brush("TextBrush"), Brushes.Transparent);
// Keep an already-open code-built surface attached to the live palette. The
// explicit-color factory below is also used by pre-theme startup dialogs, so the
// resource references belong here in the normal themed overload.
button.SetResourceReference(Control.BackgroundProperty, "ChipFaceBrush");
button.SetResourceReference(Control.ForegroundProperty, "TextBrush");
button.Template = BeveledButtonTemplate();
return button;
}
var themed = accent
? Make(content, Brush("SelectionBg"), Brush("PrimaryBrush"), Brush("SelectionFg"), Brush("OnPrimaryBrush"), Brush("PrimaryBrush"))
: Make(content, Brush("PaneBrush"), Brush("RowHoverBrush"), Brush("TextBrush"), Brush("TextBrush"), Brush("CardBorderBrush"));
// Make(object,bool) is used by long-lived annotation bars and modeless tool windows.
// A local brush value would preserve the palette that happened to be active when the
// control was constructed. Re-attach each state to resource keys so theme and accent
// changes repaint the existing button instead of leaving an old-colored island.
void ApplyRest()
{
themed.SetResourceReference(Control.BackgroundProperty, accent ? "SelectionBg" : "PaneBrush");
themed.SetResourceReference(Control.ForegroundProperty, accent ? "SelectionFg" : "TextBrush");
themed.SetResourceReference(Control.BorderBrushProperty, accent ? "PrimaryBrush" : "CardBorderBrush");
}
void ApplyHover()
{
themed.SetResourceReference(Control.BackgroundProperty, accent ? "PrimaryBrush" : "RowHoverBrush");
themed.SetResourceReference(Control.ForegroundProperty, accent ? "OnPrimaryBrush" : "TextBrush");
themed.SetResourceReference(Control.BorderBrushProperty, accent ? "PrimaryBrush" : "CardBorderBrush");
}
// These handlers are registered after the explicit-color factory's handlers, so the
// resource-backed values win and remain live for the current palette.
themed.MouseEnter += (_, _) => ApplyHover();
themed.MouseLeave += (_, _) => ApplyRest();
ApplyRest();
return themed;
}
private static ControlTemplate BeveledButtonTemplate()
{
var grid = new FrameworkElementFactory(typeof(Grid));
var face = new FrameworkElementFactory(typeof(Border)) { Name = "face" };
face.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
face.SetBinding(Border.PaddingProperty, new Binding("Padding") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
cp.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
face.AppendChild(cp);
grid.AppendChild(face);
var light = new FrameworkElementFactory(typeof(Border)) { Name = "light" };
light.SetResourceReference(Border.BorderBrushProperty, "BevelLightBrush");
light.SetResourceReference(Border.BorderThicknessProperty, "ButtonBevelLightThickness");
grid.AppendChild(light);
var dark = new FrameworkElementFactory(typeof(Border)) { Name = "dark" };
dark.SetResourceReference(Border.BorderBrushProperty, "BevelDarkBrush");
dark.SetResourceReference(Border.BorderThicknessProperty, "ButtonBevelDarkThickness");
grid.AppendChild(dark);
var template = new ControlTemplate(typeof(Button)) { VisualTree = grid };
var pressed = new Trigger { Property = Button.IsPressedProperty, Value = true };
pressed.Setters.Add(new Setter(Border.BorderBrushProperty, Brush("BevelDarkBrush"), "light"));
pressed.Setters.Add(new Setter(Border.BorderBrushProperty, Brush("BevelLightBrush"), "dark"));
template.Triggers.Add(pressed);
return template;
}
// Explicit-color overload for pre-theme windows (startup/crash/About). border==null = borderless.
public static Button Make(object content, Brush normalBg, Brush hoverBg, Brush normalFg, Brush hoverFg, Brush? border = null)
{
var btn = new Button
{
Content = content,
Padding = new Thickness(18, 6, 18, 6),
Background = normalBg,
Foreground = normalFg,
BorderBrush = border ?? Brushes.Transparent,
BorderThickness = new Thickness(border == null ? 0 : 1),
Cursor = Cursors.Hand,
FontFamily = UiFont,
FontSize = 12,
FocusVisualStyle = null,
Template = ButtonTemplate(),
};
btn.MouseEnter += (_, _) => { btn.Background = hoverBg; btn.Foreground = hoverFg; };
btn.MouseLeave += (_, _) => { btn.Background = normalBg; btn.Foreground = normalFg; };
return btn;
}
internal static ControlTemplate ButtonTemplate()
{
var bf = new FrameworkElementFactory(typeof(Border)) { Name = "bd" };
bf.SetBinding(Border.BackgroundProperty, new Binding("Background") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
bf.SetBinding(Border.BorderBrushProperty, new Binding("BorderBrush") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
bf.SetBinding(Border.BorderThicknessProperty, new Binding("BorderThickness") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
bf.SetBinding(Border.PaddingProperty, new Binding("Padding") { RelativeSource = new RelativeSource(RelativeSourceMode.TemplatedParent) });
bf.SetValue(Border.CornerRadiusProperty, RadControl);
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
cp.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
bf.AppendChild(cp);
var ct = new ControlTemplate(typeof(Button)) { VisualTree = bf };
var dis = new Trigger { Property = UIElement.IsEnabledProperty, Value = false };
dis.Setters.Add(new Setter(UIElement.OpacityProperty, 0.45) { TargetName = "bd" });
ct.Triggers.Add(dis);
return ct;
}
}
}
File diff suppressed because it is too large Load Diff
+213
View File
@@ -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);
}
}
+143
View File
@@ -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.
}
}
+200
View File
@@ -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);
}
}
+702
View File
@@ -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
+675
View File
@@ -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;
}
}
}
+184
View File
@@ -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;
}
}
}
+482
View File
@@ -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();
}
}
}
}
+723
View File
@@ -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();
}
}
}
+865
View File
@@ -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.
}
}
+192
View File
@@ -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;
}
}
+868
View File
@@ -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
+359
View File
@@ -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;
}
}
}
+594
View File
@@ -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="&#xE70D;" 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="&#xE711;"
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>
+172
View File
@@ -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;
}
}
+33
View File
@@ -0,0 +1,33 @@
using System;
using System.Windows;
using System.Windows.Media.Animation;
namespace KillerPDF
{
// Fade a window out on close: cancel the first close, animate opacity to 0, then close for real.
// DialogResult is set before Closing fires, so it survives the deferral.
internal static class WindowFx
{
public const int FadeMs = 150;
public static void EnableFadeClose(Window w, int ms = FadeMs)
{
bool fading = false;
bool readyToClose = false;
w.Closing += (s, e) =>
{
if (readyToClose) return; // our own post-fade Close - let it through
e.Cancel = true; // hold off the real close until the fade finishes
if (fading) return; // already fading - ignore repeat triggers
fading = true;
var anim = new DoubleAnimation(w.Opacity, 0, new Duration(TimeSpan.FromMilliseconds(ms)))
{
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
};
anim.Completed += (_, _) => { readyToClose = true; w.Close(); };
w.BeginAnimation(UIElement.OpacityProperty, anim);
};
}
}
}